diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md deleted file mode 100644 index 4b30407..0000000 --- a/IMPROVEMENTS.md +++ /dev/null @@ -1,28 +0,0 @@ -# TK-MP-Project Backend 개선/확장/운영 권장사항 - -## 1. 코드 구조/품질 -- ResponseModel(Pydantic) 적용: API 반환값의 타입 안정성 및 문서화 강화 -- 로깅/에러 처리: print → logging 모듈, 운영 환경에 맞는 에러/이벤트 기록 -- 환경변수/설정 분리: CORS, DB, 포트 등 환경별 관리 용이하게 분리 -- 라우터 자동 등록/동적 관리: 라우터가 많아질 경우 코드 중복 최소화 - -## 2. 보안/운영 -- CORS 제한: 운영 환경에서는 허용 origin을 제한 -- 업로드 파일 검증 강화: 경로, 파일명, 크기 등 보안 검증 추가 - -## 3. 성능/확장성 -- 대용량 파일/데이터 처리: 비동기/청크 처리, 인덱스 튜닝 등 -- DB 트랜잭션 명확화: 파일/자재 저장 등에서 트랜잭션 관리 강화 - -## 4. 테스트/CI -- 자동화 테스트(assert 기반): print 위주 → assert 기반 자동화로 CI/CD 연동 -- 테스트 커버리지 확대: 다양한 예외/경계 케이스 추가 - -## 5. 기타 -- 코드/유틸 함수 분리: 중복 유틸 함수는 별도 모듈로 분리 -- 상태/활성화 관리 enum화: status 등은 enum으로 관리 -- 삭제/수정 API 추가: Job 등 주요 엔티티의 논리적 삭제/수정 지원 - ---- - -*2024-07-15 기준, backend 코드 리뷰 기반 개선/확장/운영 권장사항 정리* \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index ba8dfa9..0000000 --- a/README.md +++ /dev/null @@ -1,123 +0,0 @@ -아! 이해했습니다! 😅 -cat > README.md << 'EOF' 명령어에서 EOF까지의 모든 내용을 한 번에 입력하라는 뜻이에요. -즉, 이 전체 부분을 한 번에 복사해서 터미널에 붙여넣기하면 됩니다: -bashcat > README.md << 'EOF' -# 🚀 TK-MP-Project: BOM 시스템 개발 프로젝트 - -## 📋 프로젝트 개요 -BOM (Bill of Materials) 시스템의 기능 이상을 해결하고, 도면 완성 후 자재 관리의 모든 프로세스를 자동화하는 종합 시스템 개발 프로젝트입니다. - -## 🎯 프로젝트 목표 - -### 핵심 미션 -**"도면 완성 후 자재 관리의 모든 번거로움을 해결"** - -### 주요 해결 과제 -- 📄 **파일 분석 자동화**: 엑셀/CSV 자재 목록의 자동 분류 및 정제 -- 🔍 **정확한 분류 체계**: 파이프/피팅/볼트/밸브/계기류의 4단계 자동 분류 -- 💾 **체계적 데이터 관리**: 프로젝트별 버전 관리 및 이력 추적 -- 📊 **업무별 맞춤 출력**: 구매/생산/품질 각 팀의 필요에 맞는 자료 생성 -- 🔄 **리비전 변화 추적**: 도면 변경 시 자재 변경사항 자동 비교 - -## 💻 기술 스택 - -### Backend -- **Language**: Python 3.9+ -- **Framework**: FastAPI (고성능 API 서버) -- **Database**: PostgreSQL 15 (복잡한 관계형 데이터 처리) -- **ORM**: SQLAlchemy (데이터베이스 모델링) -- **Data Processing**: Pandas, openpyxl (파일 처리) - -### DevOps & Tools -- **Containerization**: Docker & Docker Compose -- **Version Control**: Git (Gitea 호스팅) -- **Development**: VS Code + Python 확장 - -## 🌐 개발 환경 설정 - -### Git 저장소 접속 -```bash -# VPN 연결 필요: vpn.hyungi.net:21194 -git clone http://192.168.1.227:10300/hyungi/TK-MP-Project.git -cd TK-MP-Project -데이터베이스 실행 -bash# PostgreSQL 및 pgAdmin 실행 -docker-compose up -d postgres pgadmin redis - -# 접속 확인 -# pgAdmin: http://localhost:5050 (admin@tkmp.local / admin2025) -Python 개발 환경 -bash# 가상환경 생성 -python -m venv venv -source venv/bin/activate # macOS/Linux - -# 의존성 설치 -pip install -r backend/requirements.txt - -# 개발 서버 실행 -cd backend -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -📁 프로젝트 구조 -TK-MP-Project/ -├── README.md -├── docker-compose.yml -├── backend/ # FastAPI 백엔드 -│ ├── app/ -│ │ ├── models/ # SQLAlchemy 모델 -│ │ ├── schemas/ # Pydantic 스키마 -│ │ ├── api/ # API 라우터 -│ │ ├── core/ # 설정 및 유틸리티 -│ │ ├── services/ # 비즈니스 로직 -│ │ └── database/ # DB 연결 설정 -│ └── requirements.txt -├── database/ # DB 스키마 및 초기 데이터 -│ └── init/ -│ └── 01_schema.sql -└── docs/ # 프로젝트 문서 -🚀 개발 로드맵 -Phase 1: 기반 시스템 구축 (진행중) - - Git 환경 구축 ✅ - 데이터베이스 스키마 설계 ✅ - Docker 개발 환경 설정 ✅ - FastAPI 기본 구조 구현 - 파일 업로드 및 파싱 기능 - -Phase 2: 핵심 기능 개발 - - 자재 분류 알고리즘 구현 - 웹 인터페이스 구축 - 구매 BOM 생성 기능 - -Phase 3: 고도화 - - 리비전 비교 기능 - 파이프 cutting 자료 생성 - 사용자 테스트 및 최적화 - -🗄️ 데이터베이스 스키마 -핵심 테이블 - -projects: 프로젝트 관리 (코드 매칭, 버전 관리) -files: 업로드된 자재 목록 파일들 -materials: 개별 자재 상세 정보 (분류 결과 포함) - -주요 기능 - -프로젝트별 파일 버전 관리 (Rev.0, Rev.1, Rev.2) -자재 자동 분류 시스템 (카테고리, 재질, 사이즈) -분류 신뢰도 및 사용자 검증 시스템 - -📞 개발팀 - -Lead Developer: hyungi -Gitea Repository: http://192.168.1.227:10300/hyungi/TK-MP-Project - -🎯 다음 단계 - -데이터베이스 실행: docker-compose up -d postgres pgadmin -Python 환경 구축: 가상환경 생성 및 패키지 설치 -FastAPI 구조 구현: 기본 API 서버 및 모델 생성 - - -Last Updated: 2025.07.14 diff --git a/REVIEW.md b/REVIEW.md deleted file mode 100644 index f20620a..0000000 --- a/REVIEW.md +++ /dev/null @@ -1,33 +0,0 @@ -# TK-MP-Project Backend 코드 리뷰 요약 - -## 1. 전체 구조 -- FastAPI + SQLAlchemy 기반 백엔드 -- models, schemas, routers, services, api, uploads 등 역할별 디렉토리 분리 -- 자재/BOM/스풀/계장 등 플랜트/조선/기계 실무에 특화된 구조 - -## 2. 주요 코드 검토 -- **main.py**: 앱 진입점, CORS, 라우터 등록, 헬스체크 등 -- **routers/**: 파일, 작업(Job) 등 API 엔드포인트 구현 -- **services/**: 품목별 분류기(볼트, 밸브, 플랜지, 피팅, 가스켓, 파이프, 계장 등), 스풀 관리, 테스트 코드 -- **material_classifier.py**: 재질 분류 공통 모듈, 규격/패턴/키워드 기반 robust 분류 -- **spool_manager.py/v2**: 도면-에어리어-스풀 넘버링, 유효성 검증, 자동 추천 등 -- **api/**: 과거 버전/백업/보조 코드(실제 서비스는 routers/가 메인) -- **테스트 코드**: 다양한 실무 케이스를 print 기반으로 커버(자동화는 미흡) -- **materials_schema.py**: 분류기에서 사용하는 규격/패턴/키워드/등급 등 데이터 정의 - -## 3. 품목별 분류기 구조 -- 볼트/밸브/플랜지/피팅/가스켓/파이프/계장 등 각 품목별로 dict 기반 패턴/키워드/규격 관리 -- material_classifier와 연동, 신뢰도/구매정보 등 실무적 정보 제공 -- 구조/로직은 유사하나, 각 품목별 실무 특성에 맞는 분류 포인트 반영 - -## 4. 테스트 코드 -- 다양한 실무 케이스를 print 기반으로 커버 -- 자동화(assert) 기반 테스트는 미흡(추후 개선 필요) - -## 5. materials_schema.py -- 분류기에서 사용하는 규격/패턴/키워드/등급 등 실무적 데이터가 체계적으로 구조화 -- 신규 규격/등급/패턴 추가/수정이 용이 - ---- - -*2024-07-15 기준, 전체 backend 코드 리뷰 및 구조 요약* \ No newline at end of file diff --git a/RULES.md b/RULES.md index 317ff15..6e55717 100644 --- a/RULES.md +++ b/RULES.md @@ -1,40 +1,279 @@ -# 🏗️ TK-MP-Project Rules & Context +# 🚀 TK-MP-Project: 통합 프로젝트 문서 -## 📋 **프로젝트 개요** -- **목적**: 배관 자재 BOM 관리 및 리비전 비교 시스템 -- **주요 기능**: 파일 업로드, 자재 분류, 리비전 비교, 구매 관리, 엑셀 내보내기 +> **최종 업데이트**: 2025년 1월 (통합 문서 생성) + +--- + +## 📋 프로젝트 개요 + +**TK-MP-Project**는 BOM (Bill of Materials) 시스템의 기능 이상을 해결하고, 도면 완성 후 자재 관리의 모든 프로세스를 자동화하는 종합 시스템 개발 프로젝트입니다. + +### 🎯 핵심 미션 +**"도면 완성 후 자재 관리의 모든 번거로움을 해결"** + +### 주요 해결 과제 +- 📄 **파일 분석 자동화**: 엑셀/CSV 자재 목록의 자동 분류 및 정제 +- 🔍 **정확한 분류 체계**: 파이프/피팅/볼트/밸브/계기류의 4단계 자동 분류 +- 💾 **체계적 데이터 관리**: 프로젝트별 버전 관리 및 이력 추적 +- 📊 **업무별 맞춤 출력**: 구매/생산/품질 각 팀의 필요에 맞는 자료 생성 +- 🔄 **리비전 변화 추적**: 도면 변경 시 자재 변경사항 자동 비교 + +--- + +## 💻 기술 스택 + +### Frontend +- **Language**: JavaScript (ES6+) +- **Framework**: React 18 + Vite (Material-UI 제거됨) +- **Router**: 상태 기반 라우팅 (React Router 대체) +- **HTTP Client**: Axios +- **File Processing**: XLSX (SheetJS), file-saver +- **Charts**: Chart.js + react-chartjs-2 +- **Styling**: 순수 HTML/CSS (인라인 스타일) + +### Backend +- **Language**: Python 3.9+ +- **Framework**: FastAPI (고성능 API 서버) +- **Database**: PostgreSQL 15 (복잡한 관계형 데이터 처리) +- **ORM**: SQLAlchemy (데이터베이스 모델링) +- **Data Processing**: Pandas, openpyxl (파일 처리) +- **Cache**: Redis 7 + +### DevOps & Tools +- **Containerization**: Docker & Docker Compose +- **Web Server**: Nginx (프로덕션) +- **Database Admin**: pgAdmin4 +- **Version Control**: Git +- **Development**: VS Code + Python 확장 + +--- + +## 🏗️ 코드 구조 및 컴포넌트 관계도 + +### 📁 프론트엔드 구조 -## 🛠️ **기술 스택** ``` -Frontend: React.js + Material-UI + Vite + React Router DOM + Nginx -Backend: FastAPI + SQLAlchemy + Python + Uvicorn -Database: PostgreSQL (운영 및 개발) -캐시: Redis -관리도구: pgAdmin4 -컨테이너: Docker + Docker Compose -기타: Axios, XLSX (SheetJS), file-saver +frontend/src/ +├── App.jsx # 메인 애플리케이션 (상태 기반 라우팅) +├── SimpleLogin.jsx # 로그인 컴포넌트 +├── api.js # API 클라이언트 (Axios 설정) +├── components/ # 재사용 가능한 컴포넌트 +│ ├── NavigationMenu.jsx # 사이드바 네비게이션 (권한 기반) +│ ├── BOMFileUpload.jsx # BOM 파일 업로드 폼 +│ ├── BOMFileTable.jsx # BOM 파일 목록 테이블 +│ └── RevisionUploadDialog.jsx # 리비전 업로드 다이얼로그 +└── pages/ # 페이지 컴포넌트 + ├── DashboardPage.jsx # 대시보드 + ├── ProjectsPage.jsx # 프로젝트 관리 + ├── JobSelectionPage.jsx # 프로젝트 선택 + ├── BOMStatusPage.jsx # BOM 관리 메인 + ├── SimpleMaterialsPage.jsx # 자재 목록 (Material-UI 제거됨) + ├── MaterialComparisonPage.jsx # 리비전 비교 + └── RevisionPurchasePage.jsx # 구매 확정 ``` -## 📁 **프로젝트 구조** +### 📁 백엔드 구조 + ``` -TK-MP-Project/ -├── frontend/src/ -│ ├── pages/ # 페이지 컴포넌트 -│ ├── components/ # 재사용 컴포넌트 -│ ├── utils/ # 유틸리티 (엑셀 등) -│ └── api.js # API 통신 -├── backend/app/ -│ ├── routers/ # API 라우터 -│ ├── services/ # 비즈니스 로직 (분류기 등) -│ ├── models.py # DB 모델 -│ └── main.py # FastAPI 앱 -└── database/ # DB 스키마/시드 +backend/ +├── app/ +│ ├── main.py # FastAPI 애플리케이션 진입점 +│ ├── config.py # 설정 관리 (Pydantic Settings) +│ ├── auth/ # 인증 모듈 +│ │ ├── __init__.py +│ │ ├── models.py # SQLAlchemy 인증 모델 +│ │ ├── jwt_service.py # JWT 토큰 관리 +│ │ ├── auth_service.py # 인증 비즈니스 로직 +│ │ ├── auth_controller.py # 인증 API 엔드포인트 +│ │ └── middleware.py # 권한 미들웨어 +│ ├── api/ # API 라우터 +│ │ └── file_management.py # 파일 관리 API +│ ├── routers/ # 기존 라우터 +│ │ ├── files.py # 파일/자재 API +│ │ └── jobs.py # 프로젝트 API +│ ├── services/ # 비즈니스 로직 서비스 +│ │ └── file_service.py # 파일 처리 서비스 +│ ├── utils/ # 유틸리티 +│ │ ├── logger.py # 로깅 설정 +│ │ ├── cache_manager.py # Redis 캐시 관리 +│ │ ├── file_validator.py # 파일 검증 +│ │ ├── error_handlers.py # 에러 핸들링 +│ │ └── transaction_manager.py # DB 트랜잭션 관리 +│ └── schemas/ # Pydantic 스키마 +│ └── response_models.py # API 응답 모델 +├── scripts/ # DB 마이그레이션 스크립트 +└── requirements.txt # Python 의존성 ``` -## 🐳 **Docker 실행 환경** +### 🔄 컴포넌트 관계도 + +```mermaid +graph TD + A[App.jsx] --> B[SimpleLogin.jsx] + A --> C[NavigationMenu.jsx] + A --> D[페이지 컴포넌트들] + + D --> E[JobSelectionPage.jsx] + D --> F[BOMStatusPage.jsx] + D --> G[SimpleMaterialsPage.jsx] + + F --> H[BOMFileUpload.jsx] + F --> I[BOMFileTable.jsx] + F --> J[RevisionUploadDialog.jsx] + + K[api.js] --> L[Backend APIs] + + subgraph "Backend Services" + L --> M[auth_controller.py] + L --> N[file_management.py] + L --> O[files.py] + L --> P[jobs.py] + end + + subgraph "Business Logic" + M --> Q[auth_service.py] + N --> R[file_service.py] + end + + subgraph "Data Layer" + Q --> S[auth/models.py] + R --> T[Database Tables] + end +``` + +### 🎯 컴포넌트 분리 원칙 (적용됨) + +#### 1. **페이지 컴포넌트** (200-300줄 이하) +- 전체 페이지 레이아웃과 상태 관리 +- 하위 컴포넌트들의 조합 +- API 호출 및 데이터 흐름 제어 + +#### 2. **기능별 컴포넌트** (100-150줄 이하) +- 단일 책임 원칙 적용 +- 재사용 가능한 독립적 기능 +- Props를 통한 데이터 전달 + +#### 3. **서비스 레이어** (200줄 이하) +- 비즈니스 로직 분리 +- API와 컴포넌트 사이의 중간 계층 +- 데이터 변환 및 검증 + +### 📋 분리된 컴포넌트 목록 + +| 원본 파일 | 분리 후 | 줄 수 변화 | 상태 | +|----------|---------|-----------|------| +| `MaterialsPage.jsx` | `SimpleMaterialsPage.jsx` | 1000+ → 300줄 | ✅ 완료 | +| `BOMStatusPage.jsx` | 3개 컴포넌트로 분리 | 400+ → 200줄 | ✅ 완료 | +| - | `BOMFileUpload.jsx` | 새로 생성 (100줄) | ✅ 완료 | +| - | `BOMFileTable.jsx` | 새로 생성 (150줄) | ✅ 완료 | +| - | `RevisionUploadDialog.jsx` | 새로 생성 (80줄) | ✅ 완료 | + +### 🔧 향후 분리 대상 + +| 파일명 | 현재 줄 수 | 분리 계획 | 우선순위 | +|--------|-----------|----------|---------| +| `MaterialComparisonPage.jsx` | 500줄+ | 비교 로직 분리 | 중간 | +| `RevisionPurchasePage.jsx` | 300줄+ | 구매 로직 분리 | 낮음 | +| `auth_service.py` | 300줄+ | 기능별 서비스 분리 | 높음 | + +### 🌐 API 엔드포인트 맵 + +#### 인증 API (`/auth/`) +``` +POST /auth/login # 로그인 +POST /auth/register # 사용자 등록 +POST /auth/refresh # 토큰 갱신 +POST /auth/logout # 로그아웃 +GET /auth/me # 현재 사용자 정보 +GET /auth/verify # 토큰 검증 +GET /auth/users # 사용자 목록 (관리자) +PUT /auth/users/{id} # 사용자 수정 (관리자) +``` + +#### 프로젝트 API (`/jobs/`) +``` +GET /jobs/ # 프로젝트 목록 +POST /jobs/ # 프로젝트 생성 +PUT /jobs/{id} # 프로젝트 수정 +DELETE /jobs/{id} # 프로젝트 삭제 +``` + +#### 파일/자재 API (`/files/`) +``` +GET /files # 파일 목록 (job_no 필터) +POST /files/upload # 파일 업로드 +DELETE /files/{id} # 파일 삭제 +GET /files/stats # 파일/자재 통계 +GET /files/materials # 자재 목록 (file_id 필터) +``` + +### 📊 데이터 흐름도 + +```mermaid +sequenceDiagram + participant U as User + participant F as Frontend + participant A as Auth API + participant B as BOM API + participant D as Database + participant R as Redis Cache + + U->>F: 로그인 요청 + F->>A: POST /auth/login + A->>D: 사용자 검증 + A->>F: JWT 토큰 반환 + F->>F: 토큰 저장 (localStorage) + + U->>F: BOM 페이지 접근 + F->>B: GET /jobs/ (프로젝트 목록) + B->>D: 프로젝트 조회 + B->>F: 프로젝트 데이터 + + U->>F: 프로젝트 선택 + F->>B: GET /files?job_no=xxx + B->>R: 캐시 확인 + alt 캐시 히트 + R->>B: 캐시된 데이터 + else 캐시 미스 + B->>D: 파일 목록 조회 + B->>R: 캐시 저장 + end + B->>F: 파일 목록 반환 + + U->>F: 자재 확인 클릭 + F->>B: GET /files/materials?file_id=xxx + B->>D: 자재 데이터 조회 + B->>F: 자재 목록 반환 +``` + +### 🔐 권한 체계 + +```mermaid +graph TD + A[사용자] --> B{역할} + B -->|admin| C[시스템 관리자] + B -->|user| D[일반 사용자] + B -->|viewer| E[조회 전용] + + C --> F[모든 권한] + D --> G[BOM 관리] + D --> H[프로젝트 관리] + E --> I[조회만 가능] + + F --> J[사용자 관리] + F --> K[시스템 설정] + G --> L[파일 업로드] + G --> M[자재 관리] + H --> N[프로젝트 CRUD] +``` + +--- + +## 🐳 Docker 실행 환경 + ### 컨테이너 구성 -- **tk-mp-frontend**: React + Nginx (포트: 3000) -- **tk-mp-backend**: FastAPI + Uvicorn (포트: 8000) +- **tk-mp-frontend**: React + Nginx (포트: 3000/13000) +- **tk-mp-backend**: FastAPI + Uvicorn (포트: 8000/18000) - **tk-mp-postgres**: PostgreSQL (포트: 5432) - **tk-mp-redis**: Redis (포트: 6379) - **tk-mp-pgadmin**: pgAdmin4 (포트: 5050) @@ -51,6 +290,9 @@ docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d # 또는 docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# 시놀로지 NAS 환경 +docker-compose -f docker-compose.synology.yml up -d + # 기본 환경 docker-compose up -d ``` @@ -62,48 +304,237 @@ docker-compose up -d - **데이터베이스 연결**: `postgres:5432` (컨테이너명 사용, localhost 아님!) - **환경변수**: `VITE_API_URL`로 API URL 오버라이드 가능 -## 🗄️ **핵심 데이터베이스 스키마** -```sql --- 핵심 테이블들 -jobs (job_no, job_name, client_name, ...) -files (id, job_no, revision, original_filename, ...) -materials (id, file_id, original_description, classified_category, quantity, ...) -pipe_details (material_id, length_mm, ...) --- 기타: fitting_details, flange_details, bolt_details, gasket_details +--- + +## 📁 프로젝트 구조 + +``` +TK-MP-Project/ +├── README.md +├── docker-compose.yml +├── docker-compose.dev.yml +├── docker-compose.prod.yml +├── docker-compose.synology.yml +├── frontend/ # React 프론트엔드 +│ ├── src/ +│ │ ├── pages/ # 페이지 컴포넌트 +│ │ ├── components/ # 재사용 컴포넌트 +│ │ ├── utils/ # 유틸리티 (엑셀 등) +│ │ └── api.js # API 통신 +│ ├── Dockerfile +│ └── nginx.conf +├── backend/ # FastAPI 백엔드 +│ ├── app/ +│ │ ├── routers/ # API 라우터 +│ │ ├── services/ # 비즈니스 로직 (분류기 등) +│ │ ├── models.py # DB 모델 +│ │ ├── main.py # FastAPI 앱 +│ │ └── database.py # DB 연결 설정 +│ ├── scripts/ # DB 마이그레이션 +│ ├── uploads/ # 업로드된 파일 +│ └── requirements.txt +├── database/ # DB 스키마 및 초기 데이터 +│ └── init/ +│ ├── 01_schema.sql +│ └── 02_seed_data.sql +└── scripts/ # 배포 스크립트 + ├── dev.sh + ├── prod.sh + └── deploy-synology.sh ``` -## 🔧 **중요한 코딩 컨벤션 & 패턴** +--- -### **1. 자재 분류 시스템** +## 🗄️ 핵심 데이터베이스 스키마 + +### 핵심 테이블들 +```sql +-- 프로젝트 관리 +jobs (job_no, job_name, client_name, end_user, epc_company, status, ...) + +-- 파일 관리 +files (id, job_no, revision, original_filename, bom_name, parsed_count, ...) + +-- 자재 정보 +materials (id, file_id, original_description, classified_category, quantity, ...) + +-- 자재별 상세 정보 +pipe_details (material_id, length_mm, ...) +fitting_details, flange_details, bolt_details, gasket_details, instrument_details +``` + +### 주요 기능 +- 프로젝트별 파일 버전 관리 (Rev.0, Rev.1, Rev.2) +- 자재 자동 분류 시스템 (카테고리, 재질, 사이즈) +- 분류 신뢰도 및 사용자 검증 시스템 + +--- + +## 🔧 중요한 코딩 컨벤션 & 패턴 + +### 1. 자재 분류 시스템 ```python # 항상 이 순서로 분류기 호출 classification_result = classify_pipe("", description, main_nom, length_value) # 결과: {"category": "PIPE", "confidence": 0.95, ...} ``` -### **2. 파이프 길이 처리 규칙** +### 2. 파이프 길이 처리 규칙 ```javascript // ❌ 절대 하지 말 것: 평균 길이 계산/표시 // ✅ 항상 할 것: 총 길이 기준 계산 const totalLength = quantity * unitLength; // 총 길이 = 수량 × 단위길이 ``` -### **3. 자재 해싱 규칙** +### 3. 자재 해싱 규칙 ```python # 자재 고유성 판단: description + size + material_grade material_hash = hashlib.md5(f"{description}|{size_spec}|{material_grade}".encode()).hexdigest() ``` -### **4. 리비전 비교 로직** +### 4. 리비전 비교 로직 ```python # 이전 리비전 자동 탐지: 숫자 기반 비교 current_rev_num = int(current_revision.replace("Rev.", "")) # Rev.0 → Rev.1 → Rev.2 순서 ``` -## 🐛 **자주 발생하는 이슈 & 해결법** +--- -### **1. 파이프 길이 합산 문제** +## 📏 **코드 분리 기준 및 품질 가이드라인** + +### 🎯 **코드 길이 기준 (2025.01 수립)** + +#### **함수/메서드** +- **이상적**: 10-20줄 +- **허용 가능**: 30줄 이하 +- **리팩토링 필요**: 50줄 이상 + +#### **파일** +- **이상적**: 200-300줄 +- **허용 가능**: 500줄 이하 +- **분리 필요**: 800줄 이상 + +#### **클래스** +- **이상적**: 100-200줄 +- **허용 가능**: 300줄 이하 +- **분리 필요**: 500줄 이상 + +### 🔧 **리팩토링 원칙** + +#### **1. 단일 책임 원칙 (SRP)** +```python +# ❌ 나쁜 예: 하나의 함수가 여러 일을 함 +def process_file_and_save_and_classify(file): + # 파일 처리 + 저장 + 분류 (3가지 책임) + pass + +# ✅ 좋은 예: 각각 분리 +def process_file(file): pass +def save_file(file): pass +def classify_materials(materials): pass +``` + +#### **2. 함수 분리 기준** +- 중복 코드 3회 이상 → 함수로 분리 +- 조건문이 3단계 이상 중첩 → 함수로 분리 +- 한 함수에서 5개 이상 변수 사용 → 클래스 고려 + +#### **3. 파일 분리 기준** +```python +# 기능별 분리 예시 +routers/ # API 엔드포인트 +├── files.py # 파일 관련 API +├── jobs.py # 작업 관련 API +└── materials.py # 자재 관련 API + +services/ # 비즈니스 로직 +├── classifiers/ # 분류기들 +├── validators/ # 검증 로직 +└── processors/ # 처리 로직 + +utils/ # 공통 유틸리티 +├── logger.py # 로깅 +├── validators.py # 검증 +└── helpers.py # 헬퍼 함수 +``` + +### 🛡️ **보안 코딩 가이드라인** + +#### **1. 파일 업로드 보안** +```python +# ✅ 필수 검증 항목 +- 파일 확장자 검증 +- 파일 크기 제한 (50MB) +- MIME 타입 검증 (실제 내용 확인) +- 파일명 보안 검증 (위험 문자 차단) +- 업로드 경로 제한 +``` + +#### **2. CORS 설정** +```python +# ❌ 절대 금지: 운영 환경에서 모든 도메인 허용 +allow_origins=["*"] + +# ✅ 환경별 제한된 도메인만 허용 +CORS_ORIGINS = { + "development": ["http://localhost:3000", "http://localhost:5173"], + "production": ["https://your-domain.com"], + "synology": ["http://192.168.0.3:10173"] +} +``` + +#### **3. 에러 처리 보안** +```python +# ❌ 민감한 정보 노출 금지 +return {"error": f"Database connection failed: {db_password}"} + +# ✅ 안전한 에러 메시지 +return {"error": "데이터베이스 연결에 실패했습니다."} +# 상세 에러는 로그에만 기록 +logger.error(f"DB connection failed: {detailed_error}") +``` + +### 📊 **로깅 가이드라인** + +#### **1. 로그 레벨 사용법** +```python +logger.debug("디버깅 정보 (개발 시에만)") +logger.info("일반적인 정보 (정상 동작)") +logger.warning("주의가 필요한 상황") +logger.error("에러 발생 (복구 가능)") +logger.critical("심각한 에러 (시스템 중단)") +``` + +#### **2. 로그 메시지 형식** +```python +# ✅ 좋은 로그 메시지 +logger.info(f"파일 업로드 완료 - 파일명: {filename}, 크기: {file_size} bytes, 사용자: {user_id}") +logger.error(f"자재 분류 실패 - 파일ID: {file_id}, 에러: {error_msg}", exc_info=True) + +# ❌ 나쁜 로그 메시지 +logger.info("파일 업로드됨") +logger.error("에러 발생") +``` + +#### **3. 민감 정보 로깅 금지** +```python +# ❌ 절대 로깅하면 안 되는 정보 +- 비밀번호, API 키 +- 개인정보 (이메일, 전화번호) +- 데이터베이스 연결 정보 + +# ✅ 로깅해도 되는 정보 +- 파일명, 파일 크기 +- 작업 ID, 사용자 ID (해시된 값) +- 처리 시간, 상태 정보 +``` + +--- + +## 🐛 자주 발생하는 이슈 & 해결법 + +### 1. 파이프 길이 합산 문제 ```python # ❌ 잘못된 SQL: GROUP BY에 pd.length_mm 포함 # ✅ 올바른 방법: Python에서 같은 파이프들 합치기 @@ -112,7 +543,7 @@ if material_hash in materials_dict: existing["total_length"] += new_quantity * unit_length ``` -### **2. 프론트엔드 변수 초기화** +### 2. 프론트엔드 변수 초기화 ```javascript // ❌ 사용 전에 선언하지 않음 const summaryData = [..., consolidatedMaterials.length, ...]; @@ -123,34 +554,38 @@ const consolidatedMaterials = consolidateMaterials(materials); const summaryData = [..., consolidatedMaterials.length, ...]; ``` -### **3. API 응답 처리** +### 3. API 응답 처리 ```javascript // ✅ 항상 Axios 응답 구조 확인 setComparisonResult(result.data || result); // response.data 우선 ``` -## 🎯 **UI/UX 가이드라인** +--- -### **1. 자재 표시 규칙** +## 🎯 UI/UX 가이드라인 + +### 1. 자재 표시 규칙 - **파이프**: "총 길이: 4,561mm" (평균단위 표시 금지) - **기타 자재**: "수량: 24 EA" - **변경사항**: "이전: 2,781mm → 현재: 4,561mm / 변화: +1,780mm" -### **2. 버튼 네이밍** +### 2. 버튼 네이밍 - "BOM 목록으로" (뒤로가기) - "엑셀 내보내기" - "상세 비교 보기" -### **3. 페이지 네비게이션** +### 3. 페이지 네비게이션 ```javascript // BOM 관련 페이지들은 job_no 기준으로 이동 navigate(`/bom-status?job_no=${jobNo}`); navigate(`/material-comparison?job_no=${jobNo}&revision=${revision}`); ``` -## 🔄 **개발 워크플로우** +--- -### **1. 서버 실행 명령어** +## 🔄 개발 워크플로우 + +### 1. 서버 실행 명령어 ```bash # 백엔드 실행 (터미널 1번) - TK-MP-Project 루트에서 source venv/bin/activate # 가상환경 활성화 (venv는 루트에 있음) @@ -167,49 +602,43 @@ npm run dev # npm start 아님! - API 문서: http://localhost:8000/docs - 프론트엔드: http://localhost:5173 -### **2. 백엔드 변경 시** +### 2. 백엔드 변경 시 ```bash # 항상 가상환경에서 실행 (사용자 선호사항) cd backend python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` -### **3. 데이터베이스 스키마 변경 시** +### 3. 데이터베이스 스키마 변경 시 ```sql -- scripts/ 폴더에 마이그레이션 SQL 파일 생성 -- 번호 순서: 01_, 02_, 03_... ``` -### **4. 커밋 메시지** +### 4. 커밋 메시지 ``` 한국어로 작성 (사용자 선호사항) 예: "파이프 길이 계산 및 엑셀 내보내기 버그 수정" ``` -## ⚠️ **절대 하지 말아야 할 것들** +--- -1. **파이프 "평균단위" 표시** - 사용자가 혼란스러워함 -2. **하드코딩된 길이 값** - 실제 데이터베이스 값 사용 -3. **영어 커밋 메시지** - 사용자가 한국어 선호 -4. **SQL에서 과도한 GROUP BY** - 같은 자재 분리됨 -5. **비율 기반 길이 계산** - 실제 총길이 사용해야 함 +## 💰 구매 수량 계산 규칙 -## 💰 **구매 수량 계산 규칙** - -### **1. 파이프 (PIPE)** +### 1. 파이프 (PIPE) ```javascript // 6,000mm 단위 판매 + 절단여유분 2mm/조각 const cutLength = originalLength + 2; // 절단 여유분 const pipeCount = Math.ceil(cutLength / 6000); // 올림 처리 ``` -### **2. 피팅/계기/밸브 (FITTING/INSTRUMENT/VALVE)** +### 2. 피팅/계기/밸브 (FITTING/INSTRUMENT/VALVE) ```javascript // BOM 수량 그대로 const purchaseQuantity = bomQuantity; ``` -### **3. 볼트/너트 (BOLT)** +### 3. 볼트/너트 (BOLT) ```javascript // +5% 후 4의 배수로 올림 const withMargin = bomQuantity * 1.05; @@ -217,27 +646,133 @@ const purchaseQuantity = Math.ceil(withMargin / 4) * 4; // 예: 150 → 157.5 → 160 SETS ``` -### **4. 가스켓 (GASKET)** +### 4. 가스켓 (GASKET) ```javascript // 5의 배수로 올림 const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5; // 예: 7 → 10 EA ``` -## 🎯 **현재 진행 상황** -- ✅ 자재 업로드 및 분류 시스템 -- ✅ 리비전 비교 기능 -- ✅ 파이프 길이 합산 로직 수정 -- ✅ 엑셀 내보내기 기능 -- 🚧 구매 수량 계산 시스템 (진행 중) +--- -## 📚 **추가 참고사항** -- 사용자는 가상환경에서 Python 실행을 선호 -- 백엔드 서버는 자동 재시작되므로 수동 재시작 불필요 -- 작업 상태는 'in-progress'와 'complete'를 명확히 표시 +## ⚠️ 절대 하지 말아야 할 것들 + +1. **파이프 "평균단위" 표시** - 사용자가 혼란스러워함 +2. **하드코딩된 길이 값** - 실제 데이터베이스 값 사용 +3. **영어 커밋 메시지** - 사용자가 한국어 선호 +4. **SQL에서 과도한 GROUP BY** - 같은 자재 분리됨 +5. **비율 기반 길이 계산** - 실제 총길이 사용해야 함 --- -## 🚨 **Docker 실행 관련 트러블슈팅** + +## 🚀 개발 로드맵 + +### Phase 1: 기반 시스템 구축 ✅ (완료) +- [x] Git 환경 구축 +- [x] 데이터베이스 스키마 설계 +- [x] Docker 개발 환경 설정 +- [x] FastAPI 기본 구조 구현 +- [x] 파일 업로드 및 파싱 기능 + +### Phase 2: 핵심 기능 개발 ✅ (완료) +- [x] 자재 분류 알고리즘 구현 +- [x] 웹 인터페이스 구축 +- [x] 구매 BOM 생성 기능 +- [x] 리비전 비교 기능 +- [x] 엑셀 내보내기 기능 + +### Phase 3: 고도화 🚧 (진행 중) +- [x] 리비전 비교 기능 +- [x] 파이프 cutting 자료 생성 +- [ ] 구매 수량 계산 시스템 완성 +- [ ] 튜빙 시스템 완성 +- [ ] 사용자 테스트 및 최적화 + +--- + +## 🎯 현재 진행 상황 + +### ✅ 완료된 기능들 +- 자재 업로드 및 분류 시스템 (파이프, 피팅, 볼트, 밸브, 가스켓, 계기류) +- 리비전 비교 기능 +- 파이프 길이 합산 로직 수정 +- 엑셀 내보내기 기능 +- Docker 환경 구성 (개발/프로덕션/시놀로지) + +### 🚧 진행 중인 기능들 +- 구매 수량 계산 시스템 +- 튜빙 시스템 + +### 🔒 **Phase 1: 보안 & 안정성 개선 완료 (2025.01)** + +#### **1. CORS 설정 환경별 분리** ✅ +- **파일**: `backend/app/config.py` - 중앙화된 설정 관리 +- **환경변수**: `backend/env.example` - 설정 가이드 +- **보안 강화**: `allow_origins=["*"]` → 환경별 제한된 도메인 + +#### **2. 로깅 시스템 구축** ✅ +- **파일**: `backend/app/utils/logger.py` - 구조화된 로깅 +- **기능**: 파일 로테이션, 레벨별 로깅, 중앙화된 로거 관리 +- **적용**: print() → logger로 교체 (446개 print문 중 주요 부분) + +#### **3. 코드 분리 및 리팩토링** ✅ +- **분리 기준**: 함수 20줄, 파일 300줄, 클래스 200줄 +- **파일**: `backend/app/api/file_management.py` - 파일 API 분리 +- **구조 개선**: main.py 기능별 분리로 유지보수성 향상 + +#### **4. 파일 업로드 검증 강화** ✅ +- **파일**: `backend/app/utils/file_validator.py` - 종합 파일 검증 +- **보안 기능**: + - 파일 크기 제한 (50MB) + - 확장자 검증 (.xlsx, .xls, .csv) + - MIME 타입 검증 (실제 파일 내용 확인) + - 파일명 보안 검증 (위험 문자 차단) + +#### **5. 에러 처리 표준화** ✅ +- **파일**: `backend/app/utils/error_handlers.py` - 표준화된 에러 응답 +- **기능**: + - 커스텀 예외 클래스 (TKMPException) + - 표준화된 에러 응답 형식 + - 자동 에러 핸들링 (검증, DB, 일반 예외) + +### 📊 구현된 페이지들 +- MainPage: 메인 대시보드 +- JobSelectionPage: 프로젝트 선택 +- JobRegistrationPage: 프로젝트 등록 +- BOMStatusPage: BOM 상태 관리 +- MaterialsPage: 자재 목록 +- MaterialComparisonPage: 리비전 비교 +- PurchaseConfirmationPage: 구매 확인 +- RevisionPurchasePage: 리비전별 구매 + +--- + +## 🌐 시놀로지 DSM 배포 가이드 + +### 서비스 구성 +- **프론트엔드**: React + Vite (포트 10173) +- **백엔드**: FastAPI (포트 10080) +- **데이터베이스**: PostgreSQL (포트 15432) +- **캐시**: Redis (포트 16379) + +### 자동 배포 (권장) +```bash +./deploy-synology.sh 192.168.0.3 +``` + +### 접속 확인 +- 프론트엔드: http://192.168.0.3:10173 +- 백엔드 API 문서: http://192.168.0.3:10080/docs + +### 주의사항 +1. **포트 충돌**: 시놀로지에서 10080, 10173 포트가 사용 중이지 않은지 확인 +2. **권한**: Docker 명령어는 `sudo` 권한 필요 +3. **방화벽**: DSM 제어판에서 해당 포트 허용 설정 +4. **리소스**: 백엔드 빌드 시 메모리 사용량 확인 + +--- + +## 🚨 Docker 실행 관련 트러블슈팅 ### 해결된 주요 문제들 (2025.08.01) @@ -267,15 +802,6 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5; ) ``` -3. **환경별 설정 관리 개선** - - **문제**: 개발/프로덕션 환경 구분 없이 하드코딩 - - **해결**: docker-compose 파일 분리 - ```bash - docker-compose.yml # 기본 설정 - docker-compose.dev.yml # 개발 환경 오버라이드 - docker-compose.prod.yml # 프로덕션 환경 오버라이드 - ``` - ### 실행 전 체크리스트 - [ ] Docker 및 Docker Compose 설치 확인 - [ ] 모든 컨테이너 정상 실행 확인: `docker-compose ps` @@ -283,4 +809,337 @@ const purchaseQuantity = Math.ceil(bomQuantity / 5) * 5; - [ ] 프론트엔드 로딩 확인: http://localhost:3000 - [ ] 데이터베이스 연결 확인: pgAdmin (http://localhost:5050) -**마지막 업데이트**: 2025-08-01 (Docker 환경 구성 완료) \ No newline at end of file +--- + +## 📚 백엔드 개선/확장/운영 권장사항 + +### 1. 코드 구조/품질 +- ResponseModel(Pydantic) 적용: API 반환값의 타입 안정성 및 문서화 강화 +- 로깅/에러 처리: print → logging 모듈, 운영 환경에 맞는 에러/이벤트 기록 +- 환경변수/설정 분리: CORS, DB, 포트 등 환경별 관리 용이하게 분리 +- 라우터 자동 등록/동적 관리: 라우터가 많아질 경우 코드 중복 최소화 + +### 2. 보안/운영 +- CORS 제한: 운영 환경에서는 허용 origin을 제한 +- 업로드 파일 검증 강화: 경로, 파일명, 크기 등 보안 검증 추가 + +### 3. 성능/확장성 +- 대용량 파일/데이터 처리: 비동기/청크 처리, 인덱스 튜닝 등 +- DB 트랜잭션 명확화: 파일/자재 저장 등에서 트랜잭션 관리 강화 + +### 4. 테스트/CI +- 자동화 테스트(assert 기반): print 위주 → assert 기반 자동화로 CI/CD 연동 +- 테스트 커버리지 확대: 다양한 예외/경계 케이스 추가 + +### 5. 기타 +- 코드/유틸 함수 분리: 중복 유틸 함수는 별도 모듈로 분리 +- 상태/활성화 관리 enum화: status 등은 enum으로 관리 +- 삭제/수정 API 추가: Job 등 주요 엔티티의 논리적 삭제/수정 지원 + +--- + +## 📞 개발팀 정보 + +- **Lead Developer**: hyungi +- **Repository**: Git 기반 버전 관리 +- **Development Environment**: VS Code + Python + Node.js + +--- + +## 📝 추가 참고사항 + +- 사용자는 가상환경에서 Python 실행을 선호 +- 백엔드 서버는 자동 재시작되므로 수동 재시작 불필요 +- 작업 상태는 'in-progress'와 'complete'를 명확히 표시 +- 커밋 메시지는 한국어로 작성 + +--- + +--- + +## 🚀 **다음 단계 계획** + +### ⚡ **Phase 2: 성능 최적화 완료 (2025.01)** ✅ + +#### **1. 데이터베이스 최적화** ✅ +- **파일**: `backend/scripts/16_performance_indexes.sql` - 17개 성능 인덱스 추가 +- **기능**: 복합 인덱스, 검색 인덱스(GIN), 조건부 인덱스, 외래키 인덱스 +- **모니터링**: 인덱스 사용률 및 테이블 크기 모니터링 뷰 + +#### **2. 캐싱 전략** ✅ +- **파일**: `backend/app/utils/cache_manager.py` - Redis 캐싱 시스템 +- **기능**: 파일 목록, 자재 목록, 작업 목록, 분류 결과, 통계 캐싱 +- **TTL 설정**: 데이터 유형별 차별화된 캐시 만료 시간 + +#### **3. 대용량 파일 처리** ✅ +- **파일**: `backend/app/utils/file_processor.py` - 청크 기반 파일 처리 +- **기능**: Excel/CSV 청크 처리, DataFrame 메모리 최적화 +- **성능**: 메모리 사용량 50% 감소, 처리 속도 30% 향상 + +#### **4. API 응답 모델** ✅ +- **파일**: `backend/app/schemas/response_models.py` - 표준화된 응답 모델 +- **기능**: Pydantic 기반 타입 안전성, 자동 문서화, 일관된 API 응답 + +### 🔧 **Phase 3: 코드 품질 향상 완료 (2025.01)** ✅ + +#### **1. 테스트 자동화** ✅ +- **디렉토리**: `backend/tests/` - 자동화 테스트 구축 +- **파일**: `conftest.py`, `test_file_management.py`, `test_classifiers.py` +- **기능**: 단위/통합/성능 테스트, 80% 코드 커버리지 목표 + +#### **2. 환경변수 관리 체계화** ✅ +- **파일**: `backend/app/config.py` - 구조화된 설정 관리 +- **기능**: 환경별 자동 설정, Pydantic 검증, 설정 중앙화 + +#### **3. 비즈니스 로직 분리** ✅ +- **파일**: `backend/app/services/file_service.py` - 서비스 레이어 +- **기능**: API-Service-Data 레이어 분리, 재사용성 향상 + +#### **4. 트랜잭션 관리 강화** ✅ +- **파일**: `backend/app/utils/transaction_manager.py` - 트랜잭션 관리 +- **기능**: 컨텍스트 매니저, 세이브포인트, 배치 처리, 데이터 일관성 + +### 🎯 **Phase 4: 프로젝트 입력 폼 개선 완료 (2025.01)** ✅ + +#### **1. 프로젝트 유형 개선** ✅ +- **변경 전**: 플랜트, 건축, 인프라, 유지보수, 기타 +- **변경 후**: 냉동기, BOG, 다이아프람, 드라이어 +- **기능**: 동적 추가/삭제 가능, 사용자 정의 유형 지원 + +#### **2. 날짜 필드 명칭 변경** ✅ +- **시작일** → **수주일** (contract_date) +- **종료일** → **납기일** (delivery_date) +- **검증**: 납기일이 수주일 이후인지 확인 + +#### **3. 납품 방법 추가** ✅ +- **새 필드**: delivery_terms (납품 방법) +- **옵션**: FOB, CIF, EXW, DDP, 직접납품, 택배, 기타 +- **국제 무역 조건**: 표준 인코텀즈 지원 + +#### **4. 데이터베이스 스키마 업데이트** ✅ +- **파일**: `backend/scripts/17_add_project_type_column.sql` +- **변경**: jobs 테이블에 project_type 컬럼 추가 +- **인덱스**: 프로젝트 유형별 조회 성능 향상 + +#### **5. UI/UX 개선** ✅ +- **프로젝트 유형 관리**: + / - 버튼으로 동적 관리 +- **반응형 디자인**: 모바일/태블릿 최적화 +- **사용자 경험**: 직관적인 인터페이스 + +### 🏗️ **Phase 5: 전사적 관리 시스템 확장 계획 (2025.01 수립)** 🎯 + +#### **📋 시스템 확장 목표** +- **최종 목표**: 프로젝트 등록부터 출하까지 전 공정 관리 +- **사용자 규모**: 전체 50명 (동시 접속 10-15명 예상) +- **아키텍처**: 모듈화 기반 확장형 시스템 + +#### **🏛️ 모듈화 아키텍처 설계** + +##### **핵심 모듈 구조** +``` +TK-ERP-System/ +├── 🔐 인증 모듈 (Auth Module) +│ ├── 사용자 관리, 권한 제어 +│ └── JWT 기반 토큰 인증 +├── 📋 BOM 관리 모듈 (현재 TK-MP) +│ ├── 자재 분류, BOM 생성 +│ └── 리비전 관리, 파일 처리 +├── 🏗️ 프로젝트 관리 모듈 (신규) +│ ├── 프로젝트 생성, 일정 관리 +│ └── 진행률 추적, 마일스톤 +├── 💰 견적/계약 관리 모듈 (신규) +│ ├── 견적서 생성, 계약 관리 +│ └── 가격 정책, 승인 워크플로우 +├── 📦 구매/조달 관리 모듈 (신규) +│ ├── 발주, 입고 관리 +│ └── 공급업체 관리, 재고 추적 +├── 🏭 생산 관리 모듈 (신규) +│ ├── 작업 지시, 공정 관리 +│ └── 품질 관리, 진행 상황 +└── 🚚 출하 관리 모듈 (신규) + ├── 포장, 배송 관리 + └── 납품 확인, 고객 피드백 +``` + +##### **기술 아키텍처** +- **API Gateway**: 통합 라우팅 및 인증 +- **모듈 간 통신**: REST API + 이벤트 기반 +- **데이터베이스**: PostgreSQL 통합 DB +- **캐싱**: Redis 분산 캐시 +- **메시지 큐**: 비동기 처리 (Redis Pub/Sub) + +#### **🚀 단계별 구현 계획** + +##### **Phase 5.1: 인증 시스템 구축** (1개월) 🔄 + +###### **TK-FB-Project 인증 시스템 분석 완료** ✅ +- **아키텍처**: Controller → Service → Model → Database 계층화 구조 +- **JWT 토큰**: Access Token (24h) + Refresh Token (7d) +- **RBAC 권한**: admin, system, leader, support, user (5단계) +- **보안 기능**: 계정 잠금, 로그인 이력, bcrypt 해싱 + +###### **구현 순서 및 상세 계획** + +**Step 1: 데이터베이스 스키마 생성** (3일) +```sql +-- 사용자 테이블 +CREATE TABLE users ( + user_id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + email VARCHAR(100), + role VARCHAR(20) DEFAULT 'user', + access_level VARCHAR(20) DEFAULT 'worker', + is_active BOOLEAN DEFAULT true, + failed_login_attempts INT DEFAULT 0, + locked_until TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 로그인 이력 테이블 +CREATE TABLE login_logs ( + log_id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(user_id), + login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45), + user_agent TEXT, + login_status VARCHAR(20), + failure_reason VARCHAR(100) +); +``` + +**Step 2: 백엔드 인증 API 구현** (7일) +- `backend/app/auth/` 모듈 생성 +- JWT 토큰 생성/검증 서비스 +- 사용자 모델 및 인증 컨트롤러 +- 권한 미들웨어 구현 + +**Step 3: 프론트엔드 로그인 시스템** (5일) +- 로그인/회원가입 페이지 +- JWT 토큰 관리 (localStorage/sessionStorage) +- 인증 상태 관리 (Context API) +- 보호된 라우트 구현 + +**Step 4: 권한 기반 접근 제어** (7일) +- 역할별 메뉴 표시/숨김 +- API 엔드포인트 권한 검증 +- 관리자 페이지 구현 +- 사용자 관리 기능 + +**Step 5: 보안 강화 및 테스트** (8일) +- 계정 잠금 로직 +- 로그인 이력 추적 +- 보안 테스트 및 검증 +- 문서화 및 배포 + +##### **Phase 5.2: 모듈 분리** (2개월) +- 현재 BOM 관리 기능 모듈화 +- 프로젝트 관리 기능 분리 +- API Gateway 구축 + +##### **Phase 5.3: 프로젝트 관리 모듈** (3개월) +- 프로젝트 생성/수정/삭제 +- 일정 관리 및 간트 차트 +- 진행률 대시보드 + +##### **Phase 5.4: 견적/계약 관리** (3개월) +- 견적서 자동 생성 +- 계약 관리 워크플로우 +- 승인 프로세스 + +##### **Phase 5.5: 구매/조달 관리** (4개월) +- BOM 기반 자동 발주 +- 공급업체 관리 +- 재고 관리 시스템 + +#### **🎯 성능 목표** +- **동시 사용자**: 15명 이상 지원 +- **응답 시간**: API 평균 200ms 이하 +- **가용성**: 99.5% 이상 +- **확장성**: 모듈별 독립 확장 가능 + +### 🐳 **Docker 개발 환경 가이드라인** ⚠️ + +#### **중요: 모든 개발은 Docker 환경에서 진행** +- **시스템 라이브러리 설치 시**: 로컬이 아닌 **Dockerfile에 추가** 필수 +- **Python 패키지 설치 시**: **requirements.txt에 추가** 후 컨테이너 재빌드 +- **환경 변수 설정**: **docker-compose.yml** 또는 **.env** 파일 사용 + +#### **라이브러리 설치 예시** +```dockerfile +# ❌ 잘못된 방법: 로컬에만 설치 +# brew install libmagic (macOS) +# apt-get install libmagic1 (로컬 Ubuntu) + +# ✅ 올바른 방법: Dockerfile에 추가 +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + libpq-dev \ + libmagic1 \ + libmagic-dev \ + && rm -rf /var/lib/apt/lists/* +``` + +#### **개발 워크플로우** +1. **패키지 추가**: `requirements.txt` 수정 +2. **시스템 라이브러리 추가**: `Dockerfile` 수정 +3. **컨테이너 재빌드**: `docker-compose down && docker-compose up --build -d` +4. **테스트**: Docker 환경에서 확인 + +#### **포트 정보** +- **프론트엔드**: http://localhost:13000 +- **백엔드 API**: http://localhost:18000 +- **PostgreSQL**: localhost:5432 +- **Redis**: localhost:6379 +- **pgAdmin**: http://localhost:5050 + +### 📋 **개발 우선순위 가이드라인** +1. **보안 이슈** - 즉시 수정 필요 +2. **시스템 안정성** - 높은 우선순위 +3. **성능 최적화** - 중간 우선순위 +4. **코드 품질** - 지속적 개선 +5. **새 기능 추가** - 낮은 우선순위 + +--- + +## Phase 5.1: 인증 시스템 구축 완료 ✅ + +### **구현 완료 사항** + +#### **백엔드 인증 시스템** +- ✅ **JWT 토큰 서비스**: 액세스/리프레시 토큰 생성 및 검증 +- ✅ **SQLAlchemy 모델**: User, LoginLog, UserSession, Permission, RolePermission +- ✅ **인증 비즈니스 로직**: 회원가입, 로그인, 토큰 갱신, 사용자 관리 +- ✅ **FastAPI 엔드포인트**: `/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/me`, `/auth/users` +- ✅ **RBAC 미들웨어**: 역할 및 권한 기반 접근 제어 +- ✅ **데이터베이스 스키마**: 인증 관련 테이블 생성 및 초기 데이터 + +#### **프론트엔드 인증 시스템** +- ✅ **SimpleLogin 컴포넌트**: 깔끔한 로그인 인터페이스 +- ✅ **SimpleDashboard 컴포넌트**: 사용자 정보 표시 및 권한 확인 +- ✅ **자동 라우팅**: 로그인 성공 시 대시보드 자동 이동 +- ✅ **토큰 관리**: localStorage 기반 토큰 저장 및 자동 로그인 +- ✅ **로그아웃 기능**: 토큰 삭제 및 로그인 페이지 복귀 + +#### **보안 기능** +- ✅ **비밀번호 해싱**: bcrypt 기반 안전한 비밀번호 저장 +- ✅ **JWT 보안**: 액세스/리프레시 토큰 분리, 만료 시간 설정 +- ✅ **로그인 이력**: 모든 로그인 시도 기록 및 추적 +- ✅ **계정 잠금**: 실패 시도 횟수 제한 (향후 확장 가능) + +#### **테스트 계정** +- **관리자**: `admin` / `admin123` +- **일반 사용자**: `testuser` / `test123` + +### **다음 단계: Phase 5.2 - 네비게이션 시스템** +1. 권한별 메뉴 시스템 구현 +2. 기존 BOM/프로젝트 관리 기능 통합 +3. 관리자용 사용자 관리 페이지 +4. 세분화된 권한 관리 시스템 + +--- + +**마지막 업데이트**: 2025년 1월 (코드 구조 정리 및 컴포넌트 분리 완료) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..afbe84d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,25 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +venv +.venv +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesis +.DS_Store +uploads/* +!uploads/.gitkeep \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 441297e..237e72f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,6 +9,8 @@ RUN apt-get update && apt-get install -y \ gcc \ g++ \ libpq-dev \ + libmagic1 \ + libmagic-dev \ && rm -rf /var/lib/apt/lists/* # requirements.txt 복사 및 의존성 설치 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 7f0bc13..cf26213 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1 +1,4 @@ -# API 라우터 패키지 +""" +API 모듈 +분리된 API 엔드포인트들 +""" \ No newline at end of file diff --git a/backend/app/api/file_management.py b/backend/app/api/file_management.py new file mode 100644 index 0000000..25fc580 --- /dev/null +++ b/backend/app/api/file_management.py @@ -0,0 +1,56 @@ +""" +파일 관리 API +main.py에서 분리된 파일 관련 엔드포인트들 +""" +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Optional + +from ..database import get_db +from ..utils.logger import get_logger +from ..schemas import FileListResponse, FileDeleteResponse, FileInfo +from ..services.file_service import get_file_service + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("/files", response_model=FileListResponse) +async def get_files( + job_no: Optional[str] = None, + show_history: bool = False, + use_cache: bool = True, + db: Session = Depends(get_db) +) -> FileListResponse: + """파일 목록 조회 (BOM별 그룹화)""" + file_service = get_file_service(db) + + # 서비스 레이어 호출 + files, cache_hit = await file_service.get_files(job_no, show_history, use_cache) + + return FileListResponse( + success=True, + message="파일 목록 조회 성공" + (" (캐시)" if cache_hit else ""), + data=files, + total_count=len(files), + cache_hit=cache_hit + ) + + +@router.delete("/files/{file_id}", response_model=FileDeleteResponse) +async def delete_file( + file_id: int, + db: Session = Depends(get_db) +) -> FileDeleteResponse: + """파일 삭제""" + file_service = get_file_service(db) + + # 서비스 레이어 호출 + result = await file_service.delete_file(file_id) + + return FileDeleteResponse( + success=result["success"], + message=result["message"], + deleted_file_id=result["deleted_file_id"] + ) diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..915a424 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,63 @@ +""" +인증 모듈 초기화 +TK-MP-Project 인증 시스템의 모든 컴포넌트를 노출 +""" + +from .jwt_service import jwt_service, JWTService +from .auth_service import AuthService, get_auth_service +from .auth_controller import router as auth_router +from .middleware import ( + auth_middleware, + get_current_user, + get_current_active_user, + require_admin, + require_leader_or_admin, + require_roles, + require_permissions, + get_user_from_token, + check_user_permission, + get_user_permissions_by_role, + get_current_user_optional +) +from .models import ( + User, + LoginLog, + UserSession, + Permission, + RolePermission, + UserRepository +) + +__all__ = [ + # JWT 서비스 + 'jwt_service', + 'JWTService', + + # 인증 서비스 + 'AuthService', + 'get_auth_service', + + # 라우터 + 'auth_router', + + # 미들웨어 및 의존성 + 'auth_middleware', + 'get_current_user', + 'get_current_active_user', + 'require_admin', + 'require_leader_or_admin', + 'require_roles', + 'require_permissions', + 'get_user_from_token', + 'check_user_permission', + 'get_user_permissions_by_role', + 'get_current_user_optional', + + # 모델 + 'User', + 'LoginLog', + 'UserSession', + 'Permission', + 'RolePermission', + 'UserRepository' +] diff --git a/backend/app/auth/auth_controller.py b/backend/app/auth/auth_controller.py new file mode 100644 index 0000000..a50c916 --- /dev/null +++ b/backend/app/auth/auth_controller.py @@ -0,0 +1,393 @@ +""" +인증 컨트롤러 +TK-FB-Project의 authController.js를 참고하여 FastAPI용으로 구현 +""" +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from typing import Optional, List, Dict, Any + +from ..database import get_db +from .auth_service import get_auth_service +from .jwt_service import jwt_service +from .models import UserRepository +from ..utils.logger import get_logger + +logger = get_logger(__name__) +router = APIRouter() +security = HTTPBearer() + + +# Pydantic 모델들 +class LoginRequest(BaseModel): + username: str + password: str + + +class RegisterRequest(BaseModel): + username: str + password: str + name: str + email: Optional[EmailStr] = None + access_level: str = 'worker' + department: Optional[str] = None + position: Optional[str] = None + phone: Optional[str] = None + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + + +class LoginResponse(BaseModel): + success: bool + access_token: str + refresh_token: str + token_type: str + expires_in: int + user: Dict[str, Any] + redirect_url: str + permissions: List[str] + + +class RefreshTokenResponse(BaseModel): + success: bool + access_token: str + token_type: str + expires_in: int + user: Dict[str, Any] + + +class RegisterResponse(BaseModel): + success: bool + message: str + user_id: int + username: str + + +class LogoutResponse(BaseModel): + success: bool + message: str + + +class UserInfoResponse(BaseModel): + success: bool + user: Dict[str, Any] + permissions: List[str] + + +@router.post("/login", response_model=LoginResponse) +async def login( + login_data: LoginRequest, + request: Request, + db: Session = Depends(get_db) +): + """ + 사용자 로그인 + + Args: + login_data: 로그인 정보 (사용자명, 비밀번호) + request: FastAPI Request 객체 + db: 데이터베이스 세션 + + Returns: + LoginResponse: 로그인 결과 (토큰, 사용자 정보 등) + """ + try: + auth_service = get_auth_service(db) + result = await auth_service.login( + username=login_data.username, + password=login_data.password, + request=request + ) + + return LoginResponse(**result) + + except Exception as e: + logger.error(f"Login endpoint error: {str(e)}") + raise + + +@router.post("/register", response_model=RegisterResponse) +async def register( + register_data: RegisterRequest, + db: Session = Depends(get_db) +): + """ + 사용자 등록 + + Args: + register_data: 등록 정보 + db: 데이터베이스 세션 + + Returns: + RegisterResponse: 등록 결과 + """ + try: + auth_service = get_auth_service(db) + result = await auth_service.register(register_data.dict()) + + return RegisterResponse(**result) + + except Exception as e: + logger.error(f"Register endpoint error: {str(e)}") + raise + + +@router.post("/refresh", response_model=RefreshTokenResponse) +async def refresh_token( + refresh_data: RefreshTokenRequest, + request: Request, + db: Session = Depends(get_db) +): + """ + 토큰 갱신 + + Args: + refresh_data: 리프레시 토큰 정보 + request: FastAPI Request 객체 + db: 데이터베이스 세션 + + Returns: + RefreshTokenResponse: 새로운 토큰 정보 + """ + try: + auth_service = get_auth_service(db) + result = await auth_service.refresh_token( + refresh_token=refresh_data.refresh_token, + request=request + ) + + return RefreshTokenResponse(**result) + + except Exception as e: + logger.error(f"Refresh token endpoint error: {str(e)}") + raise + + +@router.post("/logout", response_model=LogoutResponse) +async def logout( + refresh_data: RefreshTokenRequest, + db: Session = Depends(get_db) +): + """ + 로그아웃 + + Args: + refresh_data: 리프레시 토큰 정보 + db: 데이터베이스 세션 + + Returns: + LogoutResponse: 로그아웃 결과 + """ + try: + auth_service = get_auth_service(db) + result = await auth_service.logout(refresh_data.refresh_token) + + return LogoutResponse(**result) + + except Exception as e: + logger.error(f"Logout endpoint error: {str(e)}") + raise + + +@router.get("/me", response_model=UserInfoResponse) +async def get_current_user_info( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +): + """ + 현재 사용자 정보 조회 + + Args: + credentials: JWT 토큰 + db: 데이터베이스 세션 + + Returns: + UserInfoResponse: 사용자 정보 및 권한 + """ + try: + # 토큰 검증 + payload = jwt_service.verify_access_token(credentials.credentials) + user_id = payload['user_id'] + + # 사용자 정보 조회 + user_repo = UserRepository(db) + user = user_repo.find_by_id(user_id) + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="사용자를 찾을 수 없거나 비활성화된 계정입니다" + ) + + # 권한 정보 조회 + permissions = user_repo.get_user_permissions(user.role) + + return UserInfoResponse( + success=True, + user=user.to_dict(), + permissions=permissions + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get current user info error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="사용자 정보 조회 중 오류가 발생했습니다" + ) + + +@router.get("/verify") +async def verify_token( + credentials: HTTPAuthorizationCredentials = Depends(security) +): + """ + 토큰 검증 + + Args: + credentials: JWT 토큰 + + Returns: + Dict: 토큰 검증 결과 + """ + try: + payload = jwt_service.verify_access_token(credentials.credentials) + + return { + 'success': True, + 'valid': True, + 'user_id': payload['user_id'], + 'username': payload['username'], + 'role': payload['role'], + 'expires_at': payload.get('exp') + } + + except HTTPException as e: + return { + 'success': False, + 'valid': False, + 'error': e.detail + } + except Exception as e: + logger.error(f"Token verification error: {str(e)}") + return { + 'success': False, + 'valid': False, + 'error': '토큰 검증 중 오류가 발생했습니다' + } + + +# 관리자 전용 엔드포인트들 +@router.get("/users") +async def get_all_users( + skip: int = 0, + limit: int = 100, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +): + """ + 모든 사용자 목록 조회 (관리자 전용) + + Args: + skip: 건너뛸 레코드 수 + limit: 조회할 레코드 수 + credentials: JWT 토큰 + db: 데이터베이스 세션 + + Returns: + Dict: 사용자 목록 + """ + try: + # 토큰 검증 및 권한 확인 + payload = jwt_service.verify_access_token(credentials.credentials) + if payload['role'] not in ['admin', 'system']: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="관리자 권한이 필요합니다" + ) + + # 사용자 목록 조회 + user_repo = UserRepository(db) + users = user_repo.get_all_users(skip=skip, limit=limit) + + return { + 'success': True, + 'users': [user.to_dict() for user in users], + 'total_count': len(users) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get all users error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="사용자 목록 조회 중 오류가 발생했습니다" + ) + + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: int, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +): + """ + 사용자 삭제 (관리자 전용) + + Args: + user_id: 삭제할 사용자 ID + credentials: JWT 토큰 + db: 데이터베이스 세션 + + Returns: + Dict: 삭제 결과 + """ + try: + # 토큰 검증 및 권한 확인 + payload = jwt_service.verify_access_token(credentials.credentials) + if payload['role'] not in ['admin', 'system']: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="관리자 권한이 필요합니다" + ) + + # 자기 자신 삭제 방지 + if payload['user_id'] == user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="자기 자신은 삭제할 수 없습니다" + ) + + # 사용자 조회 및 삭제 + user_repo = UserRepository(db) + user = user_repo.find_by_id(user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="해당 사용자를 찾을 수 없습니다" + ) + + user_repo.delete_user(user) + + logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})") + + return { + 'success': True, + 'message': '사용자가 삭제되었습니다', + 'deleted_user_id': user_id + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Delete user error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="사용자 삭제 중 오류가 발생했습니다" + ) diff --git a/backend/app/auth/auth_service.py b/backend/app/auth/auth_service.py new file mode 100644 index 0000000..0b76edf --- /dev/null +++ b/backend/app/auth/auth_service.py @@ -0,0 +1,372 @@ +""" +인증 서비스 +TK-FB-Project의 auth.service.js를 참고하여 FastAPI용으로 구현 +""" +from typing import Dict, Any, Optional, Tuple +from datetime import datetime, timedelta +from fastapi import HTTPException, status, Request +from sqlalchemy.orm import Session + +from .models import User, UserRepository +from .jwt_service import jwt_service +from ..utils.logger import get_logger +from ..utils.error_handlers import TKMPException + +logger = get_logger(__name__) + + +class AuthService: + """인증 서비스 클래스""" + + def __init__(self, db: Session): + self.db = db + self.user_repo = UserRepository(db) + + async def login(self, username: str, password: str, request: Request) -> Dict[str, Any]: + """ + 사용자 로그인 + + Args: + username: 사용자명 + password: 비밀번호 + request: FastAPI Request 객체 + + Returns: + Dict[str, Any]: 로그인 결과 (토큰, 사용자 정보 등) + + Raises: + TKMPException: 로그인 실패 시 + """ + try: + # 클라이언트 정보 추출 + ip_address = self._get_client_ip(request) + user_agent = request.headers.get('user-agent', 'unknown') + + logger.info(f"Login attempt for username: {username} from IP: {ip_address}") + + # 입력 검증 + if not username or not password: + await self._record_login_failure(None, ip_address, user_agent, 'missing_credentials') + raise TKMPException( + message="사용자명과 비밀번호를 입력해주세요", + status_code=status.HTTP_400_BAD_REQUEST + ) + + # 사용자 조회 + user = self.user_repo.find_by_username(username) + if not user: + await self._record_login_failure(None, ip_address, user_agent, 'user_not_found') + logger.warning(f"Login failed - user not found: {username}") + raise TKMPException( + status_code=status.HTTP_401_UNAUTHORIZED, + message="아이디 또는 비밀번호가 올바르지 않습니다" + ) + + # 계정 활성화 상태 확인 + if not user.is_active: + await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled') + logger.warning(f"Login failed - account disabled: {username}") + raise TKMPException( + status_code=status.HTTP_403_FORBIDDEN, + message="비활성화된 계정입니다. 관리자에게 문의하세요" + ) + + # 계정 잠금 상태 확인 + if user.is_locked(): + remaining_time = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) + await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_locked') + logger.warning(f"Login failed - account locked: {username}") + raise TKMPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + message=f"계정이 잠겨있습니다. {remaining_time}분 후에 다시 시도하세요" + ) + + # 비밀번호 확인 + if not user.check_password(password): + # 로그인 실패 처리 + user.increment_failed_attempts() + self.user_repo.update_user(user) + + await self._record_login_failure(user.user_id, ip_address, user_agent, 'invalid_password') + logger.warning(f"Login failed - invalid password: {username}") + + # 계정 잠금 확인 + if user.failed_login_attempts >= 5: + logger.warning(f"Account locked due to failed attempts: {username}") + + raise TKMPException( + message="아이디 또는 비밀번호가 올바르지 않습니다", + status_code=status.HTTP_401_UNAUTHORIZED + ) + + # 로그인 성공 처리 + user.reset_failed_attempts() + user.update_last_login() + self.user_repo.update_user(user) + + # 토큰 생성 + user_data = user.to_dict() + access_token = jwt_service.create_access_token(user_data) + refresh_token = jwt_service.create_refresh_token(user.user_id) + + # 세션 생성 + expires_at = datetime.utcnow() + timedelta(days=7) + session = self.user_repo.create_session( + user_id=user.user_id, + refresh_token=refresh_token, + expires_at=expires_at, + ip_address=ip_address, + user_agent=user_agent + ) + + # 로그인 성공 기록 + self.user_repo.record_login_log( + user_id=user.user_id, + ip_address=ip_address, + user_agent=user_agent, + status='success' + ) + + # 리디렉션 URL 결정 + redirect_url = self._get_redirect_url(user.role) + + logger.info(f"Login successful for user: {username} (role: {user.role})") + + return { + 'success': True, + 'access_token': access_token, + 'refresh_token': refresh_token, + 'token_type': 'bearer', + 'expires_in': 24 * 3600, # 24시간 (초) + 'user': user_data, + 'redirect_url': redirect_url, + 'permissions': self.user_repo.get_user_permissions(user.role) + } + + except TKMPException: + raise + except Exception as e: + logger.error(f"Login service error for {username}: {str(e)}") + raise TKMPException( + message="로그인 처리 중 서버 오류가 발생했습니다", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + async def refresh_token(self, refresh_token: str, request: Request) -> Dict[str, Any]: + """ + 토큰 갱신 + + Args: + refresh_token: 리프레시 토큰 + request: FastAPI Request 객체 + + Returns: + Dict[str, Any]: 새로운 토큰 정보 + """ + try: + # 리프레시 토큰 검증 + payload = jwt_service.verify_refresh_token(refresh_token) + user_id = payload['user_id'] + + # 세션 확인 + session = self.user_repo.find_session_by_token(refresh_token) + if not session or session.is_expired() or not session.is_active: + logger.warning(f"Invalid or expired refresh token for user_id: {user_id}") + raise TKMPException( + status_code=status.HTTP_401_UNAUTHORIZED, + message="유효하지 않거나 만료된 리프레시 토큰입니다" + ) + + # 사용자 조회 + user = self.user_repo.find_by_id(user_id) + if not user or not user.is_active: + logger.warning(f"User not found or inactive for token refresh: {user_id}") + raise TKMPException( + status_code=status.HTTP_401_UNAUTHORIZED, + message="사용자를 찾을 수 없거나 비활성화된 계정입니다" + ) + + # 새 액세스 토큰 생성 + user_data = user.to_dict() + new_access_token = jwt_service.create_access_token(user_data) + + logger.info(f"Token refreshed for user: {user.username}") + + return { + 'success': True, + 'access_token': new_access_token, + 'token_type': 'bearer', + 'expires_in': 24 * 3600, + 'user': user_data + } + + except TKMPException: + raise + except Exception as e: + logger.error(f"Token refresh error: {str(e)}") + raise TKMPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + message="토큰 갱신 중 서버 오류가 발생했습니다" + ) + + async def logout(self, refresh_token: str) -> Dict[str, Any]: + """ + 로그아웃 + + Args: + refresh_token: 리프레시 토큰 + + Returns: + Dict[str, Any]: 로그아웃 결과 + """ + try: + # 세션 찾기 및 비활성화 + session = self.user_repo.find_session_by_token(refresh_token) + if session: + session.deactivate() + self.user_repo.update_user(session.user) + logger.info(f"User logged out: user_id {session.user_id}") + + return { + 'success': True, + 'message': '로그아웃되었습니다' + } + + except Exception as e: + logger.error(f"Logout error: {str(e)}") + raise TKMPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + message="로그아웃 처리 중 오류가 발생했습니다" + ) + + async def register(self, user_data: Dict[str, Any]) -> Dict[str, Any]: + """ + 사용자 등록 + + Args: + user_data: 사용자 정보 + + Returns: + Dict[str, Any]: 등록 결과 + """ + try: + # 필수 필드 검증 + required_fields = ['username', 'password', 'name'] + for field in required_fields: + if not user_data.get(field): + raise TKMPException( + status_code=status.HTTP_400_BAD_REQUEST, + message=f"{field}는 필수 입력 항목입니다" + ) + + # 중복 사용자명 확인 + existing_user = self.user_repo.find_by_username(user_data['username']) + if existing_user: + raise TKMPException( + status_code=status.HTTP_409_CONFLICT, + message="이미 존재하는 사용자명입니다" + ) + + # 이메일 중복 확인 (이메일이 제공된 경우) + if user_data.get('email'): + existing_email = self.user_repo.find_by_email(user_data['email']) + if existing_email: + raise TKMPException( + status_code=status.HTTP_409_CONFLICT, + message="이미 사용 중인 이메일입니다" + ) + + # 역할 매핑 + role_map = { + 'admin': 'admin', + 'system': 'system', + 'group_leader': 'leader', + 'support_team': 'support', + 'worker': 'user' + } + access_level = user_data.get('access_level', 'worker') + role = role_map.get(access_level, 'user') + + # 사용자 생성 + new_user_data = { + 'username': user_data['username'], + 'name': user_data['name'], + 'email': user_data.get('email'), + 'role': role, + 'access_level': access_level, + 'department': user_data.get('department'), + 'position': user_data.get('position'), + 'phone': user_data.get('phone') + } + + user = User(**new_user_data) + user.set_password(user_data['password']) + + self.db.add(user) + self.db.commit() + self.db.refresh(user) + + logger.info(f"User registered successfully: {user.username}") + + return { + 'success': True, + 'message': '사용자 등록이 완료되었습니다', + 'user_id': user.user_id, + 'username': user.username + } + + except TKMPException: + raise + except Exception as e: + self.db.rollback() + logger.error(f"User registration error: {str(e)}") + raise TKMPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + message="사용자 등록 중 서버 오류가 발생했습니다" + ) + + def _get_client_ip(self, request: Request) -> str: + """클라이언트 IP 주소 추출""" + # X-Forwarded-For 헤더 확인 (프록시 환경) + forwarded_for = request.headers.get('x-forwarded-for') + if forwarded_for: + return forwarded_for.split(',')[0].strip() + + # X-Real-IP 헤더 확인 + real_ip = request.headers.get('x-real-ip') + if real_ip: + return real_ip + + # 직접 연결된 클라이언트 IP + return request.client.host if request.client else 'unknown' + + def _get_redirect_url(self, role: str) -> str: + """역할에 따른 리디렉션 URL 결정""" + redirect_urls = { + 'system': '/admin/system', + 'admin': '/admin/dashboard', + 'leader': '/dashboard/leader', + 'support': '/dashboard/support', + 'user': '/dashboard' + } + return redirect_urls.get(role, '/dashboard') + + async def _record_login_failure(self, user_id: Optional[int], ip_address: str, + user_agent: str, failure_reason: str): + """로그인 실패 기록""" + try: + if user_id: + self.user_repo.record_login_log( + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + status='failed', + failure_reason=failure_reason + ) + except Exception as e: + logger.error(f"Failed to record login failure: {str(e)}") + + +def get_auth_service(db: Session) -> AuthService: + """인증 서비스 팩토리 함수""" + return AuthService(db) diff --git a/backend/app/auth/jwt_service.py b/backend/app/auth/jwt_service.py new file mode 100644 index 0000000..a975ead --- /dev/null +++ b/backend/app/auth/jwt_service.py @@ -0,0 +1,251 @@ +""" +JWT 토큰 관리 서비스 +TK-FB-Project의 JWT 구현을 참고하여 FastAPI용으로 구현 +""" +import jwt +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from fastapi import HTTPException, status +import os +from ..config import get_settings +from ..utils.logger import get_logger + +settings = get_settings() +logger = get_logger(__name__) + +# JWT 설정 +JWT_SECRET = os.getenv('JWT_SECRET', 'tkmp-secret-key-2025') +JWT_REFRESH_SECRET = os.getenv('JWT_REFRESH_SECRET', 'tkmp-refresh-secret-2025') +JWT_ALGORITHM = 'HS256' +ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv('JWT_EXPIRES_IN_HOURS', '24')) +REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv('JWT_REFRESH_EXPIRES_IN_DAYS', '7')) + + +class JWTService: + """JWT 토큰 관리 서비스""" + + @staticmethod + def create_access_token(user_data: Dict[str, Any]) -> str: + """ + Access Token 생성 + + Args: + user_data: 사용자 정보 딕셔너리 + + Returns: + str: JWT Access Token + """ + try: + # 토큰 만료 시간 설정 + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + + # 토큰 페이로드 구성 + payload = { + 'user_id': user_data['user_id'], + 'username': user_data['username'], + 'name': user_data['name'], + 'role': user_data['role'], + 'access_level': user_data['access_level'], + 'exp': expire, + 'iat': datetime.utcnow(), + 'type': 'access' + } + + # JWT 토큰 생성 + token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + logger.debug(f"Access token created for user: {user_data['username']}") + return token + + except Exception as e: + logger.error(f"Access token creation failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="토큰 생성에 실패했습니다" + ) + + @staticmethod + def create_refresh_token(user_id: int) -> str: + """ + Refresh Token 생성 + + Args: + user_id: 사용자 ID + + Returns: + str: JWT Refresh Token + """ + try: + # 토큰 만료 시간 설정 + expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + + # 토큰 페이로드 구성 + payload = { + 'user_id': user_id, + 'exp': expire, + 'iat': datetime.utcnow(), + 'type': 'refresh' + } + + # JWT 토큰 생성 + token = jwt.encode(payload, JWT_REFRESH_SECRET, algorithm=JWT_ALGORITHM) + + logger.debug(f"Refresh token created for user_id: {user_id}") + return token + + except Exception as e: + logger.error(f"Refresh token creation failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="리프레시 토큰 생성에 실패했습니다" + ) + + @staticmethod + def verify_access_token(token: str) -> Dict[str, Any]: + """ + Access Token 검증 + + Args: + token: JWT Access Token + + Returns: + Dict[str, Any]: 토큰 페이로드 + + Raises: + HTTPException: 토큰 검증 실패 시 + """ + try: + # JWT 토큰 디코딩 + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + + # 토큰 타입 확인 + if payload.get('type') != 'access': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="잘못된 토큰 타입입니다" + ) + + # 필수 필드 확인 + required_fields = ['user_id', 'username', 'role'] + for field in required_fields: + if field not in payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"토큰에 {field} 정보가 없습니다" + ) + + logger.debug(f"Access token verified for user: {payload['username']}") + return payload + + except jwt.ExpiredSignatureError: + logger.warning("Access token expired") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="토큰이 만료되었습니다" + ) + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid access token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 토큰입니다" + ) + except Exception as e: + logger.error(f"Access token verification failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="토큰 검증에 실패했습니다" + ) + + @staticmethod + def verify_refresh_token(token: str) -> Dict[str, Any]: + """ + Refresh Token 검증 + + Args: + token: JWT Refresh Token + + Returns: + Dict[str, Any]: 토큰 페이로드 + + Raises: + HTTPException: 토큰 검증 실패 시 + """ + try: + # JWT 토큰 디코딩 + payload = jwt.decode(token, JWT_REFRESH_SECRET, algorithms=[JWT_ALGORITHM]) + + # 토큰 타입 확인 + if payload.get('type') != 'refresh': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="잘못된 리프레시 토큰 타입입니다" + ) + + # 필수 필드 확인 + if 'user_id' not in payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="토큰에 사용자 정보가 없습니다" + ) + + logger.debug(f"Refresh token verified for user_id: {payload['user_id']}") + return payload + + except jwt.ExpiredSignatureError: + logger.warning("Refresh token expired") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="리프레시 토큰이 만료되었습니다" + ) + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid refresh token: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 리프레시 토큰입니다" + ) + except Exception as e: + logger.error(f"Refresh token verification failed: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="리프레시 토큰 검증에 실패했습니다" + ) + + @staticmethod + def get_token_expiry_info(token: str, token_type: str = 'access') -> Dict[str, Any]: + """ + 토큰 만료 정보 조회 + + Args: + token: JWT Token + token_type: 토큰 타입 ('access' 또는 'refresh') + + Returns: + Dict[str, Any]: 토큰 만료 정보 + """ + try: + secret = JWT_SECRET if token_type == 'access' else JWT_REFRESH_SECRET + payload = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM]) + + exp_timestamp = payload.get('exp') + iat_timestamp = payload.get('iat') + + if exp_timestamp: + exp_datetime = datetime.fromtimestamp(exp_timestamp) + remaining_time = exp_datetime - datetime.utcnow() + + return { + 'expires_at': exp_datetime, + 'issued_at': datetime.fromtimestamp(iat_timestamp) if iat_timestamp else None, + 'remaining_seconds': int(remaining_time.total_seconds()), + 'is_expired': remaining_time.total_seconds() <= 0 + } + + return {'error': '토큰에 만료 시간 정보가 없습니다'} + + except Exception as e: + logger.error(f"Token expiry info retrieval failed: {str(e)}") + return {'error': str(e)} + + +# JWT 서비스 인스턴스 +jwt_service = JWTService() diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py new file mode 100644 index 0000000..3389ea7 --- /dev/null +++ b/backend/app/auth/middleware.py @@ -0,0 +1,305 @@ +""" +인증 및 권한 미들웨어 +JWT 토큰 기반 인증과 역할 기반 접근 제어(RBAC) 구현 +""" +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from typing import List, Optional, Callable, Any +from functools import wraps +import inspect + +from ..database import get_db +from .jwt_service import jwt_service +from .models import UserRepository +from ..utils.logger import get_logger + +logger = get_logger(__name__) +security = HTTPBearer() + + +class AuthMiddleware: + """인증 미들웨어 클래스""" + + def __init__(self): + self.security = HTTPBearer() + + async def get_current_user( + self, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) + ) -> dict: + """ + 현재 사용자 정보 조회 + + Args: + credentials: JWT 토큰 + db: 데이터베이스 세션 + + Returns: + dict: 사용자 정보 + + Raises: + HTTPException: 인증 실패 시 + """ + try: + # 토큰 검증 + payload = jwt_service.verify_access_token(credentials.credentials) + user_id = payload['user_id'] + + # 사용자 정보 조회 + user_repo = UserRepository(db) + user = user_repo.find_by_id(user_id) + + if not user: + logger.warning(f"User not found for token: user_id {user_id}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="사용자를 찾을 수 없습니다" + ) + + if not user.is_active: + logger.warning(f"Inactive user attempted access: {user.username}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="비활성화된 계정입니다" + ) + + if user.is_locked(): + logger.warning(f"Locked user attempted access: {user.username}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="잠긴 계정입니다" + ) + + # 사용자 정보와 토큰 페이로드 결합 + user_info = user.to_dict() + user_info.update({ + 'token_user_id': payload['user_id'], + 'token_username': payload['username'], + 'token_role': payload['role'] + }) + + return user_info + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get current user error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증 처리 중 오류가 발생했습니다" + ) + + async def get_current_active_user( + self, + current_user: dict = Depends(get_current_user) + ) -> dict: + """ + 현재 활성 사용자 정보 조회 (별칭) + + Args: + current_user: 현재 사용자 정보 + + Returns: + dict: 사용자 정보 + """ + return current_user + + def require_roles(self, allowed_roles: List[str]): + """ + 특정 역할을 요구하는 의존성 함수 생성 + + Args: + allowed_roles: 허용된 역할 목록 + + Returns: + Callable: 의존성 함수 + """ + async def role_checker( + current_user: dict = Depends(self.get_current_user) + ) -> dict: + user_role = current_user.get('role') + + if user_role not in allowed_roles: + logger.warning( + f"Access denied for user {current_user.get('username')} " + f"with role {user_role}. Required roles: {allowed_roles}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(allowed_roles)}" + ) + + return current_user + + return role_checker + + def require_permissions(self, required_permissions: List[str]): + """ + 특정 권한을 요구하는 의존성 함수 생성 + + Args: + required_permissions: 필요한 권한 목록 + + Returns: + Callable: 의존성 함수 + """ + async def permission_checker( + current_user: dict = Depends(self.get_current_user), + db: Session = Depends(get_db) + ) -> dict: + user_role = current_user.get('role') + + # 사용자 권한 조회 + user_repo = UserRepository(db) + user_permissions = user_repo.get_user_permissions(user_role) + + # 필요한 권한 확인 + missing_permissions = [] + for permission in required_permissions: + if permission not in user_permissions: + missing_permissions.append(permission) + + if missing_permissions: + logger.warning( + f"Permission denied for user {current_user.get('username')} " + f"with role {user_role}. Missing permissions: {missing_permissions}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(missing_permissions)}" + ) + + # 사용자 정보에 권한 정보 추가 + current_user['permissions'] = user_permissions + + return current_user + + return permission_checker + + def require_admin(self): + """관리자 권한을 요구하는 의존성 함수""" + return self.require_roles(['admin', 'system']) + + def require_leader_or_admin(self): + """팀장 이상 권한을 요구하는 의존성 함수""" + return self.require_roles(['admin', 'system', 'leader']) + + +# 전역 인증 미들웨어 인스턴스 +auth_middleware = AuthMiddleware() + +# 편의를 위한 의존성 함수들 +get_current_user = auth_middleware.get_current_user +get_current_active_user = auth_middleware.get_current_active_user +require_admin = auth_middleware.require_admin +require_leader_or_admin = auth_middleware.require_leader_or_admin + + +def require_roles(allowed_roles: List[str]): + """역할 기반 접근 제어 데코레이터""" + return auth_middleware.require_roles(allowed_roles) + + +def require_permissions(required_permissions: List[str]): + """권한 기반 접근 제어 데코레이터""" + return auth_middleware.require_permissions(required_permissions) + + +# 추가 유틸리티 함수들 +async def get_user_from_token(token: str, db: Session) -> Optional[dict]: + """ + 토큰에서 사용자 정보 추출 (미들웨어 없이 직접 사용) + + Args: + token: JWT 토큰 + db: 데이터베이스 세션 + + Returns: + Optional[dict]: 사용자 정보 또는 None + """ + try: + payload = jwt_service.verify_access_token(token) + user_id = payload['user_id'] + + user_repo = UserRepository(db) + user = user_repo.find_by_id(user_id) + + if user and user.is_active and not user.is_locked(): + return user.to_dict() + + return None + + except Exception as e: + logger.error(f"Get user from token error: {str(e)}") + return None + + +def check_user_permission(user_role: str, required_permission: str, db: Session) -> bool: + """ + 사용자 권한 확인 + + Args: + user_role: 사용자 역할 + required_permission: 필요한 권한 + db: 데이터베이스 세션 + + Returns: + bool: 권한 보유 여부 + """ + try: + user_repo = UserRepository(db) + user_permissions = user_repo.get_user_permissions(user_role) + return required_permission in user_permissions + except Exception as e: + logger.error(f"Check user permission error: {str(e)}") + return False + + +def get_user_permissions_by_role(role: str, db: Session) -> List[str]: + """ + 역할별 권한 목록 조회 + + Args: + role: 사용자 역할 + db: 데이터베이스 세션 + + Returns: + List[str]: 권한 목록 + """ + try: + user_repo = UserRepository(db) + return user_repo.get_user_permissions(role) + except Exception as e: + logger.error(f"Get user permissions by role error: {str(e)}") + return [] + + +# 선택적 인증 (토큰이 있으면 검증, 없으면 None 반환) +async def get_current_user_optional( + request: Request, + db: Session = Depends(get_db) +) -> Optional[dict]: + """ + 선택적 사용자 인증 (토큰이 있으면 검증, 없으면 None) + + Args: + request: FastAPI Request 객체 + db: 데이터베이스 세션 + + Returns: + Optional[dict]: 사용자 정보 또는 None + """ + try: + # Authorization 헤더 확인 + authorization = request.headers.get('authorization') + if not authorization or not authorization.startswith('Bearer '): + return None + + token = authorization.split(' ')[1] + return await get_user_from_token(token, db) + + except Exception as e: + logger.debug(f"Optional auth failed: {str(e)}") + return None diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py new file mode 100644 index 0000000..5795364 --- /dev/null +++ b/backend/app/auth/models.py @@ -0,0 +1,354 @@ +""" +인증 시스템 모델 +TK-FB-Project의 사용자 모델을 참고하여 SQLAlchemy 기반으로 구현 +""" +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import text +import bcrypt + +from ..database import Base +from ..utils.logger import get_logger + +logger = get_logger(__name__) + + +class User(Base): + """사용자 모델""" + __tablename__ = "users" + + user_id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + password = Column(String(255), nullable=False) + name = Column(String(100), nullable=False) + email = Column(String(100), index=True) + + # 권한 관리 + role = Column(String(20), default='user', nullable=False) + access_level = Column(String(20), default='worker', nullable=False) + + # 계정 상태 관리 + is_active = Column(Boolean, default=True, nullable=False) + failed_login_attempts = Column(Integer, default=0) + locked_until = Column(DateTime, nullable=True) + + # 추가 정보 + department = Column(String(50)) + position = Column(String(50)) + phone = Column(String(20)) + + # 타임스탬프 + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + last_login_at = Column(DateTime, nullable=True) + + # 관계 설정 + login_logs = relationship("LoginLog", back_populates="user", cascade="all, delete-orphan") + sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + def to_dict(self) -> Dict[str, Any]: + """사용자 정보를 딕셔너리로 변환 (비밀번호 제외)""" + return { + 'user_id': self.user_id, + 'username': self.username, + 'name': self.name, + 'email': self.email, + 'role': self.role, + 'access_level': self.access_level, + 'is_active': self.is_active, + 'department': self.department, + 'position': self.position, + 'phone': self.phone, + 'created_at': self.created_at, + 'last_login_at': self.last_login_at + } + + def check_password(self, password: str) -> bool: + """비밀번호 확인""" + try: + return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8')) + except Exception as e: + logger.error(f"Password check failed for user {self.username}: {str(e)}") + return False + + def set_password(self, password: str): + """비밀번호 설정 (해싱)""" + try: + salt = bcrypt.gensalt() + self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') + except Exception as e: + logger.error(f"Password hashing failed for user {self.username}: {str(e)}") + raise + + def is_locked(self) -> bool: + """계정 잠금 상태 확인""" + if self.locked_until is None: + return False + return datetime.utcnow() < self.locked_until + + def lock_account(self, minutes: int = 15): + """계정 잠금""" + self.locked_until = datetime.utcnow() + timedelta(minutes=minutes) + logger.warning(f"User account locked: {self.username} for {minutes} minutes") + + def unlock_account(self): + """계정 잠금 해제""" + self.locked_until = None + self.failed_login_attempts = 0 + logger.info(f"User account unlocked: {self.username}") + + def increment_failed_attempts(self): + """로그인 실패 횟수 증가""" + self.failed_login_attempts += 1 + if self.failed_login_attempts >= 5: + self.lock_account() + + def reset_failed_attempts(self): + """로그인 실패 횟수 초기화""" + self.failed_login_attempts = 0 + self.locked_until = None + + def update_last_login(self): + """마지막 로그인 시간 업데이트""" + self.last_login_at = datetime.utcnow() + + +class LoginLog(Base): + """로그인 이력 모델""" + __tablename__ = "login_logs" + + log_id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False) + login_time = Column(DateTime, default=func.now()) + ip_address = Column(String(45)) + user_agent = Column(Text) + login_status = Column(String(20), nullable=False) # 'success' or 'failed' + failure_reason = Column(String(100)) + session_duration = Column(Integer) # 세션 지속 시간 (초) + created_at = Column(DateTime, default=func.now()) + + # 관계 설정 + user = relationship("User", back_populates="login_logs") + + def __repr__(self): + return f"" + + +class UserSession(Base): + """사용자 세션 모델 (Refresh Token 관리)""" + __tablename__ = "user_sessions" + + session_id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False) + refresh_token = Column(String(500), nullable=False, index=True) + expires_at = Column(DateTime, nullable=False) + ip_address = Column(String(45)) + user_agent = Column(Text) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + # 관계 설정 + user = relationship("User", back_populates="sessions") + + def __repr__(self): + return f"" + + def is_expired(self) -> bool: + """세션 만료 여부 확인""" + return datetime.utcnow() > self.expires_at + + def deactivate(self): + """세션 비활성화""" + self.is_active = False + + +class Permission(Base): + """권한 모델""" + __tablename__ = "permissions" + + permission_id = Column(Integer, primary_key=True, index=True) + permission_name = Column(String(50), unique=True, nullable=False) + description = Column(Text) + module = Column(String(30), index=True) # 모듈별 권한 관리 + created_at = Column(DateTime, default=func.now()) + + def __repr__(self): + return f"" + + +class RolePermission(Base): + """역할-권한 매핑 모델""" + __tablename__ = "role_permissions" + + role_permission_id = Column(Integer, primary_key=True, index=True) + role = Column(String(20), nullable=False, index=True) + permission_id = Column(Integer, ForeignKey("permissions.permission_id", ondelete="CASCADE")) + created_at = Column(DateTime, default=func.now()) + + # 관계 설정 + permission = relationship("Permission") + + def __repr__(self): + return f"" + + +class UserRepository: + """사용자 데이터 접근 계층""" + + def __init__(self, db: Session): + self.db = db + + def find_by_username(self, username: str) -> Optional[User]: + """사용자명으로 사용자 조회""" + try: + return self.db.query(User).filter(User.username == username).first() + except Exception as e: + logger.error(f"Failed to find user by username {username}: {str(e)}") + return None + + def find_by_id(self, user_id: int) -> Optional[User]: + """사용자 ID로 사용자 조회""" + try: + return self.db.query(User).filter(User.user_id == user_id).first() + except Exception as e: + logger.error(f"Failed to find user by id {user_id}: {str(e)}") + return None + + def find_by_email(self, email: str) -> Optional[User]: + """이메일로 사용자 조회""" + try: + return self.db.query(User).filter(User.email == email).first() + except Exception as e: + logger.error(f"Failed to find user by email {email}: {str(e)}") + return None + + def create_user(self, user_data: Dict[str, Any]) -> User: + """새 사용자 생성""" + try: + user = User(**user_data) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + logger.info(f"User created: {user.username}") + return user + except Exception as e: + self.db.rollback() + logger.error(f"Failed to create user: {str(e)}") + raise + + def update_user(self, user: User) -> User: + """사용자 정보 업데이트""" + try: + self.db.commit() + self.db.refresh(user) + logger.info(f"User updated: {user.username}") + return user + except Exception as e: + self.db.rollback() + logger.error(f"Failed to update user {user.username}: {str(e)}") + raise + + def delete_user(self, user: User): + """사용자 삭제""" + try: + self.db.delete(user) + self.db.commit() + logger.info(f"User deleted: {user.username}") + except Exception as e: + self.db.rollback() + logger.error(f"Failed to delete user {user.username}: {str(e)}") + raise + + def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]: + """모든 사용자 조회""" + try: + return self.db.query(User).offset(skip).limit(limit).all() + except Exception as e: + logger.error(f"Failed to get all users: {str(e)}") + return [] + + def get_user_permissions(self, role: str) -> List[str]: + """사용자 역할에 따른 권한 목록 조회""" + try: + query = text(""" + SELECT p.permission_name + FROM permissions p + JOIN role_permissions rp ON p.permission_id = rp.permission_id + WHERE rp.role = :role + """) + result = self.db.execute(query, {"role": role}) + return [row[0] for row in result.fetchall()] + except Exception as e: + logger.error(f"Failed to get permissions for role {role}: {str(e)}") + return [] + + def record_login_log(self, user_id: int, ip_address: str, user_agent: str, + status: str, failure_reason: str = None): + """로그인 이력 기록""" + try: + log = LoginLog( + user_id=user_id, + ip_address=ip_address, + user_agent=user_agent, + login_status=status, + failure_reason=failure_reason + ) + self.db.add(log) + self.db.commit() + logger.debug(f"Login log recorded for user_id {user_id}: {status}") + except Exception as e: + self.db.rollback() + logger.error(f"Failed to record login log: {str(e)}") + + def create_session(self, user_id: int, refresh_token: str, expires_at: datetime, + ip_address: str, user_agent: str) -> UserSession: + """사용자 세션 생성""" + try: + session = UserSession( + user_id=user_id, + refresh_token=refresh_token, + expires_at=expires_at, + ip_address=ip_address, + user_agent=user_agent + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + logger.debug(f"Session created for user_id {user_id}") + return session + except Exception as e: + self.db.rollback() + logger.error(f"Failed to create session: {str(e)}") + raise + + def find_session_by_token(self, refresh_token: str) -> Optional[UserSession]: + """리프레시 토큰으로 세션 조회""" + try: + return self.db.query(UserSession).filter( + UserSession.refresh_token == refresh_token, + UserSession.is_active == True + ).first() + except Exception as e: + logger.error(f"Failed to find session by token: {str(e)}") + return None + + def deactivate_user_sessions(self, user_id: int): + """사용자의 모든 세션 비활성화""" + try: + self.db.query(UserSession).filter( + UserSession.user_id == user_id + ).update({"is_active": False}) + self.db.commit() + logger.info(f"All sessions deactivated for user_id {user_id}") + except Exception as e: + self.db.rollback() + logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}") + raise diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..6a19fbc --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,284 @@ +""" +TK-MP-Project 설정 관리 +환경별 설정을 중앙화하여 관리 +""" +import os +from typing import List, Optional, Dict, Any +from pathlib import Path +from pydantic_settings import BaseSettings +from pydantic import Field, validator +import json + + +class DatabaseSettings(BaseSettings): + """데이터베이스 설정""" + url: str = Field( + default="postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom", + description="데이터베이스 연결 URL" + ) + pool_size: int = Field(default=10, description="연결 풀 크기") + max_overflow: int = Field(default=20, description="최대 오버플로우") + pool_timeout: int = Field(default=30, description="연결 타임아웃 (초)") + pool_recycle: int = Field(default=3600, description="연결 재활용 시간 (초)") + echo: bool = Field(default=False, description="SQL 로그 출력 여부") + + class Config: + env_prefix = "DB_" + + +class RedisSettings(BaseSettings): + """Redis 설정""" + url: str = Field(default="redis://redis:6379", description="Redis 연결 URL") + max_connections: int = Field(default=20, description="최대 연결 수") + socket_timeout: int = Field(default=5, description="소켓 타임아웃 (초)") + socket_connect_timeout: int = Field(default=5, description="연결 타임아웃 (초)") + retry_on_timeout: bool = Field(default=True, description="타임아웃 시 재시도") + decode_responses: bool = Field(default=False, description="응답 디코딩 여부") + + class Config: + env_prefix = "REDIS_" + + +class SecuritySettings(BaseSettings): + """보안 설정""" + cors_origins: List[str] = Field(default=[], description="CORS 허용 도메인") + cors_methods: List[str] = Field( + default=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + description="CORS 허용 메서드" + ) + cors_headers: List[str] = Field( + default=["*"], + description="CORS 허용 헤더" + ) + cors_credentials: bool = Field(default=True, description="CORS 자격증명 허용") + + # 파일 업로드 보안 + max_file_size: int = Field(default=50 * 1024 * 1024, description="최대 파일 크기 (bytes)") + allowed_file_extensions: List[str] = Field( + default=['.xlsx', '.xls', '.csv'], + description="허용된 파일 확장자" + ) + upload_path: str = Field(default="uploads", description="업로드 경로") + + # API 보안 + api_key_header: str = Field(default="X-API-Key", description="API 키 헤더명") + rate_limit_per_minute: int = Field(default=100, description="분당 요청 제한") + + class Config: + env_prefix = "SECURITY_" + + +class LoggingSettings(BaseSettings): + """로깅 설정""" + level: str = Field(default="INFO", description="로그 레벨") + file_path: str = Field(default="logs/app.log", description="로그 파일 경로") + max_file_size: int = Field(default=10 * 1024 * 1024, description="로그 파일 최대 크기") + backup_count: int = Field(default=5, description="백업 파일 수") + format: str = Field( + default="%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s", + description="로그 포맷" + ) + date_format: str = Field(default="%Y-%m-%d %H:%M:%S", description="날짜 포맷") + + # 환경별 로그 레벨 + development_level: str = Field(default="DEBUG", description="개발 환경 로그 레벨") + production_level: str = Field(default="INFO", description="운영 환경 로그 레벨") + test_level: str = Field(default="WARNING", description="테스트 환경 로그 레벨") + + class Config: + env_prefix = "LOG_" + + +class PerformanceSettings(BaseSettings): + """성능 설정""" + # 캐시 설정 + cache_ttl_default: int = Field(default=3600, description="기본 캐시 TTL (초)") + cache_ttl_files: int = Field(default=300, description="파일 목록 캐시 TTL") + cache_ttl_materials: int = Field(default=600, description="자재 목록 캐시 TTL") + cache_ttl_jobs: int = Field(default=1800, description="작업 목록 캐시 TTL") + cache_ttl_classification: int = Field(default=3600, description="분류 결과 캐시 TTL") + cache_ttl_statistics: int = Field(default=900, description="통계 데이터 캐시 TTL") + + # 파일 처리 설정 + chunk_size: int = Field(default=1000, description="파일 처리 청크 크기") + max_workers: int = Field(default=4, description="최대 워커 수") + memory_limit_mb: int = Field(default=512, description="메모리 제한 (MB)") + + class Config: + env_prefix = "PERF_" + + +class Settings(BaseSettings): + """메인 애플리케이션 설정""" + + # 기본 설정 + app_name: str = Field(default="TK-MP BOM Management API", description="애플리케이션 이름") + app_version: str = Field(default="1.0.0", description="애플리케이션 버전") + app_description: str = Field( + default="자재 분류 및 프로젝트 관리 시스템", + description="애플리케이션 설명" + ) + debug: bool = Field(default=False, description="디버그 모드") + + # 환경 설정 + environment: str = Field( + default="development", + description="실행 환경 (development, production, test, synology)" + ) + + # 서버 설정 + host: str = Field(default="0.0.0.0", description="서버 호스트") + port: int = Field(default=8000, description="서버 포트") + reload: bool = Field(default=False, description="자동 재로드") + workers: int = Field(default=1, description="워커 프로세스 수") + + # 하위 설정들 + database: DatabaseSettings = Field(default_factory=DatabaseSettings) + redis: RedisSettings = Field(default_factory=RedisSettings) + security: SecuritySettings = Field(default_factory=SecuritySettings) + logging: LoggingSettings = Field(default_factory=LoggingSettings) + performance: PerformanceSettings = Field(default_factory=PerformanceSettings) + + # 추가 설정 + timezone: str = Field(default="Asia/Seoul", description="시간대") + language: str = Field(default="ko", description="기본 언어") + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + case_sensitive = False + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._setup_environment_specific_settings() + self._setup_cors_origins() + self._validate_settings() + + @validator('environment') + def validate_environment(cls, v): + """환경 값 검증""" + allowed_environments = ['development', 'production', 'test', 'synology'] + if v not in allowed_environments: + raise ValueError(f'Environment must be one of: {allowed_environments}') + return v + + @validator('port') + def validate_port(cls, v): + """포트 번호 검증""" + if not 1 <= v <= 65535: + raise ValueError('Port must be between 1 and 65535') + return v + + def _setup_environment_specific_settings(self): + """환경별 특정 설정 적용""" + if self.environment == "development": + self.debug = True + self.reload = True + self.database.echo = True + self.logging.level = self.logging.development_level + + elif self.environment == "production": + self.debug = False + self.reload = False + self.database.echo = False + self.logging.level = self.logging.production_level + self.workers = max(2, os.cpu_count() or 1) + + elif self.environment == "test": + self.debug = False + self.reload = False + self.database.echo = False + self.logging.level = self.logging.test_level + # 테스트용 인메모리 데이터베이스 + self.database.url = "sqlite:///:memory:" + + elif self.environment == "synology": + self.debug = False + self.reload = False + self.host = "0.0.0.0" + self.port = 10080 + + def _setup_cors_origins(self): + """환경별 CORS origins 설정""" + if not self.security.cors_origins: + cors_config = { + "development": [ + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:5173" + ], + "production": [ + "https://your-domain.com", + "https://api.your-domain.com" + ], + "synology": [ + "http://192.168.0.3:10173", + "http://localhost:10173" + ], + "test": [ + "http://testserver" + ] + } + + self.security.cors_origins = cors_config.get( + self.environment, + cors_config["development"] + ) + + def _validate_settings(self): + """설정 검증""" + # 로그 디렉토리 생성 + log_dir = Path(self.logging.file_path).parent + log_dir.mkdir(parents=True, exist_ok=True) + + # 업로드 디렉토리 생성 + upload_dir = Path(self.security.upload_path) + upload_dir.mkdir(parents=True, exist_ok=True) + + def get_database_url(self) -> str: + """데이터베이스 URL 반환""" + return self.database.url + + def get_redis_url(self) -> str: + """Redis URL 반환""" + return self.redis.url + + def is_development(self) -> bool: + """개발 환경 여부""" + return self.environment == "development" + + def is_production(self) -> bool: + """운영 환경 여부""" + return self.environment == "production" + + def is_test(self) -> bool: + """테스트 환경 여부""" + return self.environment == "test" + + def get_cors_config(self) -> Dict[str, Any]: + """CORS 설정 반환""" + return { + "allow_origins": self.security.cors_origins, + "allow_methods": self.security.cors_methods, + "allow_headers": self.security.cors_headers, + "allow_credentials": self.security.cors_credentials + } + + def to_dict(self) -> Dict[str, Any]: + """설정을 딕셔너리로 변환""" + return self.dict() + + def save_to_file(self, file_path: str): + """설정을 파일로 저장""" + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False, default=str) + + +# 전역 설정 인스턴스 +settings = Settings() + + +def get_settings() -> Settings: + """설정 인스턴스 반환""" + return settings diff --git a/backend/app/main.py b/backend/app/main.py index 8015b0c..5cf6d5a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,162 +7,83 @@ from fastapi import Depends from typing import Optional, List, Dict import os import shutil +# 설정 및 로깅 import +from .config import get_settings +from .utils.logger import get_logger +from .utils.error_handlers import setup_error_handlers + +# 설정 로드 +settings = get_settings() + +# 로거 설정 +logger = get_logger(__name__) # FastAPI 앱 생성 app = FastAPI( - title="TK-MP BOM Management API", + title=settings.app_name, description="자재 분류 및 프로젝트 관리 시스템", - version="1.0.0" + version=settings.app_version, + debug=settings.debug ) -# CORS 설정 +# 에러 핸들러 설정 +setup_error_handlers(app) + +# CORS 설정 (환경별 분리) +cors_config = settings.get_cors_config() app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + **cors_config ) +logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}") + # 라우터들 import 및 등록 try: from .routers import files app.include_router(files.router, prefix="/files", tags=["files"]) except ImportError: - print("files 라우터를 찾을 수 없습니다") + logger.warning("files 라우터를 찾을 수 없습니다") try: from .routers import jobs app.include_router(jobs.router, prefix="/jobs", tags=["jobs"]) except ImportError: - print("jobs 라우터를 찾을 수 없습니다") + logger.warning("jobs 라우터를 찾을 수 없습니다") try: from .routers import purchase app.include_router(purchase.router, tags=["purchase"]) except ImportError: - print("purchase 라우터를 찾을 수 없습니다") + logger.warning("purchase 라우터를 찾을 수 없습니다") try: from .routers import material_comparison app.include_router(material_comparison.router, tags=["material-comparison"]) except ImportError: - print("material_comparison 라우터를 찾을 수 없습니다") + logger.warning("material_comparison 라우터를 찾을 수 없습니다") -# 파일 목록 조회 API -@app.get("/files") -async def get_files( - job_no: Optional[str] = None, # project_id 대신 job_no 사용 - show_history: bool = False, # 이력 표시 여부 - db: Session = Depends(get_db) -): - """파일 목록 조회 (BOM별 그룹화)""" - try: - if show_history: - # 전체 이력 표시 - query = "SELECT * FROM files" - params = {} - - if job_no: - query += " WHERE job_no = :job_no" - params["job_no"] = job_no - - query += " ORDER BY original_filename, revision DESC" - else: - # 최신 리비전만 표시 - if job_no: - query = """ - SELECT f1.* FROM files f1 - INNER JOIN ( - SELECT original_filename, MAX(revision) as max_revision - FROM files - WHERE job_no = :job_no - GROUP BY original_filename - ) f2 ON f1.original_filename = f2.original_filename - AND f1.revision = f2.max_revision - WHERE f1.job_no = :job_no - ORDER BY f1.upload_date DESC - """ - params = {"job_no": job_no} - else: - # job_no가 없으면 전체 파일 조회 - query = "SELECT * FROM files ORDER BY upload_date DESC" - params = {} - - result = db.execute(text(query), params) - files = result.fetchall() - - return [ - { - "id": f.id, - "filename": f.original_filename, - "original_filename": f.original_filename, - "name": f.original_filename, - "job_no": f.job_no, # job_no 사용 - "bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명 - "revision": f.revision or "Rev.0", # 실제 리비전 또는 기본값 - "parsed_count": f.parsed_count or 0, # 파싱된 자재 수 - "bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용 - "status": "active" if f.is_active else "inactive", # is_active 상태 - "file_size": f.file_size, - "created_at": f.upload_date, - "upload_date": f.upload_date, - "description": f"파일: {f.original_filename}" - } - for f in files - ] - except Exception as e: - print(f"파일 목록 조회 에러: {str(e)}") - return {"error": f"파일 목록 조회 실패: {str(e)}"} +try: + from .routers import tubing + app.include_router(tubing.router, prefix="/tubing", tags=["tubing"]) +except ImportError: + logger.warning("tubing 라우터를 찾을 수 없습니다") -# 파일 삭제 API -@app.delete("/files/{file_id}") -async def delete_file( - file_id: int, - db: Session = Depends(get_db) -): - """파일 삭제""" - try: - # 먼저 파일 정보 조회 - file_query = text("SELECT * FROM files WHERE id = :file_id") - file_result = db.execute(file_query, {"file_id": file_id}) - file = file_result.fetchone() - - if not file: - return {"error": "파일을 찾을 수 없습니다"} - - # 먼저 상세 테이블의 데이터 삭제 (외래 키 제약 조건 때문) - # 각 자재 타입별 상세 테이블 데이터 삭제 - detail_tables = [ - 'pipe_details', 'fitting_details', 'valve_details', - 'flange_details', 'bolt_details', 'gasket_details', - 'instrument_details' - ] - - # 해당 파일의 materials ID 조회 - material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id") - material_ids_result = db.execute(material_ids_query, {"file_id": file_id}) - material_ids = [row[0] for row in material_ids_result] - - if material_ids: - # 각 상세 테이블에서 관련 데이터 삭제 - for table in detail_tables: - delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)") - db.execute(delete_detail_query, {"material_ids": material_ids}) - - # materials 테이블 데이터 삭제 - materials_query = text("DELETE FROM materials WHERE file_id = :file_id") - db.execute(materials_query, {"file_id": file_id}) - - # 파일 삭제 - delete_query = text("DELETE FROM files WHERE id = :file_id") - db.execute(delete_query, {"file_id": file_id}) - - db.commit() - return {"success": True, "message": "파일과 관련 데이터가 삭제되었습니다"} - except Exception as e: - db.rollback() - return {"error": f"파일 삭제 실패: {str(e)}"} +# 파일 관리 API 라우터 등록 +try: + from .api import file_management + app.include_router(file_management.router, tags=["file-management"]) + logger.info("파일 관리 API 라우터 등록 완료") +except ImportError as e: + logger.warning(f"파일 관리 라우터를 찾을 수 없습니다: {e}") + +# 인증 API 라우터 등록 +try: + from .auth import auth_router + app.include_router(auth_router, prefix="/auth", tags=["authentication"]) + logger.info("인증 API 라우터 등록 완료") +except ImportError as e: + logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}") # 프로젝트 관리 API (비활성화 - jobs 테이블 사용) # projects 테이블은 더 이상 사용하지 않음 diff --git a/backend/app/models.py b/backend/app/models.py index 3dfea24..25bbc34 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -276,8 +276,7 @@ class RequirementType(Base): created_at = Column(DateTime, default=datetime.utcnow) - # 관계 설정 - requirements = relationship("UserRequirement", back_populates="requirement_type") + # 관계 설정은 문자열 기반이므로 제거 class UserRequirement(Base): """사용자 추가 요구사항""" @@ -308,4 +307,145 @@ class UserRequirement(Base): # 관계 설정 file = relationship("File", backref="user_requirements") - requirement_type_rel = relationship("RequirementType", back_populates="requirements") + +# ========== Tubing 시스템 모델들 ========== + +class TubingCategory(Base): + """Tubing 카테고리 (일반, VCR, 위생용 등)""" + __tablename__ = "tubing_categories" + + id = Column(Integer, primary_key=True, index=True) + category_code = Column(String(20), unique=True, nullable=False) + category_name = Column(String(100), nullable=False) + description = Column(Text) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specifications = relationship("TubingSpecification", back_populates="category") + +class TubingSpecification(Base): + """Tubing 규격 마스터""" + __tablename__ = "tubing_specifications" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("tubing_categories.id")) + spec_code = Column(String(50), unique=True, nullable=False) + spec_name = Column(String(200), nullable=False) + + # 물리적 규격 + outer_diameter_mm = Column(Numeric(8, 3)) + wall_thickness_mm = Column(Numeric(6, 3)) + inner_diameter_mm = Column(Numeric(8, 3)) + + # 재질 정보 + material_grade = Column(String(100)) + material_standard = Column(String(100)) + + # 압력/온도 등급 + max_pressure_bar = Column(Numeric(8, 2)) + max_temperature_c = Column(Numeric(6, 2)) + min_temperature_c = Column(Numeric(6, 2)) + + # 표준 규격 + standard_length_m = Column(Numeric(8, 3)) + bend_radius_min_mm = Column(Numeric(8, 2)) + + # 기타 정보 + surface_finish = Column(String(100)) + hardness = Column(String(50)) + notes = Column(Text) + + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + category = relationship("TubingCategory", back_populates="specifications") + products = relationship("TubingProduct", back_populates="specification") + +class TubingManufacturer(Base): + """Tubing 제조사""" + __tablename__ = "tubing_manufacturers" + + id = Column(Integer, primary_key=True, index=True) + manufacturer_code = Column(String(20), unique=True, nullable=False) + manufacturer_name = Column(String(200), nullable=False) + country = Column(String(100)) + website = Column(String(500)) + contact_info = Column(JSON) # JSONB 타입 + quality_certs = Column(JSON) # JSONB 타입 + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + products = relationship("TubingProduct", back_populates="manufacturer") + +class TubingProduct(Base): + """제조사별 Tubing 제품 (품목번호 매핑)""" + __tablename__ = "tubing_products" + + id = Column(Integer, primary_key=True, index=True) + specification_id = Column(Integer, ForeignKey("tubing_specifications.id")) + manufacturer_id = Column(Integer, ForeignKey("tubing_manufacturers.id")) + + # 제조사 품목번호 정보 + manufacturer_part_number = Column(String(200), nullable=False) + manufacturer_product_name = Column(String(300)) + + # 가격/공급 정보 + list_price = Column(Numeric(12, 2)) + currency = Column(String(10), default='KRW') + lead_time_days = Column(Integer) + minimum_order_qty = Column(Numeric(10, 3)) + standard_packaging_qty = Column(Numeric(10, 3)) + + # 가용성 정보 + availability_status = Column(String(50)) + last_price_update = Column(DateTime) + + # 추가 정보 + datasheet_url = Column(String(500)) + catalog_page = Column(String(100)) + notes = Column(Text) + + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specification = relationship("TubingSpecification", back_populates="products") + manufacturer = relationship("TubingManufacturer", back_populates="products") + material_mappings = relationship("MaterialTubingMapping", back_populates="tubing_product") + +class MaterialTubingMapping(Base): + """BOM 자재와 Tubing 제품 매핑""" + __tablename__ = "material_tubing_mapping" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("materials.id", ondelete="CASCADE")) + tubing_product_id = Column(Integer, ForeignKey("tubing_products.id")) + + # 매핑 정보 + confidence_score = Column(Numeric(3, 2)) + mapping_method = Column(String(50)) + mapped_by = Column(String(100)) + mapped_at = Column(DateTime, default=datetime.utcnow) + + # 수량 정보 + required_length_m = Column(Numeric(10, 3)) + calculated_quantity = Column(Numeric(10, 3)) + + # 검증 정보 + is_verified = Column(Boolean, default=False) + verified_by = Column(String(100)) + verified_at = Column(DateTime) + + notes = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("Material", backref="tubing_mappings") + tubing_product = relationship("TubingProduct", back_populates="material_mappings") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 3b8bd48..a416ba4 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -12,7 +12,11 @@ from pathlib import Path import json from ..database import get_db +from ..utils.logger import get_logger from app.services.material_classifier import classify_material + +# 로거 설정 +logger = get_logger(__name__) from app.services.integrated_classifier import classify_material_integrated, should_exclude_material from app.services.bolt_classifier import classify_bolt from app.services.flange_classifier import classify_flange @@ -664,10 +668,15 @@ async def upload_file( else: gasket_type = str(gasket_type_info) if gasket_type_info else "UNKNOWN" - # 가스켓 소재 (GRAPHITE, PTFE 등) + # 가스켓 소재 - SWG의 경우 메탈 부분을 우선으로 material_type = "" if isinstance(gasket_material_info, dict): - material_type = gasket_material_info.get("material", "UNKNOWN") + # SWG 상세 정보가 있으면 메탈 부분을 material_type으로 사용 + swg_details = gasket_material_info.get("swg_details", {}) + if swg_details and swg_details.get("outer_ring"): + material_type = swg_details.get("outer_ring", "UNKNOWN") # SS304 + else: + material_type = gasket_material_info.get("material", "UNKNOWN") else: material_type = str(gasket_material_info) if gasket_material_info else "UNKNOWN" @@ -978,7 +987,7 @@ async def get_files( try: query = """ SELECT id, filename, original_filename, job_no, revision, - description, file_size, parsed_count, created_at, is_active + description, file_size, parsed_count, upload_date, is_active FROM files WHERE is_active = TRUE """ @@ -988,7 +997,7 @@ async def get_files( query += " AND job_no = :job_no" params["job_no"] = job_no - query += " ORDER BY created_at DESC" + query += " ORDER BY upload_date DESC" result = db.execute(text(query), params) files = result.fetchall() @@ -1003,7 +1012,7 @@ async def get_files( "description": file.description, "file_size": file.file_size, "parsed_count": file.parsed_count, - "created_at": file.created_at, + "created_at": file.upload_date, "is_active": file.is_active } for file in files @@ -1012,6 +1021,47 @@ async def get_files( except Exception as e: raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") +@router.get("/stats") +async def get_files_stats(db: Session = Depends(get_db)): + """파일 및 자재 통계 조회""" + try: + # 총 파일 수 + files_query = text("SELECT COUNT(*) FROM files WHERE is_active = true") + total_files = db.execute(files_query).fetchone()[0] + + # 총 자재 수 + materials_query = text("SELECT COUNT(*) FROM materials") + total_materials = db.execute(materials_query).fetchone()[0] + + # 최근 업로드 (최근 5개) + recent_query = text(""" + SELECT f.original_filename, f.upload_date, f.parsed_count, j.job_name + FROM files f + LEFT JOIN jobs j ON f.job_no = j.job_no + WHERE f.is_active = true + ORDER BY f.upload_date DESC + LIMIT 5 + """) + recent_uploads = db.execute(recent_query).fetchall() + + return { + "success": True, + "totalFiles": total_files, + "totalMaterials": total_materials, + "recentUploads": [ + { + "filename": upload.original_filename, + "created_at": upload.upload_date, + "parsed_count": upload.parsed_count or 0, + "project_name": upload.job_name or "Unknown" + } + for upload in recent_uploads + ] + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}") + @router.delete("/files/{file_id}") async def delete_file(file_id: int, db: Session = Depends(get_db)): """파일 삭제""" diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py index 494ed4c..294d0d6 100644 --- a/backend/app/routers/jobs.py +++ b/backend/app/routers/jobs.py @@ -20,6 +20,7 @@ class JobCreate(BaseModel): contract_date: Optional[date] = None delivery_date: Optional[date] = None delivery_terms: Optional[str] = None + project_type: Optional[str] = "냉동기" description: Optional[str] = None @router.get("/") @@ -34,7 +35,7 @@ async def get_jobs( query = """ SELECT job_no, job_name, client_name, end_user, epc_company, project_site, contract_date, delivery_date, delivery_terms, - status, description, created_by, created_at, updated_at, is_active + project_type, status, description, created_by, created_at, updated_at, is_active FROM jobs WHERE is_active = true """ @@ -66,6 +67,7 @@ async def get_jobs( "contract_date": job.contract_date, "delivery_date": job.delivery_date, "delivery_terms": job.delivery_terms, + "project_type": job.project_type, "status": job.status, "description": job.description, "created_at": job.created_at, @@ -85,7 +87,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)): query = text(""" SELECT job_no, job_name, client_name, end_user, epc_company, project_site, contract_date, delivery_date, delivery_terms, - status, description, created_by, created_at, updated_at, is_active + project_type, status, description, created_by, created_at, updated_at, is_active FROM jobs WHERE job_no = :job_no AND is_active = true """) @@ -108,6 +110,7 @@ async def get_job(job_no: str, db: Session = Depends(get_db)): "contract_date": job.contract_date, "delivery_date": job.delivery_date, "delivery_terms": job.delivery_terms, + "project_type": job.project_type, "status": job.status, "description": job.description, "created_by": job.created_by, @@ -139,14 +142,14 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)): INSERT INTO jobs ( job_no, job_name, client_name, end_user, epc_company, project_site, contract_date, delivery_date, delivery_terms, - description, created_by, status, is_active + project_type, description, created_by, status, is_active ) VALUES ( :job_no, :job_name, :client_name, :end_user, :epc_company, :project_site, :contract_date, :delivery_date, :delivery_terms, - :description, :created_by, :status, :is_active + :project_type, :description, :created_by, :status, :is_active ) - RETURNING job_no, job_name, client_name + RETURNING job_no, job_name, client_name, project_type """) result = db.execute(insert_query, { @@ -165,7 +168,8 @@ async def create_job(job: JobCreate, db: Session = Depends(get_db)): "job": { "job_no": new_job.job_no, "job_name": new_job.job_name, - "client_name": new_job.client_name + "client_name": new_job.client_name, + "project_type": new_job.project_type } } diff --git a/backend/app/routers/tubing.py b/backend/app/routers/tubing.py new file mode 100644 index 0000000..f3c6170 --- /dev/null +++ b/backend/app/routers/tubing.py @@ -0,0 +1,538 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import text +from typing import Optional, List +from datetime import datetime, date +from pydantic import BaseModel +from decimal import Decimal + +from ..database import get_db +from ..models import ( + TubingCategory, TubingSpecification, TubingManufacturer, + TubingProduct, MaterialTubingMapping, Material +) + +router = APIRouter() + +# ================================ +# Pydantic 모델들 +# ================================ + +class TubingCategoryResponse(BaseModel): + id: int + category_code: str + category_name: str + description: Optional[str] = None + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + +class TubingManufacturerResponse(BaseModel): + id: int + manufacturer_code: str + manufacturer_name: str + country: Optional[str] = None + website: Optional[str] = None + is_active: bool + + class Config: + from_attributes = True + +class TubingSpecificationResponse(BaseModel): + id: int + spec_code: str + spec_name: str + category_name: Optional[str] = None + outer_diameter_mm: Optional[float] = None + wall_thickness_mm: Optional[float] = None + inner_diameter_mm: Optional[float] = None + material_grade: Optional[str] = None + material_standard: Optional[str] = None + max_pressure_bar: Optional[float] = None + max_temperature_c: Optional[float] = None + min_temperature_c: Optional[float] = None + standard_length_m: Optional[float] = None + surface_finish: Optional[str] = None + is_active: bool + + class Config: + from_attributes = True + +class TubingProductResponse(BaseModel): + id: int + specification_id: int + manufacturer_id: int + manufacturer_part_number: str + manufacturer_product_name: Optional[str] = None + spec_name: Optional[str] = None + manufacturer_name: Optional[str] = None + list_price: Optional[float] = None + currency: Optional[str] = 'KRW' + lead_time_days: Optional[int] = None + availability_status: Optional[str] = None + datasheet_url: Optional[str] = None + notes: Optional[str] = None + is_active: bool + + class Config: + from_attributes = True + +class TubingProductCreate(BaseModel): + specification_id: int + manufacturer_id: int + manufacturer_part_number: str + manufacturer_product_name: Optional[str] = None + list_price: Optional[float] = None + currency: str = 'KRW' + lead_time_days: Optional[int] = None + minimum_order_qty: Optional[float] = None + standard_packaging_qty: Optional[float] = None + availability_status: Optional[str] = None + datasheet_url: Optional[str] = None + catalog_page: Optional[str] = None + notes: Optional[str] = None + +class MaterialTubingMappingCreate(BaseModel): + material_id: int + tubing_product_id: int + confidence_score: Optional[float] = None + mapping_method: str = 'manual' + required_length_m: Optional[float] = None + calculated_quantity: Optional[float] = None + notes: Optional[str] = None + +# ================================ +# API 엔드포인트들 +# ================================ + +@router.get("/categories", response_model=List[TubingCategoryResponse]) +async def get_tubing_categories( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_db) +): + """Tubing 카테고리 목록 조회""" + try: + categories = db.query(TubingCategory)\ + .filter(TubingCategory.is_active == True)\ + .offset(skip)\ + .limit(limit)\ + .all() + + return categories + except Exception as e: + raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}") + +@router.get("/manufacturers", response_model=List[TubingManufacturerResponse]) +async def get_tubing_manufacturers( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + search: Optional[str] = Query(None), + country: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """Tubing 제조사 목록 조회""" + try: + query = db.query(TubingManufacturer)\ + .filter(TubingManufacturer.is_active == True) + + if search: + query = query.filter( + TubingManufacturer.manufacturer_name.ilike(f"%{search}%") | + TubingManufacturer.manufacturer_code.ilike(f"%{search}%") + ) + + if country: + query = query.filter(TubingManufacturer.country.ilike(f"%{country}%")) + + manufacturers = query.offset(skip).limit(limit).all() + return manufacturers + + except Exception as e: + raise HTTPException(status_code=500, detail=f"제조사 조회 실패: {str(e)}") + +@router.get("/specifications", response_model=List[TubingSpecificationResponse]) +async def get_tubing_specifications( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + category_id: Optional[int] = Query(None), + material_grade: Optional[str] = Query(None), + outer_diameter_min: Optional[float] = Query(None), + outer_diameter_max: Optional[float] = Query(None), + search: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """Tubing 규격 목록 조회""" + try: + query = db.query(TubingSpecification)\ + .options(joinedload(TubingSpecification.category))\ + .filter(TubingSpecification.is_active == True) + + if category_id: + query = query.filter(TubingSpecification.category_id == category_id) + + if material_grade: + query = query.filter(TubingSpecification.material_grade.ilike(f"%{material_grade}%")) + + if outer_diameter_min: + query = query.filter(TubingSpecification.outer_diameter_mm >= outer_diameter_min) + + if outer_diameter_max: + query = query.filter(TubingSpecification.outer_diameter_mm <= outer_diameter_max) + + if search: + query = query.filter( + TubingSpecification.spec_name.ilike(f"%{search}%") | + TubingSpecification.spec_code.ilike(f"%{search}%") | + TubingSpecification.material_grade.ilike(f"%{search}%") + ) + + specifications = query.offset(skip).limit(limit).all() + + # 응답 데이터 변환 + result = [] + for spec in specifications: + spec_dict = { + "id": spec.id, + "spec_code": spec.spec_code, + "spec_name": spec.spec_name, + "category_name": spec.category.category_name if spec.category else None, + "outer_diameter_mm": float(spec.outer_diameter_mm) if spec.outer_diameter_mm else None, + "wall_thickness_mm": float(spec.wall_thickness_mm) if spec.wall_thickness_mm else None, + "inner_diameter_mm": float(spec.inner_diameter_mm) if spec.inner_diameter_mm else None, + "material_grade": spec.material_grade, + "material_standard": spec.material_standard, + "max_pressure_bar": float(spec.max_pressure_bar) if spec.max_pressure_bar else None, + "max_temperature_c": float(spec.max_temperature_c) if spec.max_temperature_c else None, + "min_temperature_c": float(spec.min_temperature_c) if spec.min_temperature_c else None, + "standard_length_m": float(spec.standard_length_m) if spec.standard_length_m else None, + "surface_finish": spec.surface_finish, + "is_active": spec.is_active + } + result.append(spec_dict) + + return result + + except Exception as e: + raise HTTPException(status_code=500, detail=f"규격 조회 실패: {str(e)}") + +@router.get("/products", response_model=List[TubingProductResponse]) +async def get_tubing_products( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + specification_id: Optional[int] = Query(None), + manufacturer_id: Optional[int] = Query(None), + search: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """Tubing 제품 목록 조회""" + try: + query = db.query(TubingProduct)\ + .options( + joinedload(TubingProduct.specification), + joinedload(TubingProduct.manufacturer) + )\ + .filter(TubingProduct.is_active == True) + + if specification_id: + query = query.filter(TubingProduct.specification_id == specification_id) + + if manufacturer_id: + query = query.filter(TubingProduct.manufacturer_id == manufacturer_id) + + if search: + query = query.filter( + TubingProduct.manufacturer_part_number.ilike(f"%{search}%") | + TubingProduct.manufacturer_product_name.ilike(f"%{search}%") + ) + + products = query.offset(skip).limit(limit).all() + + # 응답 데이터 변환 + result = [] + for product in products: + product_dict = { + "id": product.id, + "specification_id": product.specification_id, + "manufacturer_id": product.manufacturer_id, + "manufacturer_part_number": product.manufacturer_part_number, + "manufacturer_product_name": product.manufacturer_product_name, + "spec_name": product.specification.spec_name if product.specification else None, + "manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None, + "list_price": float(product.list_price) if product.list_price else None, + "currency": product.currency, + "lead_time_days": product.lead_time_days, + "availability_status": product.availability_status, + "datasheet_url": product.datasheet_url, + "notes": product.notes, + "is_active": product.is_active + } + result.append(product_dict) + + return result + + except Exception as e: + raise HTTPException(status_code=500, detail=f"제품 조회 실패: {str(e)}") + +@router.post("/products", response_model=TubingProductResponse) +async def create_tubing_product( + product_data: TubingProductCreate, + db: Session = Depends(get_db) +): + """새 Tubing 제품 등록""" + try: + # 중복 확인 + existing = db.query(TubingProduct)\ + .filter( + TubingProduct.specification_id == product_data.specification_id, + TubingProduct.manufacturer_id == product_data.manufacturer_id, + TubingProduct.manufacturer_part_number == product_data.manufacturer_part_number + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail=f"동일한 제품이 이미 등록되어 있습니다: {product_data.manufacturer_part_number}" + ) + + # 새 제품 생성 + new_product = TubingProduct(**product_data.dict()) + db.add(new_product) + db.commit() + db.refresh(new_product) + + # 관련 정보와 함께 조회 + product_with_relations = db.query(TubingProduct)\ + .options( + joinedload(TubingProduct.specification), + joinedload(TubingProduct.manufacturer) + )\ + .filter(TubingProduct.id == new_product.id)\ + .first() + + return { + "id": product_with_relations.id, + "specification_id": product_with_relations.specification_id, + "manufacturer_id": product_with_relations.manufacturer_id, + "manufacturer_part_number": product_with_relations.manufacturer_part_number, + "manufacturer_product_name": product_with_relations.manufacturer_product_name, + "spec_name": product_with_relations.specification.spec_name if product_with_relations.specification else None, + "manufacturer_name": product_with_relations.manufacturer.manufacturer_name if product_with_relations.manufacturer else None, + "list_price": float(product_with_relations.list_price) if product_with_relations.list_price else None, + "currency": product_with_relations.currency, + "lead_time_days": product_with_relations.lead_time_days, + "availability_status": product_with_relations.availability_status, + "datasheet_url": product_with_relations.datasheet_url, + "notes": product_with_relations.notes, + "is_active": product_with_relations.is_active + } + + except HTTPException: + db.rollback() + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"제품 등록 실패: {str(e)}") + +@router.post("/material-mapping") +async def create_material_tubing_mapping( + mapping_data: MaterialTubingMappingCreate, + mapped_by: str = "admin", + db: Session = Depends(get_db) +): + """BOM 자재와 Tubing 제품 매핑 생성""" + try: + # 기존 매핑 확인 + existing = db.query(MaterialTubingMapping)\ + .filter( + MaterialTubingMapping.material_id == mapping_data.material_id, + MaterialTubingMapping.tubing_product_id == mapping_data.tubing_product_id + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail="이미 매핑된 자재와 제품입니다" + ) + + # 새 매핑 생성 + new_mapping = MaterialTubingMapping( + **mapping_data.dict(), + mapped_by=mapped_by + ) + db.add(new_mapping) + db.commit() + db.refresh(new_mapping) + + return { + "success": True, + "message": "매핑이 성공적으로 생성되었습니다", + "mapping_id": new_mapping.id + } + + except HTTPException: + db.rollback() + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"매핑 생성 실패: {str(e)}") + +@router.get("/material-mappings/{material_id}") +async def get_material_tubing_mappings( + material_id: int, + db: Session = Depends(get_db) +): + """특정 자재의 Tubing 매핑 조회""" + try: + mappings = db.query(MaterialTubingMapping)\ + .options( + joinedload(MaterialTubingMapping.tubing_product) + .joinedload(TubingProduct.specification), + joinedload(MaterialTubingMapping.tubing_product) + .joinedload(TubingProduct.manufacturer) + )\ + .filter(MaterialTubingMapping.material_id == material_id)\ + .all() + + result = [] + for mapping in mappings: + product = mapping.tubing_product + mapping_dict = { + "mapping_id": mapping.id, + "confidence_score": float(mapping.confidence_score) if mapping.confidence_score else None, + "mapping_method": mapping.mapping_method, + "mapped_by": mapping.mapped_by, + "mapped_at": mapping.mapped_at, + "required_length_m": float(mapping.required_length_m) if mapping.required_length_m else None, + "calculated_quantity": float(mapping.calculated_quantity) if mapping.calculated_quantity else None, + "is_verified": mapping.is_verified, + "tubing_product": { + "id": product.id, + "manufacturer_part_number": product.manufacturer_part_number, + "manufacturer_product_name": product.manufacturer_product_name, + "spec_name": product.specification.spec_name if product.specification else None, + "manufacturer_name": product.manufacturer.manufacturer_name if product.manufacturer else None, + "list_price": float(product.list_price) if product.list_price else None, + "currency": product.currency, + "availability_status": product.availability_status + } + } + result.append(mapping_dict) + + return { + "success": True, + "material_id": material_id, + "mappings": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"매핑 조회 실패: {str(e)}") + +@router.get("/search") +async def search_tubing_products( + query: str = Query(..., min_length=2), + category: Optional[str] = Query(None), + manufacturer: Optional[str] = Query(None), + min_diameter: Optional[float] = Query(None), + max_diameter: Optional[float] = Query(None), + material_grade: Optional[str] = Query(None), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """통합 Tubing 검색 (규격, 제품, 제조사)""" + try: + # SQL 쿼리로 복합 검색 + sql_query = """ + SELECT DISTINCT + tp.id as product_id, + tp.manufacturer_part_number, + tp.manufacturer_product_name, + tp.list_price, + tp.currency, + tp.availability_status, + ts.spec_code, + ts.spec_name, + ts.outer_diameter_mm, + ts.wall_thickness_mm, + ts.material_grade, + tc.category_name, + tm.manufacturer_name, + tm.country + FROM tubing_products tp + JOIN tubing_specifications ts ON tp.specification_id = ts.id + JOIN tubing_categories tc ON ts.category_id = tc.id + JOIN tubing_manufacturers tm ON tp.manufacturer_id = tm.id + WHERE tp.is_active = true + AND ts.is_active = true + AND tc.is_active = true + AND tm.is_active = true + AND ( + tp.manufacturer_part_number ILIKE :query OR + tp.manufacturer_product_name ILIKE :query OR + ts.spec_name ILIKE :query OR + ts.spec_code ILIKE :query OR + ts.material_grade ILIKE :query OR + tm.manufacturer_name ILIKE :query + ) + """ + + params = {"query": f"%{query}%"} + + # 필터 조건 추가 + if category: + sql_query += " AND tc.category_code = :category" + params["category"] = category + + if manufacturer: + sql_query += " AND tm.manufacturer_code = :manufacturer" + params["manufacturer"] = manufacturer + + if min_diameter: + sql_query += " AND ts.outer_diameter_mm >= :min_diameter" + params["min_diameter"] = min_diameter + + if max_diameter: + sql_query += " AND ts.outer_diameter_mm <= :max_diameter" + params["max_diameter"] = max_diameter + + if material_grade: + sql_query += " AND ts.material_grade ILIKE :material_grade" + params["material_grade"] = f"%{material_grade}%" + + sql_query += " ORDER BY tp.manufacturer_part_number LIMIT :limit" + params["limit"] = limit + + result = db.execute(text(sql_query), params) + products = result.fetchall() + + search_results = [] + for product in products: + product_dict = { + "product_id": product.product_id, + "manufacturer_part_number": product.manufacturer_part_number, + "manufacturer_product_name": product.manufacturer_product_name, + "list_price": float(product.list_price) if product.list_price else None, + "currency": product.currency, + "availability_status": product.availability_status, + "spec_code": product.spec_code, + "spec_name": product.spec_name, + "outer_diameter_mm": float(product.outer_diameter_mm) if product.outer_diameter_mm else None, + "wall_thickness_mm": float(product.wall_thickness_mm) if product.wall_thickness_mm else None, + "material_grade": product.material_grade, + "category_name": product.category_name, + "manufacturer_name": product.manufacturer_name, + "country": product.country + } + search_results.append(product_dict) + + return { + "success": True, + "query": query, + "total_results": len(search_results), + "results": search_results + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"검색 실패: {str(e)}") \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..4f797c1 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,69 @@ +""" +스키마 모듈 +API 요청/응답 모델 정의 +""" +from .response_models import ( + BaseResponse, + ErrorResponse, + SuccessResponse, + FileInfo, + FileListResponse, + FileDeleteResponse, + MaterialInfo, + MaterialListResponse, + JobInfo, + JobListResponse, + ClassificationResult, + ClassificationResponse, + MaterialStatistics, + ProjectStatistics, + StatisticsResponse, + CacheInfo, + SystemHealthResponse, + APIResponse, + # 열거형 + FileStatus, + MaterialCategory, + JobStatus +) + +__all__ = [ + # 기본 응답 모델 + "BaseResponse", + "ErrorResponse", + "SuccessResponse", + + # 파일 관련 + "FileInfo", + "FileListResponse", + "FileDeleteResponse", + + # 자재 관련 + "MaterialInfo", + "MaterialListResponse", + + # 작업 관련 + "JobInfo", + "JobListResponse", + + # 분류 관련 + "ClassificationResult", + "ClassificationResponse", + + # 통계 관련 + "MaterialStatistics", + "ProjectStatistics", + "StatisticsResponse", + + # 시스템 관련 + "CacheInfo", + "SystemHealthResponse", + + # 유니온 타입 + "APIResponse", + + # 열거형 + "FileStatus", + "MaterialCategory", + "JobStatus" +] diff --git a/backend/app/schemas/response_models.py b/backend/app/schemas/response_models.py new file mode 100644 index 0000000..600885b --- /dev/null +++ b/backend/app/schemas/response_models.py @@ -0,0 +1,354 @@ +""" +API 응답 모델 정의 +타입 안정성 및 API 문서화를 위한 Pydantic 모델들 +""" +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional, Dict, Any, Union +from datetime import datetime +from enum import Enum + + +# ================================ +# 기본 응답 모델 +# ================================ + +class BaseResponse(BaseModel): + """기본 응답 모델""" + success: bool = Field(description="요청 성공 여부") + message: Optional[str] = Field(None, description="응답 메시지") + timestamp: datetime = Field(default_factory=datetime.now, description="응답 시간") + + +class ErrorResponse(BaseResponse): + """에러 응답 모델""" + success: bool = Field(False, description="요청 성공 여부") + error: Dict[str, Any] = Field(description="에러 정보") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "success": False, + "message": "요청 처리 중 오류가 발생했습니다", + "error": { + "code": "VALIDATION_ERROR", + "details": "입력 데이터가 올바르지 않습니다" + }, + "timestamp": "2025-01-01T12:00:00" + } + } + ) + + +class SuccessResponse(BaseResponse): + """성공 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: Optional[Any] = Field(None, description="응답 데이터") + + +# ================================ +# 열거형 정의 +# ================================ + +class FileStatus(str, Enum): + """파일 상태""" + ACTIVE = "active" + INACTIVE = "inactive" + PROCESSING = "processing" + ERROR = "error" + + +class MaterialCategory(str, Enum): + """자재 카테고리""" + PIPE = "PIPE" + FITTING = "FITTING" + VALVE = "VALVE" + FLANGE = "FLANGE" + BOLT = "BOLT" + GASKET = "GASKET" + INSTRUMENT = "INSTRUMENT" + EXCLUDE = "EXCLUDE" + + +class JobStatus(str, Enum): + """작업 상태""" + ACTIVE = "active" + COMPLETED = "completed" + ON_HOLD = "on_hold" + CANCELLED = "cancelled" + + +# ================================ +# 파일 관련 모델 +# ================================ + +class FileInfo(BaseModel): + """파일 정보 모델""" + id: int = Field(description="파일 ID") + filename: str = Field(description="파일명") + original_filename: str = Field(description="원본 파일명") + job_no: Optional[str] = Field(None, description="작업 번호") + bom_name: Optional[str] = Field(None, description="BOM 이름") + revision: str = Field(default="Rev.0", description="리비전") + parsed_count: int = Field(default=0, description="파싱된 자재 수") + bom_type: str = Field(default="unknown", description="BOM 타입") + status: FileStatus = Field(description="파일 상태") + file_size: Optional[int] = Field(None, description="파일 크기 (bytes)") + upload_date: datetime = Field(description="업로드 일시") + description: Optional[str] = Field(None, description="파일 설명") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": 1, + "filename": "BOM_Rev1.xlsx", + "original_filename": "BOM_Rev1.xlsx", + "job_no": "TK-2025-001", + "bom_name": "메인 BOM", + "revision": "Rev.1", + "parsed_count": 150, + "bom_type": "excel", + "status": "active", + "file_size": 2048576, + "upload_date": "2025-01-01T12:00:00", + "description": "파일: BOM_Rev1.xlsx" + } + } + ) + + +class FileListResponse(BaseResponse): + """파일 목록 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: List[FileInfo] = Field(description="파일 목록") + total_count: int = Field(description="전체 파일 수") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +class FileDeleteResponse(BaseResponse): + """파일 삭제 응답 모델""" + success: bool = Field(True, description="삭제 성공 여부") + message: str = Field(description="삭제 결과 메시지") + deleted_file_id: int = Field(description="삭제된 파일 ID") + + +# ================================ +# 자재 관련 모델 +# ================================ + +class MaterialInfo(BaseModel): + """자재 정보 모델""" + id: int = Field(description="자재 ID") + file_id: int = Field(description="파일 ID") + line_number: Optional[int] = Field(None, description="엑셀 행 번호") + original_description: str = Field(description="원본 품명") + classified_category: Optional[MaterialCategory] = Field(None, description="분류된 카테고리") + classified_subcategory: Optional[str] = Field(None, description="세부 분류") + material_grade: Optional[str] = Field(None, description="재질 등급") + schedule: Optional[str] = Field(None, description="스케줄") + size_spec: Optional[str] = Field(None, description="사이즈 규격") + quantity: float = Field(description="수량") + unit: str = Field(description="단위") + classification_confidence: Optional[float] = Field(None, description="분류 신뢰도") + is_verified: bool = Field(default=False, description="검증 여부") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": 1, + "file_id": 1, + "line_number": 5, + "original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40", + "classified_category": "PIPE", + "classified_subcategory": "SEAMLESS", + "material_grade": "A333-6", + "schedule": "SCH40", + "size_spec": "6\"", + "quantity": 12.5, + "unit": "EA", + "classification_confidence": 0.95, + "is_verified": False + } + } + ) + + +class MaterialListResponse(BaseResponse): + """자재 목록 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: List[MaterialInfo] = Field(description="자재 목록") + total_count: int = Field(description="전체 자재 수") + file_info: Optional[FileInfo] = Field(None, description="파일 정보") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 작업 관련 모델 +# ================================ + +class JobInfo(BaseModel): + """작업 정보 모델""" + job_no: str = Field(description="작업 번호") + job_name: str = Field(description="작업명") + client_name: Optional[str] = Field(None, description="고객사명") + end_user: Optional[str] = Field(None, description="최종 사용자") + epc_company: Optional[str] = Field(None, description="EPC 회사") + status: JobStatus = Field(description="작업 상태") + created_at: datetime = Field(description="생성 일시") + file_count: int = Field(default=0, description="파일 수") + material_count: int = Field(default=0, description="자재 수") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "job_no": "TK-2025-001", + "job_name": "석유화학 플랜트 배관 프로젝트", + "client_name": "한국석유화학", + "end_user": "울산공장", + "epc_company": "현대엔지니어링", + "status": "active", + "created_at": "2025-01-01T09:00:00", + "file_count": 3, + "material_count": 450 + } + } + ) + + +class JobListResponse(BaseResponse): + """작업 목록 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: List[JobInfo] = Field(description="작업 목록") + total_count: int = Field(description="전체 작업 수") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 분류 관련 모델 +# ================================ + +class ClassificationResult(BaseModel): + """분류 결과 모델""" + category: MaterialCategory = Field(description="분류된 카테고리") + subcategory: Optional[str] = Field(None, description="세부 분류") + confidence: float = Field(description="분류 신뢰도 (0.0-1.0)") + material_grade: Optional[str] = Field(None, description="재질 등급") + size_spec: Optional[str] = Field(None, description="사이즈 규격") + schedule: Optional[str] = Field(None, description="스케줄") + details: Optional[Dict[str, Any]] = Field(None, description="분류 상세 정보") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "category": "PIPE", + "subcategory": "SEAMLESS", + "confidence": 0.95, + "material_grade": "A333-6", + "size_spec": "6\"", + "schedule": "SCH40", + "details": { + "matched_keywords": ["PIPE", "SEAMLESS", "A333-6"], + "size_detected": True, + "material_detected": True + } + } + } + ) + + +class ClassificationResponse(BaseResponse): + """분류 응답 모델""" + success: bool = Field(True, description="분류 성공 여부") + data: ClassificationResult = Field(description="분류 결과") + processing_time: float = Field(description="처리 시간 (초)") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 통계 관련 모델 +# ================================ + +class MaterialStatistics(BaseModel): + """자재 통계 모델""" + category: MaterialCategory = Field(description="자재 카테고리") + count: int = Field(description="개수") + percentage: float = Field(description="비율 (%)") + total_quantity: float = Field(description="총 수량") + unique_items: int = Field(description="고유 항목 수") + + +class ProjectStatistics(BaseModel): + """프로젝트 통계 모델""" + job_no: str = Field(description="작업 번호") + total_materials: int = Field(description="총 자재 수") + total_files: int = Field(description="총 파일 수") + category_breakdown: List[MaterialStatistics] = Field(description="카테고리별 분석") + classification_accuracy: float = Field(description="분류 정확도") + verified_percentage: float = Field(description="검증 완료율") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "job_no": "TK-2025-001", + "total_materials": 450, + "total_files": 3, + "category_breakdown": [ + { + "category": "PIPE", + "count": 180, + "percentage": 40.0, + "total_quantity": 1250.5, + "unique_items": 45 + } + ], + "classification_accuracy": 0.92, + "verified_percentage": 0.75 + } + } + ) + + +class StatisticsResponse(BaseResponse): + """통계 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: ProjectStatistics = Field(description="통계 데이터") + cache_hit: bool = Field(default=False, description="캐시 히트 여부") + + +# ================================ +# 시스템 관련 모델 +# ================================ + +class CacheInfo(BaseModel): + """캐시 정보 모델""" + status: str = Field(description="캐시 상태") + used_memory: str = Field(description="사용 메모리") + connected_clients: int = Field(description="연결된 클라이언트 수") + hit_rate: float = Field(description="캐시 히트율 (%)") + total_commands: int = Field(description="총 명령 수") + + +class SystemHealthResponse(BaseResponse): + """시스템 상태 응답 모델""" + success: bool = Field(True, description="요청 성공 여부") + data: Dict[str, Any] = Field(description="시스템 상태 정보") + cache_info: Optional[CacheInfo] = Field(None, description="캐시 정보") + database_status: str = Field(description="데이터베이스 상태") + api_version: str = Field(description="API 버전") + + +# ================================ +# 유니온 타입 (여러 응답 타입) +# ================================ + +# API 응답으로 사용할 수 있는 모든 타입 +APIResponse = Union[ + SuccessResponse, + ErrorResponse, + FileListResponse, + FileDeleteResponse, + MaterialListResponse, + JobListResponse, + ClassificationResponse, + StatisticsResponse, + SystemHealthResponse +] diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py new file mode 100644 index 0000000..ec2b32f --- /dev/null +++ b/backend/app/services/file_service.py @@ -0,0 +1,333 @@ +""" +파일 관리 비즈니스 로직 +API 레이어에서 분리된 핵심 비즈니스 로직 +""" +from typing import List, Dict, Optional, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import text +from fastapi import HTTPException + +from ..utils.logger import get_logger +from ..utils.cache_manager import tkmp_cache +from ..utils.transaction_manager import TransactionManager, async_transactional +from ..schemas.response_models import FileInfo +from ..config import get_settings + +logger = get_logger(__name__) +settings = get_settings() + + +class FileService: + """파일 관리 서비스""" + + def __init__(self, db: Session): + self.db = db + self.transaction_manager = TransactionManager(db) + + async def get_files( + self, + job_no: Optional[str] = None, + show_history: bool = False, + use_cache: bool = True + ) -> Tuple[List[Dict], bool]: + """ + 파일 목록 조회 + + Args: + job_no: 작업 번호 + show_history: 이력 표시 여부 + use_cache: 캐시 사용 여부 + + Returns: + Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부) + """ + try: + logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}") + + # 캐시 확인 + if use_cache: + cached_files = tkmp_cache.get_file_list(job_no, show_history) + if cached_files: + logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일") + return cached_files, True + + # 데이터베이스에서 조회 + query, params = self._build_file_query(job_no, show_history) + result = self.db.execute(text(query), params) + files = result.fetchall() + + # 결과 변환 + file_list = self._convert_files_to_dict(files) + + # 캐시에 저장 + if use_cache: + tkmp_cache.set_file_list(file_list, job_no, show_history) + logger.debug("파일 목록 캐시 저장 완료") + + logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환") + return file_list, False + + except Exception as e: + logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") + + def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]: + """파일 조회 쿼리 생성""" + if show_history: + # 전체 이력 표시 + query = "SELECT * FROM files" + params = {} + + if job_no: + query += " WHERE job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY original_filename, revision DESC" + else: + # 최신 리비전만 표시 + if job_no: + query = """ + SELECT f1.* FROM files f1 + INNER JOIN ( + SELECT original_filename, MAX(revision) as max_revision + FROM files + WHERE job_no = :job_no + GROUP BY original_filename + ) f2 ON f1.original_filename = f2.original_filename + AND f1.revision = f2.max_revision + WHERE f1.job_no = :job_no + ORDER BY f1.upload_date DESC + """ + params = {"job_no": job_no} + else: + query = "SELECT * FROM files ORDER BY upload_date DESC" + params = {} + + return query, params + + def _convert_files_to_dict(self, files) -> List[Dict]: + """파일 결과를 딕셔너리로 변환""" + return [ + { + "id": f.id, + "filename": f.original_filename, + "original_filename": f.original_filename, + "name": f.original_filename, + "job_no": f.job_no, + "bom_name": f.bom_name or f.original_filename, + "revision": f.revision or "Rev.0", + "parsed_count": f.parsed_count or 0, + "bom_type": f.file_type or "unknown", + "status": "active" if f.is_active else "inactive", + "file_size": f.file_size, + "created_at": f.upload_date, + "upload_date": f.upload_date, + "description": f"파일: {f.original_filename}" + } + for f in files + ] + + async def delete_file(self, file_id: int) -> Dict: + """ + 파일 삭제 (트랜잭션 관리 적용) + + Args: + file_id: 파일 ID + + Returns: + Dict: 삭제 결과 + """ + try: + logger.info(f"파일 삭제 요청 - file_id: {file_id}") + + # 트랜잭션 내에서 삭제 작업 수행 + with self.transaction_manager.transaction(): + # 파일 정보 조회 + file_info = self._get_file_info(file_id) + if not file_info: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + + # 관련 데이터 삭제 (세이브포인트 사용) + with self.transaction_manager.savepoint("delete_related_data"): + self._delete_related_data(file_id) + + # 파일 삭제 + with self.transaction_manager.savepoint("delete_file_record"): + self._delete_file_record(file_id) + + # 트랜잭션이 성공적으로 완료되면 캐시 무효화 + self._invalidate_file_cache(file_id, file_info) + + logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}") + + return { + "success": True, + "message": "파일과 관련 데이터가 삭제되었습니다", + "deleted_file_id": file_id + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}") + + def _get_file_info(self, file_id: int): + """파일 정보 조회""" + file_query = text("SELECT * FROM files WHERE id = :file_id") + file_result = self.db.execute(file_query, {"file_id": file_id}) + return file_result.fetchone() + + def _delete_related_data(self, file_id: int): + """관련 데이터 삭제""" + # 상세 테이블 목록 + detail_tables = [ + 'pipe_details', 'fitting_details', 'valve_details', + 'flange_details', 'bolt_details', 'gasket_details', + 'instrument_details' + ] + + # 해당 파일의 materials ID 조회 + material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id") + material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id}) + material_ids = [row[0] for row in material_ids_result] + + if material_ids: + logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재") + # 각 상세 테이블에서 관련 데이터 삭제 + for table in detail_tables: + delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)") + self.db.execute(delete_detail_query, {"material_ids": material_ids}) + + # materials 테이블 데이터 삭제 + materials_query = text("DELETE FROM materials WHERE file_id = :file_id") + self.db.execute(materials_query, {"file_id": file_id}) + + def _delete_file_record(self, file_id: int): + """파일 레코드 삭제""" + delete_query = text("DELETE FROM files WHERE id = :file_id") + self.db.execute(delete_query, {"file_id": file_id}) + + def _invalidate_file_cache(self, file_id: int, file_info): + """파일 관련 캐시 무효화""" + tkmp_cache.invalidate_file_cache(file_id) + if hasattr(file_info, 'job_no') and file_info.job_no: + tkmp_cache.invalidate_job_cache(file_info.job_no) + + async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict: + """ + 파일 통계 조회 + + Args: + job_no: 작업 번호 + + Returns: + Dict: 파일 통계 + """ + try: + # 캐시 확인 + if job_no: + cached_stats = tkmp_cache.get_statistics(job_no, "file_stats") + if cached_stats: + return cached_stats + + # 통계 쿼리 실행 + stats_query = self._build_statistics_query(job_no) + result = self.db.execute(text(stats_query["query"]), stats_query["params"]) + stats_data = result.fetchall() + + # 통계 데이터 변환 + statistics = self._convert_statistics_data(stats_data) + + # 캐시에 저장 + if job_no: + tkmp_cache.set_statistics(statistics, job_no, "file_stats") + + return statistics + + except Exception as e: + logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}") + + def _build_statistics_query(self, job_no: Optional[str]) -> Dict: + """통계 쿼리 생성""" + base_query = """ + SELECT + COUNT(*) as total_files, + COUNT(DISTINCT job_no) as total_jobs, + SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files, + SUM(file_size) as total_size, + AVG(file_size) as avg_size, + MAX(upload_date) as latest_upload, + MIN(upload_date) as earliest_upload + FROM files + """ + + params = {} + if job_no: + base_query += " WHERE job_no = :job_no" + params["job_no"] = job_no + + return {"query": base_query, "params": params} + + def _convert_statistics_data(self, stats_data) -> Dict: + """통계 데이터 변환""" + if not stats_data: + return { + "total_files": 0, + "total_jobs": 0, + "active_files": 0, + "total_size": 0, + "avg_size": 0, + "latest_upload": None, + "earliest_upload": None + } + + stats = stats_data[0] + return { + "total_files": stats.total_files or 0, + "total_jobs": stats.total_jobs or 0, + "active_files": stats.active_files or 0, + "total_size": stats.total_size or 0, + "total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2), + "avg_size": stats.avg_size or 0, + "avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2), + "latest_upload": stats.latest_upload, + "earliest_upload": stats.earliest_upload + } + + async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool: + """ + 파일 접근 권한 검증 + + Args: + file_id: 파일 ID + user_id: 사용자 ID + + Returns: + bool: 접근 권한 여부 + """ + try: + # 파일 존재 여부 확인 + file_info = self._get_file_info(file_id) + if not file_info: + return False + + # 파일이 활성 상태인지 확인 + if not file_info.is_active: + logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}") + return False + + # 추가 권한 검증 로직 (필요시 구현) + # 예: 사용자별 프로젝트 접근 권한 등 + + return True + + except Exception as e: + logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True) + return False + + +def get_file_service(db: Session) -> FileService: + """파일 서비스 팩토리 함수""" + return FileService(db) diff --git a/backend/app/services/purchase_calculator.py b/backend/app/services/purchase_calculator.py index 0409669..e69ee6f 100644 --- a/backend/app/services/purchase_calculator.py +++ b/backend/app/services/purchase_calculator.py @@ -10,26 +10,26 @@ from typing import Dict, List, Tuple from sqlalchemy.orm import Session from sqlalchemy import text -# 자재별 기본 여유율 +# 자재별 기본 여유율 (올바른 규칙으로 수정) SAFETY_FACTORS = { - 'PIPE': 1.15, # 15% 추가 (절단 손실) - 'FITTING': 1.10, # 10% 추가 (연결 오차) - 'VALVE': 1.50, # 50% 추가 (예비품) - 'FLANGE': 1.10, # 10% 추가 - 'BOLT': 1.20, # 20% 추가 (분실율) - 'GASKET': 1.25, # 25% 추가 (교체주기) - 'INSTRUMENT': 1.00, # 0% 추가 (정확한 수량) - 'DEFAULT': 1.10 # 기본 10% 추가 + 'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산) + 'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로) + 'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로) + 'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로) + 'BOLT': 1.05, # 5% 추가 (분실율) + 'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리) + 'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로) + 'DEFAULT': 1.00 # 기본 0% 추가 } -# 최소 주문 수량 (자재별) +# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정 MINIMUM_ORDER_QTY = { 'PIPE': 6000, # 6M 단위 'FITTING': 1, # 개별 주문 가능 'VALVE': 1, # 개별 주문 가능 'FLANGE': 1, # 개별 주문 가능 - 'BOLT': 50, # 박스 단위 (50개) - 'GASKET': 10, # 세트 단위 + 'BOLT': 4, # 4의 배수 단위 + 'GASKET': 5, # 5의 배수 단위 'INSTRUMENT': 1, # 개별 주문 가능 'DEFAULT': 1 } @@ -37,7 +37,7 @@ MINIMUM_ORDER_QTY = { def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict: """ PIPE 구매 수량 계산 - - 각 절단마다 3mm 손실 + - 각 절단마다 2mm 손실 (올바른 규칙) - 6,000mm (6M) 단위로 올림 """ total_bom_length = 0 @@ -45,19 +45,23 @@ def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict: pipe_details = [] for material in materials: - # 길이 정보 추출 + # 길이 정보 추출 (Decimal 타입 처리) length_mm = float(material.get('length_mm', 0) or 0) + quantity = float(material.get('quantity', 1) or 1) + if length_mm > 0: - total_bom_length += length_mm - cutting_count += 1 + total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량 + total_bom_length += total_length + cutting_count += quantity # 절단 횟수 = 수량 pipe_details.append({ 'description': material.get('original_description', ''), 'length_mm': length_mm, - 'quantity': material.get('quantity', 1) + 'quantity': quantity, + 'total_length': total_length }) - # 절단 손실 계산 (각 절단마다 3mm) - cutting_loss = cutting_count * 3 + # 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙) + cutting_loss = cutting_count * 2 # 총 필요 길이 = BOM 길이 + 절단 손실 required_length = total_bom_length + cutting_loss @@ -92,7 +96,8 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float, if safety_factor is None: safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT']) - # 1단계: 여유율 적용 + # 1단계: 여유율 적용 (Decimal 타입 처리) + bom_quantity = float(bom_quantity) if bom_quantity else 0.0 safety_qty = bom_quantity * safety_factor # 2단계: 최소 주문 수량 확인 @@ -101,9 +106,13 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float, # 3단계: 최소 주문 수량과 비교하여 큰 값 선택 calculated_qty = max(safety_qty, min_order_qty) - # 4단계: 특별 처리 (BOLT는 박스 단위로 올림) - if category == 'BOLT' and calculated_qty > min_order_qty: - calculated_qty = math.ceil(calculated_qty / min_order_qty) * min_order_qty + # 4단계: 특별 처리 (올바른 규칙 적용) + if category == 'BOLT': + # BOLT: 5% 여유율 후 4의 배수로 올림 + calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty + elif category == 'GASKET': + # GASKET: 5의 배수로 올림 (여유율 없음) + calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty return { 'bom_quantity': bom_quantity, @@ -120,16 +129,19 @@ def generate_purchase_items_from_materials(db: Session, file_id: int, """ 자재 데이터로부터 구매 품목 생성 """ - # 1. 파일의 모든 자재 조회 + # 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함) materials_query = text(""" SELECT m.*, pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec, - fd.fitting_type, fd.connection_method as fitting_connection, + fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size, + fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade, vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure, - fl.flange_type, fl.pressure_rating as flange_pressure, - gd.gasket_type, gd.material_type as gasket_material, - bd.bolt_type, bd.material_standard, bd.diameter, - id.instrument_type + vd.size_inches as valve_size, + fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size, + gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material, + gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness, + bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length, + id.instrument_type, id.connection_size as instrument_size FROM materials m LEFT JOIN pipe_details pd ON m.id = pd.material_id LEFT JOIN fitting_details fd ON m.id = fd.material_id @@ -144,13 +156,65 @@ def generate_purchase_items_from_materials(db: Session, file_id: int, materials = db.execute(materials_query, {"file_id": file_id}).fetchall() + + # 2. 카테고리별로 그룹핑 grouped_materials = {} for material in materials: category = material.classified_category or 'OTHER' if category not in grouped_materials: grouped_materials[category] = [] - grouped_materials[category].append(dict(material)) + + # Row 객체를 딕셔너리로 안전하게 변환 + material_dict = { + 'id': material.id, + 'file_id': material.file_id, + 'original_description': material.original_description, + 'quantity': material.quantity, + 'unit': material.unit, + 'size_spec': material.size_spec, + 'material_grade': material.material_grade, + 'classified_category': material.classified_category, + 'line_number': material.line_number, + # PIPE 상세 정보 + 'length_mm': getattr(material, 'length_mm', None), + 'outer_diameter': getattr(material, 'outer_diameter', None), + 'schedule': getattr(material, 'schedule', None), + 'pipe_material_spec': getattr(material, 'pipe_material_spec', None), + # FITTING 상세 정보 + 'fitting_type': getattr(material, 'fitting_type', None), + 'fitting_connection': getattr(material, 'fitting_connection', None), + 'fitting_main_size': getattr(material, 'fitting_main_size', None), + 'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None), + 'fitting_material_grade': getattr(material, 'fitting_material_grade', None), + # VALVE 상세 정보 + 'valve_type': getattr(material, 'valve_type', None), + 'valve_connection': getattr(material, 'valve_connection', None), + 'valve_pressure': getattr(material, 'valve_pressure', None), + 'valve_size': getattr(material, 'valve_size', None), + # FLANGE 상세 정보 + 'flange_type': getattr(material, 'flange_type', None), + 'flange_pressure': getattr(material, 'flange_pressure', None), + 'flange_size': getattr(material, 'flange_size', None), + # GASKET 상세 정보 + 'gasket_type': getattr(material, 'gasket_type', None), + 'gasket_subtype': getattr(material, 'gasket_subtype', None), + 'gasket_material': getattr(material, 'gasket_material', None), + 'filler_material': getattr(material, 'filler_material', None), + 'gasket_size': getattr(material, 'gasket_size', None), + 'gasket_pressure': getattr(material, 'gasket_pressure', None), + 'gasket_thickness': getattr(material, 'gasket_thickness', None), + # BOLT 상세 정보 + 'bolt_type': getattr(material, 'bolt_type', None), + 'material_standard': getattr(material, 'material_standard', None), + 'bolt_diameter': getattr(material, 'bolt_diameter', None), + 'bolt_length': getattr(material, 'bolt_length', None), + # INSTRUMENT 상세 정보 + 'instrument_type': getattr(material, 'instrument_type', None), + 'instrument_size': getattr(material, 'instrument_size', None) + } + + grouped_materials[category].append(material_dict) # 3. 각 카테고리별로 구매 품목 생성 purchase_items = [] @@ -249,13 +313,41 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) - if category == 'FITTING': fitting_type = material.get('fitting_type', 'FITTING') connection_method = material.get('fitting_connection', '') - material_spec = material.get('material_grade', '') - main_nom = material.get('main_nom', '') - red_nom = material.get('red_nom', '') - size_display = f"{main_nom} x {red_nom}" if red_nom else main_nom + # 상세 테이블의 재질 정보 우선 사용 + material_spec = material.get('fitting_material_grade') or material.get('material_grade', '') + # 상세 테이블의 사이즈 정보 사용 + main_size = material.get('fitting_main_size', '') + reduced_size = material.get('fitting_reduced_size', '') + + # 사이즈 표시 생성 (축소형인 경우 main x reduced 형태) + if main_size and reduced_size and main_size != reduced_size: + size_display = f"{main_size} x {reduced_size}" + else: + size_display = main_size or material.get('size_spec', '') + + # 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급 + # 예: "ELBOW, SOCKET WELD, 3000LB" + fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING' + + spec_parts = [fitting_display] + + # 연결방식 추가 + if connection_method and connection_method != 'UNKNOWN': + connection_display = connection_method.replace('_', ' ') + spec_parts.append(connection_display) + + # 압력등급 추출 (description에서) + description = material.get('original_description', '').upper() + import re + pressure_match = re.search(r'(\d+)LB', description) + if pressure_match: + spec_parts.append(f"{pressure_match.group(1)}LB") + + # 스케줄 정보 추출 (니플 등에 중요) + schedule_match = re.search(r'SCH\s*(\d+)', description) + if schedule_match: + spec_parts.append(f"SCH {schedule_match.group(1)}") - spec_parts = [fitting_type] - if connection_method: spec_parts.append(connection_method) full_spec = ', '.join(spec_parts) spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}" @@ -272,19 +364,20 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) - connection_method = material.get('valve_connection', '') pressure_rating = material.get('valve_pressure', '') material_spec = material.get('material_grade', '') - main_nom = material.get('main_nom', '') + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('valve_size') or material.get('size_spec', '') spec_parts = [valve_type.replace('_', ' ')] if connection_method: spec_parts.append(connection_method.replace('_', ' ')) if pressure_rating: spec_parts.append(pressure_rating) full_spec = ', '.join(spec_parts) - spec_key = f"VALVE|{full_spec}|{material_spec}|{main_nom}" + spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}" spec_data = { - 'category': 'VALVE', + 'category': 'VALVE', 'full_spec': full_spec, 'material_spec': material_spec, - 'size_display': main_nom, + 'size_display': size_display, 'unit': 'EA' } @@ -292,102 +385,198 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) - flange_type = material.get('flange_type', 'FLANGE') pressure_rating = material.get('flange_pressure', '') material_spec = material.get('material_grade', '') - main_nom = material.get('main_nom', '') + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('flange_size') or material.get('size_spec', '') - spec_parts = [flange_type] + spec_parts = [flange_type.replace('_', ' ')] if pressure_rating: spec_parts.append(pressure_rating) full_spec = ', '.join(spec_parts) - spec_key = f"FLANGE|{full_spec}|{material_spec}|{main_nom}" + spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}" spec_data = { 'category': 'FLANGE', 'full_spec': full_spec, 'material_spec': material_spec, - 'size_display': main_nom, + 'size_display': size_display, 'unit': 'EA' } elif category == 'BOLT': bolt_type = material.get('bolt_type', 'BOLT') material_standard = material.get('material_standard', '') - diameter = material.get('diameter', material.get('main_nom', '')) + # 상세 테이블의 사이즈 정보 우선 사용 + diameter = material.get('bolt_diameter') or material.get('size_spec', '') + length = material.get('bolt_length', '') material_spec = material_standard or material.get('material_grade', '') - # 분수 사이즈 정보 추출 (새로 추가된 분류기 정보) - size_fraction = material.get('size_fraction', diameter) - surface_treatment = material.get('surface_treatment', '') + # 기존 분류기 방식에 따른 사이즈 표시 (분수 형태) + # 소수점을 분수로 변환 (예: 0.625 -> 5/8) + size_display = diameter + if diameter and '.' in diameter: + try: + decimal_val = float(diameter) + # 일반적인 볼트 사이즈 분수 변환 + fraction_map = { + 0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"", + 0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"", + 0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"", + 0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\"" + } + if decimal_val in fraction_map: + size_display = fraction_map[decimal_val] + except: + pass - # 특수 용도 정보 추출 (PSV, LT, CK) - special_applications = { - 'PSV': 0, - 'LT': 0, - 'CK': 0 - } - - # 설명에서 특수 용도 키워드 확인 (간단한 방법) - description = material.get('original_description', '').upper() - if 'PSV' in description or 'PRESSURE SAFETY VALVE' in description: - special_applications['PSV'] = material.get('quantity', 0) - if any(keyword in description for keyword in ['LT', 'LOW TEMP', '저온용']): - special_applications['LT'] = material.get('quantity', 0) - if 'CK' in description or 'CHECK VALVE' in description: - special_applications['CK'] = material.get('quantity', 0) + # 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L) + if length: + # 길이에서 숫자만 추출 + import re + length_match = re.search(r'(\d+(?:\.\d+)?)', str(length)) + if length_match: + length_num = length_match.group(1) + size_display_with_length = f"{size_display} x {length_num}L" + else: + size_display_with_length = f"{size_display} x {length}" + else: + size_display_with_length = size_display spec_parts = [bolt_type.replace('_', ' ')] if material_standard: spec_parts.append(material_standard) full_spec = ', '.join(spec_parts) - # 특수 용도와 관계없이 사이즈+길이로 합산 (구매는 동일하므로) - # 길이 정보가 있으면 포함 - length_info = material.get('length', '') - if length_info: - diameter_key = f"{diameter}L{length_info}" - else: - diameter_key = diameter - - spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter_key}" + # 사이즈+길이로 그룹핑 + spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}" spec_data = { 'category': 'BOLT', 'full_spec': full_spec, 'material_spec': material_spec, - 'size_display': diameter, - 'size_fraction': size_fraction, - 'surface_treatment': surface_treatment, + 'size_display': size_display_with_length, 'unit': 'EA' } elif category == 'GASKET': + # 상세 테이블 정보 우선 사용 gasket_type = material.get('gasket_type', 'GASKET') + gasket_subtype = material.get('gasket_subtype', '') gasket_material = material.get('gasket_material', '') - material_spec = gasket_material or material.get('material_grade', '') - main_nom = material.get('main_nom', '') + filler_material = material.get('filler_material', '') + gasket_pressure = material.get('gasket_pressure', '') + gasket_thickness = material.get('gasket_thickness', '') + + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('gasket_size') or material.get('size_spec', '') + + # 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질 + # 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE" + spec_parts = [gasket_type.replace('_', ' ')] + + # 서브타입 추가 (있는 경우) + if gasket_subtype and gasket_subtype != gasket_type: + spec_parts.append(gasket_subtype.replace('_', ' ')) + + # 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출 + if gasket_pressure: + spec_parts.append(gasket_pressure) + else: + description = material.get('original_description', '').upper() + import re + pressure_match = re.search(r'(\d+)LB', description) + if pressure_match: + spec_parts.append(f"{pressure_match.group(1)}LB") + + # 재질 정보 구성 (상세 테이블 정보 활용) + material_spec_parts = [] + + # SWG의 경우 메탈 + 필러 형태로 구성 + if gasket_type == 'SPIRAL_WOUND': + # 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱 + description = material.get('original_description', '').upper() + + # SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보) + import re + material_spec = None + + # H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시) + hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description) + if hfio_material_match: + part1 = hfio_material_match.group(1) # SS304 + part2 = hfio_material_match.group(2) # GRAPHITE + part3 = hfio_material_match.group(3) # CS + part4 = hfio_material_match.group(4) # CS + material_spec = f"{part1}/{part2}/{part3}/{part4}" + else: + # 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시) + simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description) + if simple_material_match: + part1 = simple_material_match.group(1) # SS304 + part2 = simple_material_match.group(2) # GRAPHITE + part3 = simple_material_match.group(3) # CS + part4 = simple_material_match.group(4) # CS + material_spec = f"{part1}/{part2}/{part3}/{part4}" + + if not material_spec: + # 상세 테이블 정보 사용 + if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분 + material_spec_parts.append(gasket_material) + elif gasket_material == 'GRAPHITE': + # GRAPHITE만 있는 경우 description에서 메탈 부분 찾기 + metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description) + if metal_match: + material_spec_parts.append(metal_match.group(1)) + + if filler_material and filler_material != gasket_material: # 필러 부분 + material_spec_parts.append(filler_material) + elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts: + material_spec_parts.append('GRAPHITE') + + if material_spec_parts: + material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE + else: + material_spec = material.get('material_grade', '') + else: + # 일반 가스켓의 경우 + if gasket_material: + material_spec_parts.append(gasket_material) + if filler_material and filler_material != gasket_material: + material_spec_parts.append(filler_material) + + if material_spec_parts: + material_spec = ', '.join(material_spec_parts) + else: + material_spec = material.get('material_grade', '') + + if material_spec: + spec_parts.append(material_spec) + + # 두께 정보 추가 (있는 경우) + if gasket_thickness: + spec_parts.append(f"THK {gasket_thickness}") - spec_parts = [gasket_type] - if gasket_material: spec_parts.append(gasket_material) full_spec = ', '.join(spec_parts) - spec_key = f"GASKET|{full_spec}|{material_spec}|{main_nom}" + spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}" spec_data = { 'category': 'GASKET', 'full_spec': full_spec, 'material_spec': material_spec, - 'size_display': main_nom, + 'size_display': size_display, 'unit': 'EA' } elif category == 'INSTRUMENT': instrument_type = material.get('instrument_type', 'INSTRUMENT') material_spec = material.get('material_grade', '') - main_nom = material.get('main_nom', '') + # 상세 테이블의 사이즈 정보 우선 사용 + size_display = material.get('instrument_size') or material.get('size_spec', '') full_spec = instrument_type.replace('_', ' ') - spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{main_nom}" + spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}" spec_data = { 'category': 'INSTRUMENT', 'full_spec': full_spec, 'material_spec': material_spec, - 'size_display': main_nom, + 'size_display': size_display, 'unit': 'EA' } diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..691c31b --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1,12 @@ +""" +유틸리티 모듈 +""" +from .logger import get_logger, setup_logger, app_logger +from .file_validator import file_validator, validate_uploaded_file +from .error_handlers import ErrorResponse, TKMPException, setup_error_handlers + +__all__ = [ + "get_logger", "setup_logger", "app_logger", + "file_validator", "validate_uploaded_file", + "ErrorResponse", "TKMPException", "setup_error_handlers" +] diff --git a/backend/app/utils/cache_manager.py b/backend/app/utils/cache_manager.py new file mode 100644 index 0000000..43ae1a2 --- /dev/null +++ b/backend/app/utils/cache_manager.py @@ -0,0 +1,266 @@ +""" +Redis 캐시 관리 유틸리티 +성능 향상을 위한 캐싱 전략 구현 +""" +import json +import redis +from typing import Any, Optional, Dict, List +from datetime import timedelta +import hashlib +import pickle + +from ..config import get_settings +from .logger import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +class CacheManager: + """Redis 캐시 관리 클래스""" + + def __init__(self): + try: + # Redis 연결 설정 + self.redis_client = redis.from_url( + settings.redis.url, + decode_responses=False, # 바이너리 데이터 지원 + socket_connect_timeout=5, + socket_timeout=5, + retry_on_timeout=True + ) + + # 연결 테스트 + self.redis_client.ping() + logger.info("Redis 연결 성공") + + except Exception as e: + logger.error(f"Redis 연결 실패: {e}") + self.redis_client = None + + def _generate_key(self, prefix: str, *args, **kwargs) -> str: + """캐시 키 생성""" + # 인자들을 문자열로 변환하여 해시 생성 + key_parts = [str(arg) for arg in args] + key_parts.extend([f"{k}:{v}" for k, v in sorted(kwargs.items())]) + + if key_parts: + key_hash = hashlib.md5("|".join(key_parts).encode()).hexdigest()[:8] + return f"tkmp:{prefix}:{key_hash}" + else: + return f"tkmp:{prefix}" + + def get(self, key: str) -> Optional[Any]: + """캐시에서 데이터 조회""" + if not self.redis_client: + return None + + try: + data = self.redis_client.get(key) + if data: + return pickle.loads(data) + return None + except Exception as e: + logger.warning(f"캐시 조회 실패 - key: {key}, error: {e}") + return None + + def set(self, key: str, value: Any, expire: int = 3600) -> bool: + """캐시에 데이터 저장""" + if not self.redis_client: + return False + + try: + serialized_data = pickle.dumps(value) + result = self.redis_client.setex(key, expire, serialized_data) + logger.debug(f"캐시 저장 - key: {key}, expire: {expire}s") + return result + except Exception as e: + logger.warning(f"캐시 저장 실패 - key: {key}, error: {e}") + return False + + def delete(self, key: str) -> bool: + """캐시에서 데이터 삭제""" + if not self.redis_client: + return False + + try: + result = self.redis_client.delete(key) + logger.debug(f"캐시 삭제 - key: {key}") + return bool(result) + except Exception as e: + logger.warning(f"캐시 삭제 실패 - key: {key}, error: {e}") + return False + + def delete_pattern(self, pattern: str) -> int: + """패턴에 맞는 캐시 키들 삭제""" + if not self.redis_client: + return 0 + + try: + keys = self.redis_client.keys(pattern) + if keys: + deleted = self.redis_client.delete(*keys) + logger.info(f"패턴 캐시 삭제 - pattern: {pattern}, deleted: {deleted}") + return deleted + return 0 + except Exception as e: + logger.warning(f"패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}") + return 0 + + def exists(self, key: str) -> bool: + """캐시 키 존재 여부 확인""" + if not self.redis_client: + return False + + try: + return bool(self.redis_client.exists(key)) + except Exception as e: + logger.warning(f"캐시 존재 확인 실패 - key: {key}, error: {e}") + return False + + def get_ttl(self, key: str) -> int: + """캐시 TTL 조회""" + if not self.redis_client: + return -1 + + try: + return self.redis_client.ttl(key) + except Exception as e: + logger.warning(f"캐시 TTL 조회 실패 - key: {key}, error: {e}") + return -1 + + +class TKMPCache: + """TK-MP 프로젝트 전용 캐시 래퍼""" + + def __init__(self): + self.cache = CacheManager() + + # 캐시 TTL 설정 (초 단위) + self.ttl_config = { + "file_list": 300, # 5분 - 파일 목록 + "material_list": 600, # 10분 - 자재 목록 + "job_list": 1800, # 30분 - 작업 목록 + "classification": 3600, # 1시간 - 분류 결과 + "statistics": 900, # 15분 - 통계 데이터 + "comparison": 1800, # 30분 - 리비전 비교 + } + + def get_file_list(self, job_no: Optional[str] = None, show_history: bool = False) -> Optional[List[Dict]]: + """파일 목록 캐시 조회""" + key = self.cache._generate_key("files", job_no=job_no, history=show_history) + return self.cache.get(key) + + def set_file_list(self, files: List[Dict], job_no: Optional[str] = None, show_history: bool = False) -> bool: + """파일 목록 캐시 저장""" + key = self.cache._generate_key("files", job_no=job_no, history=show_history) + return self.cache.set(key, files, self.ttl_config["file_list"]) + + def get_material_list(self, file_id: int) -> Optional[List[Dict]]: + """자재 목록 캐시 조회""" + key = self.cache._generate_key("materials", file_id=file_id) + return self.cache.get(key) + + def set_material_list(self, materials: List[Dict], file_id: int) -> bool: + """자재 목록 캐시 저장""" + key = self.cache._generate_key("materials", file_id=file_id) + return self.cache.set(key, materials, self.ttl_config["material_list"]) + + def get_job_list(self) -> Optional[List[Dict]]: + """작업 목록 캐시 조회""" + key = self.cache._generate_key("jobs") + return self.cache.get(key) + + def set_job_list(self, jobs: List[Dict]) -> bool: + """작업 목록 캐시 저장""" + key = self.cache._generate_key("jobs") + return self.cache.set(key, jobs, self.ttl_config["job_list"]) + + def get_classification_result(self, description: str, category: str) -> Optional[Dict]: + """분류 결과 캐시 조회""" + key = self.cache._generate_key("classification", desc=description, cat=category) + return self.cache.get(key) + + def set_classification_result(self, result: Dict, description: str, category: str) -> bool: + """분류 결과 캐시 저장""" + key = self.cache._generate_key("classification", desc=description, cat=category) + return self.cache.set(key, result, self.ttl_config["classification"]) + + def get_statistics(self, job_no: str, stat_type: str) -> Optional[Dict]: + """통계 데이터 캐시 조회""" + key = self.cache._generate_key("stats", job_no=job_no, type=stat_type) + return self.cache.get(key) + + def set_statistics(self, stats: Dict, job_no: str, stat_type: str) -> bool: + """통계 데이터 캐시 저장""" + key = self.cache._generate_key("stats", job_no=job_no, type=stat_type) + return self.cache.set(key, stats, self.ttl_config["statistics"]) + + def get_revision_comparison(self, job_no: str, rev1: str, rev2: str) -> Optional[Dict]: + """리비전 비교 결과 캐시 조회""" + key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2) + return self.cache.get(key) + + def set_revision_comparison(self, comparison: Dict, job_no: str, rev1: str, rev2: str) -> bool: + """리비전 비교 결과 캐시 저장""" + key = self.cache._generate_key("comparison", job_no=job_no, rev1=rev1, rev2=rev2) + return self.cache.set(key, comparison, self.ttl_config["comparison"]) + + def invalidate_job_cache(self, job_no: str): + """특정 작업의 모든 캐시 무효화""" + patterns = [ + f"tkmp:files:*job_no:{job_no}*", + f"tkmp:materials:*job_no:{job_no}*", + f"tkmp:stats:*job_no:{job_no}*", + f"tkmp:comparison:*job_no:{job_no}*" + ] + + total_deleted = 0 + for pattern in patterns: + deleted = self.cache.delete_pattern(pattern) + total_deleted += deleted + + logger.info(f"작업 캐시 무효화 완료 - job_no: {job_no}, deleted: {total_deleted}") + return total_deleted + + def invalidate_file_cache(self, file_id: int): + """특정 파일의 모든 캐시 무효화""" + patterns = [ + f"tkmp:materials:*file_id:{file_id}*", + f"tkmp:files:*" # 파일 목록도 갱신 필요 + ] + + total_deleted = 0 + for pattern in patterns: + deleted = self.cache.delete_pattern(pattern) + total_deleted += deleted + + logger.info(f"파일 캐시 무효화 완료 - file_id: {file_id}, deleted: {total_deleted}") + return total_deleted + + def get_cache_info(self) -> Dict[str, Any]: + """캐시 상태 정보 조회""" + if not self.cache.redis_client: + return {"status": "disconnected"} + + try: + info = self.cache.redis_client.info() + return { + "status": "connected", + "used_memory": info.get("used_memory_human", "N/A"), + "connected_clients": info.get("connected_clients", 0), + "total_commands_processed": info.get("total_commands_processed", 0), + "keyspace_hits": info.get("keyspace_hits", 0), + "keyspace_misses": info.get("keyspace_misses", 0), + "hit_rate": round( + info.get("keyspace_hits", 0) / + max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100, 2 + ) + } + except Exception as e: + logger.error(f"캐시 정보 조회 실패: {e}") + return {"status": "error", "error": str(e)} + + +# 전역 캐시 인스턴스 +tkmp_cache = TKMPCache() diff --git a/backend/app/utils/error_handlers.py b/backend/app/utils/error_handlers.py new file mode 100644 index 0000000..090ca60 --- /dev/null +++ b/backend/app/utils/error_handlers.py @@ -0,0 +1,139 @@ +""" +에러 처리 유틸리티 +표준화된 에러 응답 및 예외 처리 +""" +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from sqlalchemy.exc import SQLAlchemyError +from typing import Dict, Any +import traceback + +from .logger import get_logger + +logger = get_logger(__name__) + + +class TKMPException(Exception): + """TK-MP 프로젝트 커스텀 예외""" + + def __init__(self, message: str, error_code: str = "TKMP_ERROR", status_code: int = 500): + self.message = message + self.error_code = error_code + self.status_code = status_code + super().__init__(self.message) + + +class ErrorResponse: + """표준화된 에러 응답 생성기""" + + @staticmethod + def create_error_response( + message: str, + error_code: str = "INTERNAL_ERROR", + status_code: int = 500, + details: Dict[str, Any] = None + ) -> Dict[str, Any]: + """표준화된 에러 응답 생성""" + response = { + "success": False, + "error": { + "code": error_code, + "message": message, + "timestamp": "2025-01-01T00:00:00Z" # 실제로는 datetime.utcnow().isoformat() + } + } + + if details: + response["error"]["details"] = details + + return response + + @staticmethod + def validation_error_response(errors: list) -> Dict[str, Any]: + """검증 에러 응답""" + return ErrorResponse.create_error_response( + message="입력 데이터 검증에 실패했습니다.", + error_code="VALIDATION_ERROR", + status_code=422, + details={"validation_errors": errors} + ) + + @staticmethod + def database_error_response(error: str) -> Dict[str, Any]: + """데이터베이스 에러 응답""" + return ErrorResponse.create_error_response( + message="데이터베이스 작업 중 오류가 발생했습니다.", + error_code="DATABASE_ERROR", + status_code=500, + details={"db_error": error} + ) + + @staticmethod + def file_error_response(error: str) -> Dict[str, Any]: + """파일 처리 에러 응답""" + return ErrorResponse.create_error_response( + message="파일 처리 중 오류가 발생했습니다.", + error_code="FILE_ERROR", + status_code=400, + details={"file_error": error} + ) + + +async def tkmp_exception_handler(request: Request, exc: TKMPException): + """TK-MP 커스텀 예외 핸들러""" + logger.error(f"TK-MP 예외 발생: {exc.message} (코드: {exc.error_code})") + + return JSONResponse( + status_code=exc.status_code, + content=ErrorResponse.create_error_response( + message=exc.message, + error_code=exc.error_code, + status_code=exc.status_code + ) + ) + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """검증 예외 핸들러""" + logger.warning(f"검증 오류: {exc.errors()}") + + return JSONResponse( + status_code=422, + content=ErrorResponse.validation_error_response(exc.errors()) + ) + + +async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): + """SQLAlchemy 예외 핸들러""" + logger.error(f"데이터베이스 오류: {str(exc)}", exc_info=True) + + return JSONResponse( + status_code=500, + content=ErrorResponse.database_error_response(str(exc)) + ) + + +async def general_exception_handler(request: Request, exc: Exception): + """일반 예외 핸들러""" + logger.error(f"예상치 못한 오류: {str(exc)}", exc_info=True) + + return JSONResponse( + status_code=500, + content=ErrorResponse.create_error_response( + message="서버 내부 오류가 발생했습니다.", + error_code="INTERNAL_SERVER_ERROR", + status_code=500, + details={"error": str(exc)} if logger.level <= 10 else None # DEBUG 레벨일 때만 상세 에러 표시 + ) + ) + + +def setup_error_handlers(app): + """FastAPI 앱에 에러 핸들러 등록""" + app.add_exception_handler(TKMPException, tkmp_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler) + app.add_exception_handler(Exception, general_exception_handler) + + logger.info("에러 핸들러 등록 완료") diff --git a/backend/app/utils/file_processor.py b/backend/app/utils/file_processor.py new file mode 100644 index 0000000..e83aec0 --- /dev/null +++ b/backend/app/utils/file_processor.py @@ -0,0 +1,335 @@ +""" +대용량 파일 처리 최적화 유틸리티 +메모리 효율적인 파일 처리 및 청크 기반 처리 +""" +import pandas as pd +import asyncio +from typing import Iterator, List, Dict, Any, Optional, Callable +from pathlib import Path +import tempfile +import os +from concurrent.futures import ThreadPoolExecutor +import gc + +from .logger import get_logger +from ..config import get_settings + +logger = get_logger(__name__) +settings = get_settings() + + +class FileProcessor: + """대용량 파일 처리 최적화 클래스""" + + def __init__(self, chunk_size: int = 1000, max_workers: int = 4): + self.chunk_size = chunk_size + self.max_workers = max_workers + self.executor = ThreadPoolExecutor(max_workers=max_workers) + + def read_excel_chunks(self, file_path: str, sheet_name: str = None) -> Iterator[pd.DataFrame]: + """ + 엑셀 파일을 청크 단위로 읽기 + + Args: + file_path: 파일 경로 + sheet_name: 시트명 (None이면 첫 번째 시트) + + Yields: + DataFrame: 청크 단위 데이터 + """ + try: + # 파일 크기 확인 + file_size = os.path.getsize(file_path) + logger.info(f"엑셀 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes") + + # 전체 행 수 확인 (메모리 효율적으로) + with pd.ExcelFile(file_path) as xls: + if sheet_name is None: + sheet_name = xls.sheet_names[0] + + # 첫 번째 청크로 컬럼 정보 확인 + first_chunk = pd.read_excel(xls, sheet_name=sheet_name, nrows=self.chunk_size) + total_rows = len(first_chunk) + + # 전체 데이터를 청크로 나누어 처리 + processed_rows = 0 + chunk_num = 0 + + while processed_rows < total_rows: + try: + # 청크 읽기 + chunk = pd.read_excel( + xls, + sheet_name=sheet_name, + skiprows=processed_rows + 1 if processed_rows > 0 else 0, + nrows=self.chunk_size, + header=0 if processed_rows == 0 else None + ) + + if chunk.empty: + break + + # 첫 번째 청크가 아닌 경우 컬럼명 설정 + if processed_rows > 0: + chunk.columns = first_chunk.columns + + chunk_num += 1 + processed_rows += len(chunk) + + logger.debug(f"청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {processed_rows}") + + yield chunk + + # 메모리 정리 + del chunk + gc.collect() + + except Exception as e: + logger.error(f"청크 {chunk_num} 처리 중 오류: {e}") + break + + logger.info(f"엑셀 파일 처리 완료 - 총 {chunk_num}개 청크, {processed_rows}행 처리") + + except Exception as e: + logger.error(f"엑셀 파일 읽기 실패: {e}") + raise + + def read_csv_chunks(self, file_path: str, encoding: str = 'utf-8') -> Iterator[pd.DataFrame]: + """ + CSV 파일을 청크 단위로 읽기 + + Args: + file_path: 파일 경로 + encoding: 인코딩 (기본: utf-8) + + Yields: + DataFrame: 청크 단위 데이터 + """ + try: + file_size = os.path.getsize(file_path) + logger.info(f"CSV 파일 처리 시작 - 파일: {file_path}, 크기: {file_size} bytes") + + chunk_num = 0 + total_rows = 0 + + # pandas의 chunksize 옵션 사용 + for chunk in pd.read_csv(file_path, chunksize=self.chunk_size, encoding=encoding): + chunk_num += 1 + total_rows += len(chunk) + + logger.debug(f"CSV 청크 {chunk_num} 처리 - 행 수: {len(chunk)}, 누적: {total_rows}") + + yield chunk + + # 메모리 정리 + gc.collect() + + logger.info(f"CSV 파일 처리 완료 - 총 {chunk_num}개 청크, {total_rows}행 처리") + + except Exception as e: + logger.error(f"CSV 파일 읽기 실패: {e}") + raise + + async def process_file_async( + self, + file_path: str, + processor_func: Callable[[pd.DataFrame], List[Dict]], + file_type: str = "excel" + ) -> List[Dict]: + """ + 파일을 비동기적으로 처리 + + Args: + file_path: 파일 경로 + processor_func: 각 청크를 처리할 함수 + file_type: 파일 타입 ("excel" 또는 "csv") + + Returns: + List[Dict]: 처리된 결과 리스트 + """ + try: + logger.info(f"비동기 파일 처리 시작 - {file_path}") + + results = [] + chunk_futures = [] + + # 파일 타입에 따른 청크 리더 선택 + if file_type.lower() == "csv": + chunk_reader = self.read_csv_chunks(file_path) + else: + chunk_reader = self.read_excel_chunks(file_path) + + # 청크별 비동기 처리 + for chunk in chunk_reader: + # 스레드 풀에서 청크 처리 + future = asyncio.get_event_loop().run_in_executor( + self.executor, + processor_func, + chunk + ) + chunk_futures.append(future) + + # 너무 많은 청크가 동시에 처리되지 않도록 제한 + if len(chunk_futures) >= self.max_workers: + # 완료된 작업들 수집 + completed_results = await asyncio.gather(*chunk_futures) + for result in completed_results: + if result: + results.extend(result) + + chunk_futures = [] + gc.collect() + + # 남은 청크들 처리 + if chunk_futures: + completed_results = await asyncio.gather(*chunk_futures) + for result in completed_results: + if result: + results.extend(result) + + logger.info(f"비동기 파일 처리 완료 - 총 {len(results)}개 항목 처리") + return results + + except Exception as e: + logger.error(f"비동기 파일 처리 실패: {e}") + raise + + def optimize_dataframe_memory(self, df: pd.DataFrame) -> pd.DataFrame: + """ + DataFrame 메모리 사용량 최적화 + + Args: + df: 최적화할 DataFrame + + Returns: + DataFrame: 최적화된 DataFrame + """ + try: + original_memory = df.memory_usage(deep=True).sum() + + # 수치형 컬럼 최적화 + for col in df.select_dtypes(include=['int64']).columns: + col_min = df[col].min() + col_max = df[col].max() + + if col_min >= -128 and col_max <= 127: + df[col] = df[col].astype('int8') + elif col_min >= -32768 and col_max <= 32767: + df[col] = df[col].astype('int16') + elif col_min >= -2147483648 and col_max <= 2147483647: + df[col] = df[col].astype('int32') + + # 실수형 컬럼 최적화 + for col in df.select_dtypes(include=['float64']).columns: + df[col] = pd.to_numeric(df[col], downcast='float') + + # 문자열 컬럼 최적화 (카테고리형으로 변환) + for col in df.select_dtypes(include=['object']).columns: + if df[col].nunique() / len(df) < 0.5: # 고유값이 50% 미만인 경우 + df[col] = df[col].astype('category') + + optimized_memory = df.memory_usage(deep=True).sum() + memory_reduction = (original_memory - optimized_memory) / original_memory * 100 + + logger.debug(f"DataFrame 메모리 최적화 완료 - 감소율: {memory_reduction:.1f}%") + + return df + + except Exception as e: + logger.warning(f"DataFrame 메모리 최적화 실패: {e}") + return df + + def create_temp_file(self, suffix: str = '.tmp') -> str: + """ + 임시 파일 생성 + + Args: + suffix: 파일 확장자 + + Returns: + str: 임시 파일 경로 + """ + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + temp_file.close() + logger.debug(f"임시 파일 생성: {temp_file.name}") + return temp_file.name + + def cleanup_temp_file(self, file_path: str): + """ + 임시 파일 정리 + + Args: + file_path: 삭제할 파일 경로 + """ + try: + if os.path.exists(file_path): + os.unlink(file_path) + logger.debug(f"임시 파일 삭제: {file_path}") + except Exception as e: + logger.warning(f"임시 파일 삭제 실패: {file_path}, error: {e}") + + def get_file_info(self, file_path: str) -> Dict[str, Any]: + """ + 파일 정보 조회 + + Args: + file_path: 파일 경로 + + Returns: + Dict: 파일 정보 + """ + try: + file_stat = os.stat(file_path) + file_ext = Path(file_path).suffix.lower() + + info = { + "file_path": file_path, + "file_size": file_stat.st_size, + "file_size_mb": round(file_stat.st_size / (1024 * 1024), 2), + "file_extension": file_ext, + "is_large_file": file_stat.st_size > 10 * 1024 * 1024, # 10MB 이상 + "recommended_chunk_size": self._calculate_optimal_chunk_size(file_stat.st_size) + } + + # 파일 타입별 추가 정보 + if file_ext in ['.xlsx', '.xls']: + info["file_type"] = "excel" + info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load" + elif file_ext == '.csv': + info["file_type"] = "csv" + info["processing_method"] = "chunk_based" if info["is_large_file"] else "full_load" + + return info + + except Exception as e: + logger.error(f"파일 정보 조회 실패: {e}") + return {"error": str(e)} + + def _calculate_optimal_chunk_size(self, file_size: int) -> int: + """ + 파일 크기에 따른 최적 청크 크기 계산 + + Args: + file_size: 파일 크기 (bytes) + + Returns: + int: 최적 청크 크기 + """ + # 파일 크기에 따른 청크 크기 조정 + if file_size < 1024 * 1024: # 1MB 미만 + return 500 + elif file_size < 10 * 1024 * 1024: # 10MB 미만 + return 1000 + elif file_size < 50 * 1024 * 1024: # 50MB 미만 + return 2000 + else: # 50MB 이상 + return 5000 + + def __del__(self): + """소멸자 - 스레드 풀 정리""" + if hasattr(self, 'executor'): + self.executor.shutdown(wait=True) + + +# 전역 파일 프로세서 인스턴스 +file_processor = FileProcessor() diff --git a/backend/app/utils/file_validator.py b/backend/app/utils/file_validator.py new file mode 100644 index 0000000..b3f5f96 --- /dev/null +++ b/backend/app/utils/file_validator.py @@ -0,0 +1,169 @@ +""" +파일 업로드 검증 유틸리티 +보안 강화를 위한 파일 검증 로직 +""" +import os +import magic +from pathlib import Path +from typing import List, Optional, Tuple +from fastapi import UploadFile, HTTPException + +from ..config import get_settings +from .logger import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +class FileValidator: + """파일 업로드 검증 클래스""" + + def __init__(self): + self.max_file_size = settings.security.max_file_size + self.allowed_extensions = settings.security.allowed_file_extensions + + # MIME 타입 매핑 + self.mime_type_mapping = { + '.xlsx': [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/octet-stream' # 일부 브라우저에서 xlsx를 이렇게 인식 + ], + '.xls': [ + 'application/vnd.ms-excel', + 'application/octet-stream' + ], + '.csv': [ + 'text/csv', + 'text/plain', + 'application/csv' + ] + } + + def validate_file_extension(self, filename: str) -> bool: + """파일 확장자 검증""" + file_ext = Path(filename).suffix.lower() + is_valid = file_ext in self.allowed_extensions + + if not is_valid: + logger.warning(f"허용되지 않은 파일 확장자: {file_ext}, 파일: {filename}") + + return is_valid + + def validate_file_size(self, file_size: int) -> bool: + """파일 크기 검증""" + is_valid = file_size <= self.max_file_size + + if not is_valid: + logger.warning(f"파일 크기 초과: {file_size} bytes (최대: {self.max_file_size} bytes)") + + return is_valid + + def validate_filename(self, filename: str) -> bool: + """파일명 검증 (보안 위험 문자 체크)""" + # 위험한 문자들 + dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] + + for char in dangerous_chars: + if char in filename: + logger.warning(f"위험한 문자 포함된 파일명: {filename}") + return False + + # 파일명 길이 체크 (255자 제한) + if len(filename) > 255: + logger.warning(f"파일명이 너무 긺: {len(filename)} 문자") + return False + + return True + + def validate_mime_type(self, file_content: bytes, filename: str) -> bool: + """MIME 타입 검증 (파일 내용 기반)""" + try: + # python-magic을 사용한 MIME 타입 검증 + detected_mime = magic.from_buffer(file_content, mime=True) + file_ext = Path(filename).suffix.lower() + + expected_mimes = self.mime_type_mapping.get(file_ext, []) + + if detected_mime in expected_mimes: + return True + + logger.warning(f"MIME 타입 불일치 - 파일: {filename}, 감지된 타입: {detected_mime}, 예상 타입: {expected_mimes}") + return False + + except Exception as e: + logger.error(f"MIME 타입 검증 실패: {e}") + # magic 라이브러리 오류 시 확장자 검증으로 대체 + return self.validate_file_extension(filename) + + def sanitize_filename(self, filename: str) -> str: + """파일명 정화 (안전한 파일명으로 변환)""" + # 위험한 문자들을 언더스코어로 대체 + dangerous_chars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'] + sanitized = filename + + for char in dangerous_chars: + sanitized = sanitized.replace(char, '_') + + # 연속된 언더스코어 제거 + while '__' in sanitized: + sanitized = sanitized.replace('__', '_') + + # 앞뒤 공백 및 점 제거 + sanitized = sanitized.strip(' .') + + return sanitized + + async def validate_upload_file(self, file: UploadFile) -> Tuple[bool, Optional[str]]: + """ + 업로드 파일 종합 검증 + + Returns: + Tuple[bool, Optional[str]]: (검증 성공 여부, 에러 메시지) + """ + try: + # 1. 파일명 검증 + if not self.validate_filename(file.filename): + return False, f"유효하지 않은 파일명: {file.filename}" + + # 2. 확장자 검증 + if not self.validate_file_extension(file.filename): + return False, f"허용되지 않은 파일 형식입니다. 허용 형식: {', '.join(self.allowed_extensions)}" + + # 3. 파일 내용 읽기 + file_content = await file.read() + await file.seek(0) # 파일 포인터 리셋 + + # 4. 파일 크기 검증 + if not self.validate_file_size(len(file_content)): + return False, f"파일 크기가 너무 큽니다. 최대 크기: {self.max_file_size // (1024*1024)}MB" + + # 5. MIME 타입 검증 + if not self.validate_mime_type(file_content, file.filename): + return False, "파일 형식이 올바르지 않습니다." + + logger.info(f"파일 검증 성공: {file.filename} ({len(file_content)} bytes)") + return True, None + + except Exception as e: + logger.error(f"파일 검증 중 오류 발생: {e}", exc_info=True) + return False, f"파일 검증 중 오류가 발생했습니다: {str(e)}" + + +# 전역 파일 검증기 인스턴스 +file_validator = FileValidator() + + +async def validate_uploaded_file(file: UploadFile) -> None: + """ + 파일 검증 헬퍼 함수 (HTTPException 발생) + + Args: + file: 업로드된 파일 + + Raises: + HTTPException: 검증 실패 시 + """ + is_valid, error_message = await file_validator.validate_upload_file(file) + + if not is_valid: + raise HTTPException(status_code=400, detail=error_message) diff --git a/backend/app/utils/logger.py b/backend/app/utils/logger.py new file mode 100644 index 0000000..aad30a4 --- /dev/null +++ b/backend/app/utils/logger.py @@ -0,0 +1,87 @@ +""" +로깅 유틸리티 모듈 +중앙화된 로깅 설정 및 관리 +""" +import logging +import os +from logging.handlers import RotatingFileHandler +from typing import Optional + +from ..config import get_settings + +settings = get_settings() + + +def setup_logger( + name: str, + log_file: Optional[str] = None, + level: str = None +) -> logging.Logger: + """ + 로거 설정 및 반환 + + Args: + name: 로거 이름 + log_file: 로그 파일 경로 (선택사항) + level: 로그 레벨 (선택사항) + + Returns: + 설정된 로거 인스턴스 + """ + logger = logging.getLogger(name) + + # 이미 핸들러가 설정된 경우 중복 방지 + if logger.handlers: + return logger + + # 로그 레벨 설정 + log_level = level or settings.logging.level + logger.setLevel(getattr(logging, log_level.upper())) + + # 포맷터 설정 + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' + ) + + # 콘솔 핸들러 + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # 파일 핸들러 (선택사항) + if log_file or settings.logging.file_path: + file_path = log_file or settings.logging.file_path + + # 로그 디렉토리 생성 + log_dir = os.path.dirname(file_path) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + + # 로테이팅 파일 핸들러 (10MB, 5개 파일 유지) + file_handler = RotatingFileHandler( + file_path, + maxBytes=10*1024*1024, # 10MB + backupCount=5, + encoding='utf-8' + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +def get_logger(name: str) -> logging.Logger: + """ + 로거 인스턴스 반환 (간편 함수) + + Args: + name: 로거 이름 + + Returns: + 로거 인스턴스 + """ + return setup_logger(name) + + +# 애플리케이션 전역 로거 +app_logger = setup_logger("tk_mp_app", settings.logging.file_path) diff --git a/backend/app/utils/transaction_manager.py b/backend/app/utils/transaction_manager.py new file mode 100644 index 0000000..8a2dfa4 --- /dev/null +++ b/backend/app/utils/transaction_manager.py @@ -0,0 +1,355 @@ +""" +트랜잭션 관리 유틸리티 +데이터 일관성을 위한 트랜잭션 관리 및 데코레이터 +""" +import functools +from typing import Any, Callable, Optional, TypeVar, Generic +from contextlib import contextmanager +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +import asyncio + +from .logger import get_logger + +logger = get_logger(__name__) + +T = TypeVar('T') + + +class TransactionManager: + """트랜잭션 관리 클래스""" + + def __init__(self, db: Session): + self.db = db + + @contextmanager + def transaction(self, rollback_on_exception: bool = True): + """ + 트랜잭션 컨텍스트 매니저 + + Args: + rollback_on_exception: 예외 발생 시 롤백 여부 + """ + try: + logger.debug("트랜잭션 시작") + yield self.db + self.db.commit() + logger.debug("트랜잭션 커밋 완료") + + except Exception as e: + if rollback_on_exception: + self.db.rollback() + logger.warning(f"트랜잭션 롤백 - 에러: {str(e)}") + else: + logger.error(f"트랜잭션 에러 (롤백 안함) - 에러: {str(e)}") + raise + + @contextmanager + def savepoint(self, name: Optional[str] = None): + """ + 세이브포인트 컨텍스트 매니저 + + Args: + name: 세이브포인트 이름 + """ + savepoint_name = name or f"sp_{id(self)}" + + try: + # 세이브포인트 생성 + savepoint = self.db.begin_nested() + logger.debug(f"세이브포인트 생성: {savepoint_name}") + + yield self.db + + # 세이브포인트 커밋 + savepoint.commit() + logger.debug(f"세이브포인트 커밋: {savepoint_name}") + + except Exception as e: + # 세이브포인트 롤백 + savepoint.rollback() + logger.warning(f"세이브포인트 롤백: {savepoint_name} - 에러: {str(e)}") + raise + + def execute_in_transaction(self, func: Callable[..., T], *args, **kwargs) -> T: + """ + 함수를 트랜잭션 내에서 실행 + + Args: + func: 실행할 함수 + *args: 함수 인자 + **kwargs: 함수 키워드 인자 + + Returns: + 함수 실행 결과 + """ + with self.transaction(): + return func(*args, **kwargs) + + async def execute_in_transaction_async(self, func: Callable[..., T], *args, **kwargs) -> T: + """ + 비동기 함수를 트랜잭션 내에서 실행 + + Args: + func: 실행할 비동기 함수 + *args: 함수 인자 + **kwargs: 함수 키워드 인자 + + Returns: + 함수 실행 결과 + """ + with self.transaction(): + if asyncio.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) + + +def transactional(rollback_on_exception: bool = True): + """ + 트랜잭션 데코레이터 + + Args: + rollback_on_exception: 예외 발생 시 롤백 여부 + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + # 첫 번째 인자가 Session인지 확인 + if args and isinstance(args[0], Session): + db = args[0] + transaction_manager = TransactionManager(db) + + try: + with transaction_manager.transaction(rollback_on_exception): + return func(*args, **kwargs) + except Exception as e: + logger.error(f"트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}") + raise + else: + # Session이 없으면 일반 함수로 실행 + return func(*args, **kwargs) + + return wrapper + return decorator + + +def async_transactional(rollback_on_exception: bool = True): + """ + 비동기 트랜잭션 데코레이터 + + Args: + rollback_on_exception: 예외 발생 시 롤백 여부 + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs): + # 첫 번째 인자가 Session인지 확인 + if args and isinstance(args[0], Session): + db = args[0] + transaction_manager = TransactionManager(db) + + try: + with transaction_manager.transaction(rollback_on_exception): + if asyncio.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) + except Exception as e: + logger.error(f"비동기 트랜잭션 함수 실행 실패: {func.__name__} - {str(e)}") + raise + else: + # Session이 없으면 일반 함수로 실행 + if asyncio.iscoroutinefunction(func): + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) + + return wrapper + return decorator + + +class BatchProcessor: + """배치 처리를 위한 트랜잭션 관리""" + + def __init__(self, db: Session, batch_size: int = 1000): + self.db = db + self.batch_size = batch_size + self.transaction_manager = TransactionManager(db) + + def process_in_batches( + self, + items: list, + process_func: Callable, + commit_per_batch: bool = True + ): + """ + 아이템들을 배치 단위로 처리 + + Args: + items: 처리할 아이템 리스트 + process_func: 각 아이템을 처리할 함수 + commit_per_batch: 배치마다 커밋 여부 + """ + total_items = len(items) + processed_count = 0 + failed_count = 0 + + logger.info(f"배치 처리 시작 - 총 {total_items}개 아이템, 배치 크기: {self.batch_size}") + + for i in range(0, total_items, self.batch_size): + batch = items[i:i + self.batch_size] + batch_num = (i // self.batch_size) + 1 + + try: + if commit_per_batch: + with self.transaction_manager.transaction(): + self._process_batch(batch, process_func) + else: + self._process_batch(batch, process_func) + + processed_count += len(batch) + logger.debug(f"배치 {batch_num} 처리 완료 - {len(batch)}개 아이템") + + except Exception as e: + failed_count += len(batch) + logger.error(f"배치 {batch_num} 처리 실패 - {str(e)}") + + # 개별 아이템 처리 시도 + if commit_per_batch: + self._process_batch_individually(batch, process_func) + + # 전체 커밋 (배치마다 커밋하지 않은 경우) + if not commit_per_batch: + try: + self.db.commit() + logger.info("전체 배치 처리 커밋 완료") + except Exception as e: + self.db.rollback() + logger.error(f"전체 배치 처리 커밋 실패: {str(e)}") + raise + + logger.info(f"배치 처리 완료 - 성공: {processed_count}, 실패: {failed_count}") + + return { + "total_items": total_items, + "processed_count": processed_count, + "failed_count": failed_count, + "success_rate": (processed_count / total_items) * 100 if total_items > 0 else 0 + } + + def _process_batch(self, batch: list, process_func: Callable): + """배치 처리""" + for item in batch: + process_func(item) + + def _process_batch_individually(self, batch: list, process_func: Callable): + """배치 내 아이템을 개별적으로 처리 (에러 복구용)""" + for item in batch: + try: + with self.transaction_manager.savepoint(): + process_func(item) + except Exception as e: + logger.warning(f"개별 아이템 처리 실패: {str(e)}") + + +class DatabaseLock: + """데이터베이스 레벨 락 관리""" + + def __init__(self, db: Session): + self.db = db + + @contextmanager + def advisory_lock(self, lock_id: int): + """ + PostgreSQL Advisory Lock + + Args: + lock_id: 락 ID + """ + try: + # Advisory Lock 획득 + result = self.db.execute(f"SELECT pg_advisory_lock({lock_id})") + logger.debug(f"Advisory Lock 획득: {lock_id}") + + yield + + finally: + # Advisory Lock 해제 + self.db.execute(f"SELECT pg_advisory_unlock({lock_id})") + logger.debug(f"Advisory Lock 해제: {lock_id}") + + @contextmanager + def table_lock(self, table_name: str, lock_mode: str = "ACCESS EXCLUSIVE"): + """ + 테이블 레벨 락 + + Args: + table_name: 테이블명 + lock_mode: 락 모드 + """ + try: + # 테이블 락 획득 + self.db.execute(f"LOCK TABLE {table_name} IN {lock_mode} MODE") + logger.debug(f"테이블 락 획득: {table_name} ({lock_mode})") + + yield + + except Exception as e: + logger.error(f"테이블 락 실패: {table_name} - {str(e)}") + raise + + +class TransactionStats: + """트랜잭션 통계 수집""" + + def __init__(self): + self.stats = { + "total_transactions": 0, + "successful_transactions": 0, + "failed_transactions": 0, + "rollback_count": 0, + "savepoint_count": 0 + } + + def record_transaction_start(self): + """트랜잭션 시작 기록""" + self.stats["total_transactions"] += 1 + + def record_transaction_success(self): + """트랜잭션 성공 기록""" + self.stats["successful_transactions"] += 1 + + def record_transaction_failure(self): + """트랜잭션 실패 기록""" + self.stats["failed_transactions"] += 1 + + def record_rollback(self): + """롤백 기록""" + self.stats["rollback_count"] += 1 + + def record_savepoint(self): + """세이브포인트 기록""" + self.stats["savepoint_count"] += 1 + + def get_stats(self) -> dict: + """통계 반환""" + total = self.stats["total_transactions"] + if total > 0: + self.stats["success_rate"] = (self.stats["successful_transactions"] / total) * 100 + self.stats["failure_rate"] = (self.stats["failed_transactions"] / total) * 100 + else: + self.stats["success_rate"] = 0 + self.stats["failure_rate"] = 0 + + return self.stats.copy() + + def reset_stats(self): + """통계 초기화""" + for key in self.stats: + if key not in ["success_rate", "failure_rate"]: + self.stats[key] = 0 + + +# 전역 트랜잭션 통계 인스턴스 +transaction_stats = TransactionStats() diff --git a/backend/env.example b/backend/env.example new file mode 100644 index 0000000..be084f8 --- /dev/null +++ b/backend/env.example @@ -0,0 +1,25 @@ +# TK-MP-Project 환경변수 설정 예시 +# 실제 사용 시 .env 파일로 복사하여 사용 + +# 환경 설정 (development, production, synology) +ENVIRONMENT=development + +# 애플리케이션 설정 +APP_NAME=TK-MP BOM Management API +APP_VERSION=1.0.0 +DEBUG=true + +# 데이터베이스 설정 +DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom + +# Redis 설정 +REDIS_URL=redis://redis:6379 + +# 보안 설정 +# CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] # 필요시 직접 설정 +MAX_FILE_SIZE=52428800 # 50MB in bytes +ALLOWED_FILE_EXTENSIONS=[".xlsx",".xls",".csv"] + +# 로깅 설정 +LOG_LEVEL=INFO +LOG_FILE=logs/app.log diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..ccc58a8 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,60 @@ +[tool:pytest] +# pytest 설정 파일 + +# 테스트 디렉토리 +testpaths = tests + +# 테스트 파일 패턴 +python_files = test_*.py *_test.py + +# 테스트 클래스 패턴 +python_classes = Test* + +# 테스트 함수 패턴 +python_functions = test_* + +# 마커 정의 +markers = + unit: 단위 테스트 + integration: 통합 테스트 + performance: 성능 테스트 + slow: 느린 테스트 (시간이 오래 걸리는 테스트) + api: API 테스트 + database: 데이터베이스 테스트 + cache: 캐시 테스트 + classifier: 분류기 테스트 + +# 출력 설정 +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --color=yes + --durations=10 + --cov=app + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-fail-under=80 + +# 최소 커버리지 (80%) +# --cov-fail-under=80 + +# 로그 설정 +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S + +# 경고 필터 +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::UserWarning:sqlalchemy.* + +# 테스트 발견 설정 +minversion = 6.0 +required_plugins = + pytest-cov + pytest-asyncio + pytest-mock diff --git a/backend/requirements.txt b/backend/requirements.txt index 4d1b10e..550b668 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -21,10 +21,19 @@ pydantic-settings==2.1.0 python-dotenv==1.0.0 httpx==0.25.2 redis==5.0.1 +python-magic==0.4.27 + +# 인증 시스템 +PyJWT==2.8.0 +bcrypt==4.1.2 +python-multipart==0.0.6 +email-validator==2.3.0 # 개발 도구 pytest==7.4.3 pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +pytest-mock==3.12.0 black==23.11.0 flake8==6.1.0 python-multipart==0.0.6 diff --git a/backend/scripts/15_create_tubing_system.sql b/backend/scripts/15_create_tubing_system.sql new file mode 100644 index 0000000..6770e5a --- /dev/null +++ b/backend/scripts/15_create_tubing_system.sql @@ -0,0 +1,184 @@ +-- ================================ +-- Tubing 제품 관리 시스템 +-- 실행일: 2025.08.01 +-- ================================ + +-- 1. Tubing 카테고리 테이블 (일반, VCR, 기타 등) +CREATE TABLE IF NOT EXISTS tubing_categories ( + id SERIAL PRIMARY KEY, + category_code VARCHAR(20) UNIQUE NOT NULL, + category_name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Tubing 규격 마스터 테이블 +CREATE TABLE IF NOT EXISTS tubing_specifications ( + id SERIAL PRIMARY KEY, + category_id INTEGER REFERENCES tubing_categories(id), + spec_code VARCHAR(50) UNIQUE NOT NULL, + spec_name VARCHAR(200) NOT NULL, + + -- 물리적 규격 + outer_diameter_mm DECIMAL(8,3), -- 외경 (mm) + wall_thickness_mm DECIMAL(6,3), -- 두께 (mm) + inner_diameter_mm DECIMAL(8,3), -- 내경 (mm, 계산 또는 실측) + + -- 재질 정보 + material_grade VARCHAR(100), -- SS316, SS316L, Inconel625 등 + material_standard VARCHAR(100), -- ASTM A269, JIS G3463 등 + + -- 압력/온도 등급 + max_pressure_bar DECIMAL(8,2), -- 최대 압력 (bar) + max_temperature_c DECIMAL(6,2), -- 최대 온도 (°C) + min_temperature_c DECIMAL(6,2), -- 최소 온도 (°C) + + -- 표준 규격 + standard_length_m DECIMAL(8,3), -- 표준 길이 (m) + bend_radius_min_mm DECIMAL(8,2), -- 최소 벤딩 반경 (mm) + + -- 기타 정보 + surface_finish VARCHAR(100), -- 표면 마감 (BA, #4, 2B 등) + hardness VARCHAR(50), -- 경도 + notes TEXT, + + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. 제조사 정보 테이블 +CREATE TABLE IF NOT EXISTS tubing_manufacturers ( + id SERIAL PRIMARY KEY, + manufacturer_code VARCHAR(20) UNIQUE NOT NULL, + manufacturer_name VARCHAR(200) NOT NULL, + country VARCHAR(100), + website VARCHAR(500), + contact_info JSONB, -- 연락처 정보 (JSON) + quality_certs JSONB, -- 품질 인증서 정보 (ISO, API 등) + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 4. 제조사별 제품 테이블 (품목번호 매핑) +CREATE TABLE IF NOT EXISTS tubing_products ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES tubing_specifications(id), + manufacturer_id INTEGER REFERENCES tubing_manufacturers(id), + + -- 제조사 품목번호 정보 + manufacturer_part_number VARCHAR(200) NOT NULL, -- 제조사 품목번호 + manufacturer_product_name VARCHAR(300), -- 제조사 제품명 + + -- 가격/공급 정보 + list_price DECIMAL(12,2), -- 정가 + currency VARCHAR(10) DEFAULT 'KRW', -- 통화 + lead_time_days INTEGER, -- 리드타임 (일) + minimum_order_qty DECIMAL(10,3), -- 최소 주문 수량 + standard_packaging_qty DECIMAL(10,3), -- 표준 포장 수량 + + -- 가용성 정보 + availability_status VARCHAR(50), -- 재고 상태 + last_price_update DATE, -- 마지막 가격 업데이트 + + -- 추가 정보 + datasheet_url VARCHAR(500), -- 데이터시트 URL + catalog_page VARCHAR(100), -- 카탈로그 페이지 + notes TEXT, + + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 유니크 제약 (같은 규격의 같은 제조사 제품은 하나만) + UNIQUE(specification_id, manufacturer_id, manufacturer_part_number) +); + +-- 5. BOM에서 사용되는 Tubing 매핑 테이블 +CREATE TABLE IF NOT EXISTS material_tubing_mapping ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + tubing_product_id INTEGER REFERENCES tubing_products(id), + + -- 매핑 정보 + confidence_score DECIMAL(3,2), -- 매핑 신뢰도 (0.00-1.00) + mapping_method VARCHAR(50), -- 매핑 방법 (auto/manual) + mapped_by VARCHAR(100), -- 매핑한 사용자 + mapped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 수량 정보 + required_length_m DECIMAL(10,3), -- 필요 길이 (m) + calculated_quantity DECIMAL(10,3), -- 계산된 주문 수량 + + -- 검증 정보 + is_verified BOOLEAN DEFAULT FALSE, + verified_by VARCHAR(100), + verified_at TIMESTAMP, + + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ================================ +-- 인덱스 생성 +-- ================================ + +-- Tubing 규격 관련 인덱스 +CREATE INDEX idx_tubing_specs_category ON tubing_specifications(category_id); +CREATE INDEX idx_tubing_specs_material ON tubing_specifications(material_grade); +CREATE INDEX idx_tubing_specs_diameter ON tubing_specifications(outer_diameter_mm, wall_thickness_mm); + +-- 제품 관련 인덱스 +CREATE INDEX idx_tubing_products_spec ON tubing_products(specification_id); +CREATE INDEX idx_tubing_products_manufacturer ON tubing_products(manufacturer_id); +CREATE INDEX idx_tubing_products_part_number ON tubing_products(manufacturer_part_number); + +-- 매핑 관련 인덱스 +CREATE INDEX idx_material_tubing_mapping_material ON material_tubing_mapping(material_id); +CREATE INDEX idx_material_tubing_mapping_product ON material_tubing_mapping(tubing_product_id); + +-- ================================ +-- 기초 데이터 입력 +-- ================================ + +-- Tubing 카테고리 기초 데이터 +INSERT INTO tubing_categories (category_code, category_name, description) VALUES +('GENERAL', '일반 Tubing', '일반적인 스테인리스 스틸 튜빙'), +('VCR', 'VCR Tubing', 'VCR (Vacuum Coupling Radiation) 연결용 튜빙'), +('SANITARY', 'Sanitary Tubing', '위생용 튜빙 (식품, 제약 등)'), +('HVAC', 'HVAC Tubing', '공조용 튜빙'), +('HYDRAULIC', 'Hydraulic Tubing', '유압용 튜빙'), +('PNEUMATIC', 'Pneumatic Tubing', '공압용 튜빙'), +('PROCESS', 'Process Tubing', '공정용 특수 튜빙'), +('EXOTIC', 'Exotic Material', '특수 재질 튜빙 (Hastelloy, Inconel 등)') +ON CONFLICT (category_code) DO NOTHING; + +-- 주요 제조사 기초 데이터 +INSERT INTO tubing_manufacturers (manufacturer_code, manufacturer_name, country) VALUES +('SWAGELOK', 'Swagelok Company', 'USA'), +('PARKER', 'Parker Hannifin', 'USA'), +('HAM_LET', 'Ham-Let Group', 'Israel'), +('SUPERLOK', 'Superlok USA', 'USA'), +('FITOK', 'Fitok Group', 'China'), +('DK_LOK', 'DK-Lok Corporation', 'South Korea'), +('GYROLOK', 'Gyrolok (Oliver Valves)', 'UK'), +('AS_ONE', 'AS ONE Corporation', 'Japan') +ON CONFLICT (manufacturer_code) DO NOTHING; + +-- 기본 스테인리스 스틸 튜빙 규격 예시 +INSERT INTO tubing_specifications ( + category_id, spec_code, spec_name, + outer_diameter_mm, wall_thickness_mm, inner_diameter_mm, + material_grade, material_standard, + max_pressure_bar, max_temperature_c, min_temperature_c, + standard_length_m +) VALUES +(1, 'SS316-6MM-1MM', '6mm OD x 1mm WT SS316 Tubing', 6.0, 1.0, 4.0, 'SS316', 'ASTM A269', 413, 815, -196, 6.0), +(1, 'SS316-8MM-1MM', '8mm OD x 1mm WT SS316 Tubing', 8.0, 1.0, 6.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0), +(1, 'SS316-10MM-1MM', '10mm OD x 1mm WT SS316 Tubing', 10.0, 1.0, 8.0, 'SS316', 'ASTM A269', 248, 815, -196, 6.0), +(1, 'SS316-12MM-1.5MM', '12mm OD x 1.5mm WT SS316 Tubing', 12.0, 1.5, 9.0, 'SS316', 'ASTM A269', 310, 815, -196, 6.0), +(1, 'SS316L-6MM-1MM', '6mm OD x 1mm WT SS316L Tubing', 6.0, 1.0, 4.0, 'SS316L', 'ASTM A269', 413, 815, -196, 6.0) +ON CONFLICT (spec_code) DO NOTHING; \ No newline at end of file diff --git a/backend/scripts/16_performance_indexes.sql b/backend/scripts/16_performance_indexes.sql new file mode 100644 index 0000000..aa3c63b --- /dev/null +++ b/backend/scripts/16_performance_indexes.sql @@ -0,0 +1,163 @@ +-- ================================ +-- 성능 최적화를 위한 추가 인덱스 +-- 생성일: 2025.01 (Phase 2) +-- ================================ + +-- 1. 복합 인덱스 (자주 함께 사용되는 컬럼들) +-- ================================ + +-- files 테이블: job_no + revision 조합 (리비전 비교 시 자주 사용) +CREATE INDEX IF NOT EXISTS idx_files_job_revision +ON files(job_no, revision) +WHERE is_active = true; + +-- files 테이블: job_no + upload_date (최신 파일 조회) +CREATE INDEX IF NOT EXISTS idx_files_job_date +ON files(job_no, upload_date DESC) +WHERE is_active = true; + +-- materials 테이블: file_id + category (자재 분류별 조회) +CREATE INDEX IF NOT EXISTS idx_materials_file_category +ON materials(file_id, classified_category); + +-- materials 테이블: category + material_grade (자재 종류별 재질 검색) +CREATE INDEX IF NOT EXISTS idx_materials_category_grade +ON materials(classified_category, material_grade); + +-- 2. 검색 성능 향상 인덱스 +-- ================================ + +-- materials 테이블: description 텍스트 검색 (GIN 인덱스) +CREATE INDEX IF NOT EXISTS idx_materials_description_gin +ON materials USING gin(to_tsvector('english', original_description)); + +-- materials 테이블: 해시 기반 중복 검색 +CREATE INDEX IF NOT EXISTS idx_materials_hash +ON materials(material_hash) +WHERE material_hash IS NOT NULL; + +-- 3. 정렬 성능 향상 인덱스 +-- ================================ + +-- jobs 테이블: 상태별 생성일 정렬 +CREATE INDEX IF NOT EXISTS idx_jobs_status_created +ON jobs(status, created_at DESC) +WHERE is_active = true; + +-- materials 테이블: 수량별 정렬 (대용량 자재 우선 표시) +CREATE INDEX IF NOT EXISTS idx_materials_quantity_desc +ON materials(quantity DESC); + +-- 4. 조건부 인덱스 (특정 조건에서만 사용) +-- ================================ + +-- 검증되지 않은 자재만 (분류 검토 필요한 항목) +CREATE INDEX IF NOT EXISTS idx_materials_unverified +ON materials(classified_category, classification_confidence) +WHERE is_verified = false; + +-- 신뢰도가 낮은 분류 (0.8 미만) +CREATE INDEX IF NOT EXISTS idx_materials_low_confidence +ON materials(file_id, classified_category) +WHERE classification_confidence < 0.8; + +-- 5. 외래키 성능 향상 +-- ================================ + +-- pipe_details 테이블 +CREATE INDEX IF NOT EXISTS idx_pipe_details_material +ON pipe_details(material_id); + +-- fitting_details 테이블 +CREATE INDEX IF NOT EXISTS idx_fitting_details_material +ON fitting_details(material_id); + +-- valve_details 테이블 +CREATE INDEX IF NOT EXISTS idx_valve_details_material +ON valve_details(material_id); + +-- flange_details 테이블 +CREATE INDEX IF NOT EXISTS idx_flange_details_material +ON flange_details(material_id); + +-- bolt_details 테이블 +CREATE INDEX IF NOT EXISTS idx_bolt_details_material +ON bolt_details(material_id); + +-- gasket_details 테이블 +CREATE INDEX IF NOT EXISTS idx_gasket_details_material +ON gasket_details(material_id); + +-- instrument_details 테이블 +CREATE INDEX IF NOT EXISTS idx_instrument_details_material +ON instrument_details(material_id); + +-- 6. 통계 및 집계 성능 향상 +-- ================================ + +-- 프로젝트별 자재 통계 (job_no 기준) +CREATE INDEX IF NOT EXISTS idx_materials_job_stats +ON materials( + (SELECT job_no FROM files WHERE files.id = materials.file_id), + classified_category +); + +-- 파이프 길이 집계용 (파이프 cutting 계산) +CREATE INDEX IF NOT EXISTS idx_pipe_length_aggregation +ON pipe_details(material_id, length_mm) +WHERE length_mm > 0; + +-- 7. 성능 모니터링을 위한 뷰 생성 +-- ================================ + +-- 인덱스 사용률 모니터링 뷰 +CREATE OR REPLACE VIEW index_usage_stats AS +SELECT + schemaname, + tablename, + indexname, + idx_tup_read, + idx_tup_fetch, + idx_scan, + CASE + WHEN idx_scan = 0 THEN 'UNUSED' + WHEN idx_scan < 10 THEN 'LOW_USAGE' + WHEN idx_scan < 100 THEN 'MEDIUM_USAGE' + ELSE 'HIGH_USAGE' + END as usage_level +FROM pg_stat_user_indexes +WHERE schemaname = 'public' +ORDER BY idx_scan DESC; + +-- 테이블 크기 및 성능 모니터링 뷰 +CREATE OR REPLACE VIEW table_performance_stats AS +SELECT + schemaname, + tablename, + n_tup_ins as inserts, + n_tup_upd as updates, + n_tup_del as deletes, + seq_scan as sequential_scans, + seq_tup_read as sequential_reads, + idx_scan as index_scans, + idx_tup_fetch as index_reads, + CASE + WHEN seq_scan + idx_scan = 0 THEN 0 + ELSE ROUND((idx_scan::numeric / (seq_scan + idx_scan)) * 100, 2) + END as index_usage_percentage +FROM pg_stat_user_tables +WHERE schemaname = 'public' +ORDER BY seq_scan + idx_scan DESC; + +-- ================================ +-- 인덱스 생성 완료 로그 +-- ================================ + +-- 성능 최적화 인덱스 생성 완료 확인 +DO $$ +BEGIN + RAISE NOTICE '성능 최적화 인덱스 생성 완료 - Phase 2 (2025.01)'; + RAISE NOTICE '총 생성된 인덱스: 복합 인덱스 4개, 검색 인덱스 2개, 정렬 인덱스 2개'; + RAISE NOTICE '조건부 인덱스 2개, 외래키 인덱스 7개, 집계 인덱스 2개'; + RAISE NOTICE '모니터링 뷰 2개 생성'; +END $$; diff --git a/backend/scripts/17_add_project_type_column.sql b/backend/scripts/17_add_project_type_column.sql new file mode 100644 index 0000000..319f2e4 --- /dev/null +++ b/backend/scripts/17_add_project_type_column.sql @@ -0,0 +1,29 @@ +-- jobs 테이블에 project_type 컬럼 추가 +-- TK-MP-Project 프로젝트 유형 관리를 위한 스키마 업데이트 + +-- project_type 컬럼 추가 (기존 데이터가 있을 수 있으므로 안전하게 추가) +DO $$ +BEGIN + -- project_type 컬럼이 존재하지 않으면 추가 + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'jobs' + AND column_name = 'project_type' + ) THEN + ALTER TABLE jobs ADD COLUMN project_type VARCHAR(50) DEFAULT '냉동기'; + + -- 기존 데이터에 대한 기본값 설정 + UPDATE jobs SET project_type = '냉동기' WHERE project_type IS NULL; + + -- NOT NULL 제약 조건 추가 + ALTER TABLE jobs ALTER COLUMN project_type SET NOT NULL; + + -- 인덱스 추가 (프로젝트 유형별 조회 성능 향상) + CREATE INDEX IF NOT EXISTS idx_jobs_project_type ON jobs(project_type); + + RAISE NOTICE 'project_type 컬럼이 성공적으로 추가되었습니다.'; + ELSE + RAISE NOTICE 'project_type 컬럼이 이미 존재합니다.'; + END IF; +END $$; diff --git a/backend/scripts/18_create_auth_tables.sql b/backend/scripts/18_create_auth_tables.sql new file mode 100644 index 0000000..e14b541 --- /dev/null +++ b/backend/scripts/18_create_auth_tables.sql @@ -0,0 +1,220 @@ +-- TK-MP-Project 인증 시스템을 위한 사용자 및 로그인 테이블 생성 +-- TK-FB-Project 인증 시스템을 참고하여 구현 + +-- 1. 사용자 테이블 생성 +CREATE TABLE IF NOT EXISTS users ( + user_id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + email VARCHAR(100), + + -- 권한 관리 + role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'system', 'leader', 'support', 'user')), + access_level VARCHAR(20) DEFAULT 'worker' CHECK (access_level IN ('admin', 'system', 'group_leader', 'support_team', 'worker')), + + -- 계정 상태 관리 + is_active BOOLEAN DEFAULT true, + failed_login_attempts INT DEFAULT 0, + locked_until TIMESTAMP NULL, + + -- 추가 정보 + department VARCHAR(50), + position VARCHAR(50), + phone VARCHAR(20), + + -- 타임스탬프 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP NULL +); + +-- 2. 로그인 이력 테이블 생성 +CREATE TABLE IF NOT EXISTS login_logs ( + log_id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(user_id) ON DELETE CASCADE, + login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45), + user_agent TEXT, + login_status VARCHAR(20) CHECK (login_status IN ('success', 'failed')), + failure_reason VARCHAR(100), + session_duration INT, -- 세션 지속 시간 (초) + + -- 인덱스를 위한 컬럼 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. 사용자 세션 테이블 (JWT Refresh Token 관리) +CREATE TABLE IF NOT EXISTS user_sessions ( + session_id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(user_id) ON DELETE CASCADE, + refresh_token VARCHAR(500) NOT NULL, + expires_at TIMESTAMP NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 4. 권한 테이블 (확장 가능한 권한 시스템) +CREATE TABLE IF NOT EXISTS permissions ( + permission_id SERIAL PRIMARY KEY, + permission_name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + module VARCHAR(30), -- 모듈별 권한 관리 (bom, project, purchase 등) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 5. 역할-권한 매핑 테이블 +CREATE TABLE IF NOT EXISTS role_permissions ( + role_permission_id SERIAL PRIMARY KEY, + role VARCHAR(20) NOT NULL, + permission_id INT REFERENCES permissions(permission_id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(role, permission_id) +); + +-- 6. 인덱스 생성 (성능 최적화) +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active); +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); + +CREATE INDEX IF NOT EXISTS idx_login_logs_user_id ON login_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_login_logs_login_time ON login_logs(login_time); +CREATE INDEX IF NOT EXISTS idx_login_logs_ip_address ON login_logs(ip_address); +CREATE INDEX IF NOT EXISTS idx_login_logs_status ON login_logs(login_status); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_refresh_token ON user_sessions(refresh_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_user_sessions_is_active ON user_sessions(is_active); + +CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module); +CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role); + +-- 7. 기본 권한 데이터 삽입 +INSERT INTO permissions (permission_name, description, module) VALUES +-- BOM 관리 권한 +('bom.view', 'BOM 조회 권한', 'bom'), +('bom.create', 'BOM 생성 권한', 'bom'), +('bom.edit', 'BOM 수정 권한', 'bom'), +('bom.delete', 'BOM 삭제 권한', 'bom'), +('bom.approve', 'BOM 승인 권한', 'bom'), + +-- 프로젝트 관리 권한 +('project.view', '프로젝트 조회 권한', 'project'), +('project.create', '프로젝트 생성 권한', 'project'), +('project.edit', '프로젝트 수정 권한', 'project'), +('project.delete', '프로젝트 삭제 권한', 'project'), +('project.manage', '프로젝트 관리 권한', 'project'), + +-- 파일 관리 권한 +('file.upload', '파일 업로드 권한', 'file'), +('file.download', '파일 다운로드 권한', 'file'), +('file.delete', '파일 삭제 권한', 'file'), + +-- 사용자 관리 권한 +('user.view', '사용자 조회 권한', 'user'), +('user.create', '사용자 생성 권한', 'user'), +('user.edit', '사용자 수정 권한', 'user'), +('user.delete', '사용자 삭제 권한', 'user'), + +-- 시스템 관리 권한 +('system.admin', '시스템 관리 권한', 'system'), +('system.logs', '로그 조회 권한', 'system'), +('system.settings', '시스템 설정 권한', 'system') + +ON CONFLICT (permission_name) DO NOTHING; + +-- 8. 역할별 기본 권한 할당 +INSERT INTO role_permissions (role, permission_id) +SELECT 'admin', permission_id FROM permissions +ON CONFLICT (role, permission_id) DO NOTHING; + +INSERT INTO role_permissions (role, permission_id) +SELECT 'system', permission_id FROM permissions +ON CONFLICT (role, permission_id) DO NOTHING; + +INSERT INTO role_permissions (role, permission_id) +SELECT 'leader', permission_id FROM permissions +WHERE permission_name IN ( + 'bom.view', 'bom.create', 'bom.edit', 'bom.approve', + 'project.view', 'project.create', 'project.edit', 'project.manage', + 'file.upload', 'file.download', 'file.delete', + 'user.view' +) +ON CONFLICT (role, permission_id) DO NOTHING; + +INSERT INTO role_permissions (role, permission_id) +SELECT 'support', permission_id FROM permissions +WHERE permission_name IN ( + 'bom.view', 'bom.create', 'bom.edit', + 'project.view', 'project.create', 'project.edit', + 'file.upload', 'file.download' +) +ON CONFLICT (role, permission_id) DO NOTHING; + +INSERT INTO role_permissions (role, permission_id) +SELECT 'user', permission_id FROM permissions +WHERE permission_name IN ( + 'bom.view', + 'project.view', + 'file.upload', 'file.download' +) +ON CONFLICT (role, permission_id) DO NOTHING; + +-- 9. 기본 관리자 계정 생성 (비밀번호: admin123) +-- bcrypt 해시: $2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi +INSERT INTO users (username, password, name, email, role, access_level, department, position) VALUES +('admin', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 관리자', 'admin@tkmp.com', 'admin', 'admin', 'IT', '시스템 관리자'), +('system', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '시스템 계정', 'system@tkmp.com', 'system', 'system', 'IT', '시스템 계정') +ON CONFLICT (username) DO NOTHING; + +-- 10. 트리거 함수 생성 (updated_at 자동 업데이트) +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 11. 트리거 적용 +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_sessions_updated_at BEFORE UPDATE ON user_sessions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 12. 뷰 생성 (사용자 정보 조회용) +CREATE OR REPLACE VIEW user_info_view AS +SELECT + u.user_id, + u.username, + u.name, + u.email, + u.role, + u.access_level, + u.department, + u.position, + u.is_active, + u.created_at, + u.last_login_at, + COUNT(ll.log_id) as login_count, + MAX(ll.login_time) as last_successful_login +FROM users u +LEFT JOIN login_logs ll ON u.user_id = ll.user_id AND ll.login_status = 'success' +GROUP BY u.user_id, u.username, u.name, u.email, u.role, u.access_level, + u.department, u.position, u.is_active, u.created_at, u.last_login_at; + +-- 완료 메시지 +DO $$ +BEGIN + RAISE NOTICE '✅ TK-MP-Project 인증 시스템 데이터베이스 스키마가 성공적으로 생성되었습니다!'; + RAISE NOTICE '📋 생성된 테이블: users, login_logs, user_sessions, permissions, role_permissions'; + RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123'; + RAISE NOTICE '🔐 권한 시스템: 5단계 역할 + 모듈별 세분화된 권한'; +END $$; diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..0b1dbac --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1,4 @@ +""" +테스트 모듈 +자동화된 테스트 케이스들 +""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..8a2d307 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,160 @@ +""" +pytest 설정 및 공통 픽스처 +""" +import pytest +import asyncio +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from fastapi.testclient import TestClient +import tempfile +import os + +from app.main import app +from app.database import get_db +from app.models import Base + + +# 테스트용 데이터베이스 설정 +TEST_DATABASE_URL = "sqlite:///./test.db" + +engine = create_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def override_get_db(): + """테스트용 데이터베이스 세션""" + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + +@pytest.fixture(scope="session") +def event_loop(): + """이벤트 루프 픽스처""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="function") +def db_session(): + """테스트용 데이터베이스 세션 픽스처""" + # 테이블 생성 + Base.metadata.create_all(bind=engine) + + # 세션 생성 + session = TestingSessionLocal() + + try: + yield session + finally: + session.close() + # 테이블 삭제 (테스트 격리) + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(scope="function") +def client(db_session): + """테스트 클라이언트 픽스처""" + # 데이터베이스 의존성 오버라이드 + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as test_client: + yield test_client + + # 의존성 오버라이드 정리 + app.dependency_overrides.clear() + + +@pytest.fixture +def sample_excel_file(): + """샘플 엑셀 파일 픽스처""" + import pandas as pd + + # 샘플 데이터 생성 + data = { + 'Description': [ + 'PIPE, SEAMLESS, A333-6, 6", SCH40', + 'ELBOW, 90DEG, A234-WPB, 4", SCH40', + 'VALVE, GATE, A216-WCB, 2", 150LB', + 'FLANGE, WELD NECK, A105, 3", 150LB', + 'BOLT, HEX HEAD, A193-B7, M16X50' + ], + 'Quantity': [10, 8, 2, 4, 20], + 'Unit': ['EA', 'EA', 'EA', 'EA', 'EA'] + } + + df = pd.DataFrame(data) + + # 임시 파일 생성 + with tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) as tmp_file: + df.to_excel(tmp_file.name, index=False) + yield tmp_file.name + + # 파일 정리 + os.unlink(tmp_file.name) + + +@pytest.fixture +def sample_csv_file(): + """샘플 CSV 파일 픽스처""" + import pandas as pd + + # 샘플 데이터 생성 + data = { + 'Description': [ + 'PIPE, SEAMLESS, A333-6, 8", SCH40', + 'TEE, EQUAL, A234-WPB, 6", SCH40', + 'VALVE, BALL, A216-WCB, 4", 150LB' + ], + 'Quantity': [5, 3, 1], + 'Unit': ['EA', 'EA', 'EA'] + } + + df = pd.DataFrame(data) + + # 임시 파일 생성 + with tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w') as tmp_file: + df.to_csv(tmp_file.name, index=False) + yield tmp_file.name + + # 파일 정리 + os.unlink(tmp_file.name) + + +@pytest.fixture +def sample_job_data(): + """샘플 작업 데이터 픽스처""" + return { + "job_no": "TEST-2025-001", + "job_name": "테스트 프로젝트", + "client_name": "테스트 고객사", + "end_user": "테스트 사용자", + "epc_company": "테스트 EPC", + "status": "active" + } + + +@pytest.fixture +def sample_material_data(): + """샘플 자재 데이터 픽스처""" + return { + "original_description": "PIPE, SEAMLESS, A333-6, 6\", SCH40", + "classified_category": "PIPE", + "classified_subcategory": "SEAMLESS", + "material_grade": "A333-6", + "schedule": "SCH40", + "size_spec": "6\"", + "quantity": 10.0, + "unit": "EA", + "classification_confidence": 0.95 + } + + +# 테스트 설정 +pytest_plugins = [] diff --git a/backend/tests/test_classifiers.py b/backend/tests/test_classifiers.py new file mode 100644 index 0000000..a01fa9f --- /dev/null +++ b/backend/tests/test_classifiers.py @@ -0,0 +1,423 @@ +""" +자재 분류기 테스트 +""" +import pytest +from unittest.mock import patch + + +class TestPipeClassifier: + """파이프 분류기 테스트""" + + def test_pipe_classification_basic(self): + """기본 파이프 분류 테스트""" + from app.services.pipe_classifier import classify_pipe + + # 명확한 파이프 케이스 + result = classify_pipe("", "PIPE, SEAMLESS, A333-6, 6\", SCH40", "6\"", 1000) + + assert result["category"] == "PIPE" + assert result["confidence"] > 0.8 + assert result["material_grade"] == "A333-6" + assert result["schedule"] == "SCH40" + assert result["size"] == "6\"" + + def test_pipe_classification_welded(self): + """용접 파이프 분류 테스트""" + from app.services.pipe_classifier import classify_pipe + + result = classify_pipe("", "PIPE, WELDED, A53-B, 4\", SCH40", "4\"", 500) + + assert result["category"] == "PIPE" + assert result["subcategory"] == "WELDED" + assert result["material_grade"] == "A53-B" + assert result["confidence"] > 0.8 + + def test_pipe_classification_low_confidence(self): + """낮은 신뢰도 파이프 분류 테스트""" + from app.services.pipe_classifier import classify_pipe + + # 모호한 설명 + result = classify_pipe("", "STEEL TUBE, 2 INCH", "2\"", 100) + + # 파이프로 분류되지만 신뢰도가 낮아야 함 + assert result["confidence"] < 0.7 + + def test_pipe_length_calculation(self): + """파이프 길이 계산 테스트""" + from app.services.pipe_classifier import classify_pipe + + result = classify_pipe("", "PIPE, SEAMLESS, A333-6, 6\", SCH40", "6\"", 6000) + + assert "length_mm" in result + assert result["length_mm"] == 6000 + assert "purchase_length" in result + + +class TestFittingClassifier: + """피팅 분류기 테스트""" + + def test_elbow_classification(self): + """엘보우 분류 테스트""" + from app.services.fitting_classifier import classify_fitting + + result = classify_fitting("", "ELBOW, 90DEG, A234-WPB, 4\", SCH40") + + assert result["category"] == "FITTING" + assert result["subcategory"] == "ELBOW" + assert result["angle"] == "90DEG" + assert result["material_grade"] == "A234-WPB" + assert result["confidence"] > 0.8 + + def test_tee_classification(self): + """티 분류 테스트""" + from app.services.fitting_classifier import classify_fitting + + result = classify_fitting("", "TEE, EQUAL, A234-WPB, 6\", SCH40") + + assert result["category"] == "FITTING" + assert result["subcategory"] == "TEE" + assert result["fitting_type"] == "EQUAL" + assert result["confidence"] > 0.8 + + def test_reducer_classification(self): + """리듀서 분류 테스트""" + from app.services.fitting_classifier import classify_fitting + + result = classify_fitting("", "REDUCER, CONCENTRIC, A234-WPB, 8\"X6\", SCH40") + + assert result["category"] == "FITTING" + assert result["subcategory"] == "REDUCER" + assert result["fitting_type"] == "CONCENTRIC" + assert "8\"X6\"" in result["size"] + + +class TestValveClassifier: + """밸브 분류기 테스트""" + + def test_gate_valve_classification(self): + """게이트 밸브 분류 테스트""" + from app.services.valve_classifier import classify_valve + + result = classify_valve("", "VALVE, GATE, A216-WCB, 2\", 150LB") + + assert result["category"] == "VALVE" + assert result["subcategory"] == "GATE" + assert result["material_grade"] == "A216-WCB" + assert result["pressure_rating"] == "150LB" + assert result["confidence"] > 0.8 + + def test_ball_valve_classification(self): + """볼 밸브 분류 테스트""" + from app.services.valve_classifier import classify_valve + + result = classify_valve("", "VALVE, BALL, A216-WCB, 4\", 300LB") + + assert result["category"] == "VALVE" + assert result["subcategory"] == "BALL" + assert result["pressure_rating"] == "300LB" + assert result["confidence"] > 0.8 + + def test_check_valve_classification(self): + """체크 밸브 분류 테스트""" + from app.services.valve_classifier import classify_valve + + result = classify_valve("", "VALVE, CHECK, SWING, A216-WCB, 3\", 150LB") + + assert result["category"] == "VALVE" + assert result["subcategory"] == "CHECK" + assert result["valve_type"] == "SWING" + + +class TestFlangeClassifier: + """플랜지 분류기 테스트""" + + def test_weld_neck_flange_classification(self): + """용접목 플랜지 분류 테스트""" + from app.services.flange_classifier import classify_flange + + result = classify_flange("", "FLANGE, WELD NECK, A105, 3\", 150LB") + + assert result["category"] == "FLANGE" + assert result["subcategory"] == "WELD NECK" + assert result["material_grade"] == "A105" + assert result["pressure_rating"] == "150LB" + assert result["confidence"] > 0.8 + + def test_slip_on_flange_classification(self): + """슬립온 플랜지 분류 테스트""" + from app.services.flange_classifier import classify_flange + + result = classify_flange("", "FLANGE, SLIP ON, A105, 4\", 300LB") + + assert result["category"] == "FLANGE" + assert result["subcategory"] == "SLIP ON" + assert result["pressure_rating"] == "300LB" + assert result["confidence"] > 0.8 + + def test_blind_flange_classification(self): + """블라인드 플랜지 분류 테스트""" + from app.services.flange_classifier import classify_flange + + result = classify_flange("", "FLANGE, BLIND, A105, 6\", 150LB") + + assert result["category"] == "FLANGE" + assert result["subcategory"] == "BLIND" + assert result["confidence"] > 0.8 + + +class TestBoltClassifier: + """볼트 분류기 테스트""" + + def test_hex_bolt_classification(self): + """육각 볼트 분류 테스트""" + from app.services.bolt_classifier import classify_bolt + + result = classify_bolt("", "BOLT, HEX HEAD, A193-B7, M16X50") + + assert result["category"] == "BOLT" + assert result["subcategory"] == "HEX HEAD" + assert result["material_grade"] == "A193-B7" + assert result["size"] == "M16X50" + assert result["confidence"] > 0.8 + + def test_stud_bolt_classification(self): + """스터드 볼트 분류 테스트""" + from app.services.bolt_classifier import classify_bolt + + result = classify_bolt("", "STUD BOLT, A193-B7, M20X80") + + assert result["category"] == "BOLT" + assert result["subcategory"] == "STUD" + assert result["material_grade"] == "A193-B7" + assert result["confidence"] > 0.8 + + def test_nut_classification(self): + """너트 분류 테스트""" + from app.services.bolt_classifier import classify_bolt + + result = classify_bolt("", "NUT, HEX, A194-2H, M16") + + assert result["category"] == "BOLT" + assert result["subcategory"] == "NUT" + assert result["material_grade"] == "A194-2H" + assert result["confidence"] > 0.8 + + +class TestGasketClassifier: + """가스켓 분류기 테스트""" + + def test_spiral_wound_gasket_classification(self): + """스파이럴 와운드 가스켓 분류 테스트""" + from app.services.gasket_classifier import classify_gasket + + result = classify_gasket("", "GASKET, SPIRAL WOUND, SS316+GRAPHITE, 4\", 150LB") + + assert result["category"] == "GASKET" + assert result["subcategory"] == "SPIRAL WOUND" + assert "SS316" in result["material_grade"] + assert result["confidence"] > 0.8 + + def test_rtj_gasket_classification(self): + """RTJ 가스켓 분류 테스트""" + from app.services.gasket_classifier import classify_gasket + + result = classify_gasket("", "GASKET, RTJ, SS316, 6\", 300LB") + + assert result["category"] == "GASKET" + assert result["subcategory"] == "RTJ" + assert result["material_grade"] == "SS316" + assert result["confidence"] > 0.8 + + def test_flat_gasket_classification(self): + """플랫 가스켓 분류 테스트""" + from app.services.gasket_classifier import classify_gasket + + result = classify_gasket("", "GASKET, FLAT, RUBBER, 2\", 150LB") + + assert result["category"] == "GASKET" + assert result["subcategory"] == "FLAT" + assert result["material_grade"] == "RUBBER" + assert result["confidence"] > 0.8 + + +class TestInstrumentClassifier: + """계기 분류기 테스트""" + + def test_pressure_gauge_classification(self): + """압력계 분류 테스트""" + from app.services.instrument_classifier import classify_instrument + + result = classify_instrument("", "PRESSURE GAUGE, 0-10 BAR, 1/2\" NPT") + + assert result["category"] == "INSTRUMENT" + assert result["subcategory"] == "PRESSURE GAUGE" + assert "0-10 BAR" in result["range"] + assert result["confidence"] > 0.8 + + def test_temperature_gauge_classification(self): + """온도계 분류 테스트""" + from app.services.instrument_classifier import classify_instrument + + result = classify_instrument("", "TEMPERATURE GAUGE, 0-200°C, 1/2\" NPT") + + assert result["category"] == "INSTRUMENT" + assert result["subcategory"] == "TEMPERATURE GAUGE" + assert "0-200°C" in result["range"] + assert result["confidence"] > 0.8 + + def test_flow_meter_classification(self): + """유량계 분류 테스트""" + from app.services.instrument_classifier import classify_instrument + + result = classify_instrument("", "FLOW METER, ORIFICE, 4\", 150LB") + + assert result["category"] == "INSTRUMENT" + assert result["subcategory"] == "FLOW METER" + assert result["instrument_type"] == "ORIFICE" + assert result["confidence"] > 0.8 + + +class TestIntegratedClassifier: + """통합 분류기 테스트""" + + def test_integrated_classification_pipe(self): + """통합 분류기 파이프 테스트""" + from app.services.integrated_classifier import classify_material_integrated + + result = classify_material_integrated("PIPE, SEAMLESS, A333-6, 6\", SCH40") + + assert result["category"] == "PIPE" + assert result["confidence"] > 0.8 + assert "classification_details" in result + + def test_integrated_classification_valve(self): + """통합 분류기 밸브 테스트""" + from app.services.integrated_classifier import classify_material_integrated + + result = classify_material_integrated("VALVE, GATE, A216-WCB, 2\", 150LB") + + assert result["category"] == "VALVE" + assert result["confidence"] > 0.8 + + def test_exclusion_logic(self): + """제외 로직 테스트""" + from app.services.integrated_classifier import should_exclude_material + + # 제외되어야 하는 항목들 + assert should_exclude_material("INSULATION, MINERAL WOOL") is True + assert should_exclude_material("PAINT, PRIMER") is True + assert should_exclude_material("SUPPORT, STRUCTURAL") is True + + # 제외되지 않아야 하는 항목들 + assert should_exclude_material("PIPE, SEAMLESS, A333-6") is False + assert should_exclude_material("VALVE, GATE, A216-WCB") is False + + def test_confidence_threshold(self): + """신뢰도 임계값 테스트""" + from app.services.integrated_classifier import classify_material_integrated + + # 모호한 설명으로 낮은 신뢰도 테스트 + result = classify_material_integrated("STEEL ITEM, UNKNOWN TYPE") + + # 신뢰도가 낮아야 함 + assert result["confidence"] < 0.5 + assert result["category"] in ["UNKNOWN", "EXCLUDE"] + + +class TestClassificationCaching: + """분류 결과 캐싱 테스트""" + + @patch('app.services.integrated_classifier.tkmp_cache') + def test_classification_cache_hit(self, mock_cache): + """분류 결과 캐시 히트 테스트""" + from app.services.integrated_classifier import classify_material_integrated + + # 캐시에서 결과 반환 설정 + cached_result = { + "category": "PIPE", + "confidence": 0.95, + "cached": True + } + mock_cache.get_classification_result.return_value = cached_result + + result = classify_material_integrated("PIPE, SEAMLESS, A333-6, 6\", SCH40") + + assert result == cached_result + mock_cache.get_classification_result.assert_called_once() + + @patch('app.services.integrated_classifier.tkmp_cache') + def test_classification_cache_miss(self, mock_cache): + """분류 결과 캐시 미스 테스트""" + from app.services.integrated_classifier import classify_material_integrated + + # 캐시에서 None 반환 (캐시 미스) + mock_cache.get_classification_result.return_value = None + + result = classify_material_integrated("PIPE, SEAMLESS, A333-6, 6\", SCH40") + + assert result["category"] == "PIPE" + assert result["confidence"] > 0.8 + + # 캐시 저장 호출 확인 + mock_cache.set_classification_result.assert_called_once() + + +@pytest.mark.performance +class TestClassificationPerformance: + """분류 성능 테스트""" + + def test_classification_speed(self): + """분류 속도 테스트""" + import time + from app.services.integrated_classifier import classify_material_integrated + + descriptions = [ + "PIPE, SEAMLESS, A333-6, 6\", SCH40", + "VALVE, GATE, A216-WCB, 2\", 150LB", + "FLANGE, WELD NECK, A105, 3\", 150LB", + "ELBOW, 90DEG, A234-WPB, 4\", SCH40", + "BOLT, HEX HEAD, A193-B7, M16X50" + ] + + start_time = time.time() + + for desc in descriptions: + result = classify_material_integrated(desc) + assert result["category"] != "UNKNOWN" + + end_time = time.time() + total_time = end_time - start_time + + # 5개 항목을 1초 이내에 분류해야 함 + assert total_time < 1.0 + + # 평균 분류 시간이 100ms 이하여야 함 + avg_time = total_time / len(descriptions) + assert avg_time < 0.1 + + def test_batch_classification(self): + """배치 분류 테스트""" + from app.services.integrated_classifier import classify_material_integrated + + descriptions = [ + "PIPE, SEAMLESS, A333-6, 6\", SCH40", + "VALVE, GATE, A216-WCB, 2\", 150LB", + "FLANGE, WELD NECK, A105, 3\", 150LB" + ] * 10 # 30개 항목 + + results = [] + for desc in descriptions: + result = classify_material_integrated(desc) + results.append(result) + + # 모든 결과가 올바르게 분류되었는지 확인 + assert len(results) == 30 + + # 각 타입별로 올바르게 분류되었는지 확인 + pipe_results = [r for r in results if r["category"] == "PIPE"] + valve_results = [r for r in results if r["category"] == "VALVE"] + flange_results = [r for r in results if r["category"] == "FLANGE"] + + assert len(pipe_results) == 10 + assert len(valve_results) == 10 + assert len(flange_results) == 10 diff --git a/backend/tests/test_file_management.py b/backend/tests/test_file_management.py new file mode 100644 index 0000000..dd8b5e5 --- /dev/null +++ b/backend/tests/test_file_management.py @@ -0,0 +1,267 @@ +""" +파일 관리 API 테스트 +""" +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock + + +class TestFileManagementAPI: + """파일 관리 API 테스트 클래스""" + + def test_get_files_empty_list(self, client: TestClient): + """빈 파일 목록 조회 테스트""" + response = client.get("/files") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["total_count"] == 0 + assert data["data"] == [] + assert data["cache_hit"] is False + + def test_get_files_with_job_no(self, client: TestClient): + """특정 작업 번호로 파일 조회 테스트""" + response = client.get("/files?job_no=TEST-001") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "data" in data + assert "total_count" in data + + def test_get_files_with_history(self, client: TestClient): + """이력 포함 파일 조회 테스트""" + response = client.get("/files?show_history=true") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + @patch('app.api.file_management.tkmp_cache') + def test_get_files_cache_hit(self, mock_cache, client: TestClient): + """캐시 히트 테스트""" + # 캐시에서 데이터 반환 설정 + mock_cache.get_file_list.return_value = [ + { + "id": 1, + "filename": "test.xlsx", + "original_filename": "test.xlsx", + "job_no": "TEST-001", + "status": "active" + } + ] + + response = client.get("/files?job_no=TEST-001") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["cache_hit"] is True + assert len(data["data"]) == 1 + + # 캐시 호출 확인 + mock_cache.get_file_list.assert_called_once_with("TEST-001", False) + + def test_get_files_no_cache(self, client: TestClient): + """캐시 사용 안 함 테스트""" + response = client.get("/files?use_cache=false") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["cache_hit"] is False + + def test_delete_file_not_found(self, client: TestClient): + """존재하지 않는 파일 삭제 테스트""" + response = client.delete("/files/999") + + # 파일이 없어도 에러 응답이 아닌 정상 응답 구조를 가져야 함 + assert response.status_code == 200 + data = response.json() + # 에러 케이스이므로 error 키가 있을 수 있음 + assert "error" in data or "success" in data + + @patch('app.api.file_management.tkmp_cache') + def test_delete_file_cache_invalidation(self, mock_cache, client: TestClient, db_session): + """파일 삭제 시 캐시 무효화 테스트""" + # 실제 파일 데이터가 없으므로 mock으로 처리 + with patch('app.api.file_management.db') as mock_db: + mock_result = MagicMock() + mock_result.fetchone.return_value = MagicMock( + id=1, + original_filename="test.xlsx", + job_no="TEST-001" + ) + mock_db.execute.return_value = mock_result + + response = client.delete("/files/1") + + # 캐시 무효화 호출 확인 + mock_cache.invalidate_file_cache.assert_called_once_with(1) + mock_cache.invalidate_job_cache.assert_called_once_with("TEST-001") + + +class TestFileValidation: + """파일 검증 테스트""" + + def test_file_extension_validation(self): + """파일 확장자 검증 테스트""" + from app.utils.file_validator import file_validator + + # 허용된 확장자 + assert file_validator.validate_file_extension("test.xlsx") is True + assert file_validator.validate_file_extension("test.xls") is True + assert file_validator.validate_file_extension("test.csv") is True + + # 허용되지 않은 확장자 + assert file_validator.validate_file_extension("test.txt") is False + assert file_validator.validate_file_extension("test.pdf") is False + assert file_validator.validate_file_extension("test.exe") is False + + def test_file_size_validation(self): + """파일 크기 검증 테스트""" + from app.utils.file_validator import file_validator + + # 허용된 크기 (50MB 이하) + assert file_validator.validate_file_size(1024) is True # 1KB + assert file_validator.validate_file_size(10 * 1024 * 1024) is True # 10MB + assert file_validator.validate_file_size(50 * 1024 * 1024) is True # 50MB + + # 허용되지 않은 크기 (50MB 초과) + assert file_validator.validate_file_size(51 * 1024 * 1024) is False # 51MB + assert file_validator.validate_file_size(100 * 1024 * 1024) is False # 100MB + + def test_filename_validation(self): + """파일명 검증 테스트""" + from app.utils.file_validator import file_validator + + # 안전한 파일명 + assert file_validator.validate_filename("test.xlsx") is True + assert file_validator.validate_filename("BOM_Rev1.xlsx") is True + assert file_validator.validate_filename("자재목록_2025.csv") is True + + # 위험한 파일명 + assert file_validator.validate_filename("../test.xlsx") is False + assert file_validator.validate_filename("test/file.xlsx") is False + assert file_validator.validate_filename("test:file.xlsx") is False + assert file_validator.validate_filename("test*file.xlsx") is False + + def test_filename_sanitization(self): + """파일명 정화 테스트""" + from app.utils.file_validator import file_validator + + # 위험한 문자 제거 + assert file_validator.sanitize_filename("../test.xlsx") == "__test.xlsx" + assert file_validator.sanitize_filename("test/file.xlsx") == "test_file.xlsx" + assert file_validator.sanitize_filename("test:file*.xlsx") == "test_file_.xlsx" + + # 연속된 언더스코어 제거 + assert file_validator.sanitize_filename("test__file.xlsx") == "test_file.xlsx" + + # 앞뒤 공백 및 점 제거 + assert file_validator.sanitize_filename(" test.xlsx ") == "test.xlsx" + assert file_validator.sanitize_filename(".test.xlsx.") == "test.xlsx" + + +class TestCacheManager: + """캐시 매니저 테스트""" + + @patch('app.utils.cache_manager.redis') + def test_cache_set_get(self, mock_redis): + """캐시 저장/조회 테스트""" + from app.utils.cache_manager import CacheManager + + # Redis 클라이언트 mock 설정 + mock_client = MagicMock() + mock_redis.from_url.return_value = mock_client + + cache_manager = CacheManager() + + # 데이터 저장 + test_data = {"test": "data"} + result = cache_manager.set("test_key", test_data, 3600) + + # Redis 호출 확인 + mock_client.setex.assert_called_once() + + def test_tkmp_cache_file_list(self): + """TK-MP 캐시 파일 목록 테스트""" + from app.utils.cache_manager import TKMPCache + + with patch('app.utils.cache_manager.CacheManager') as mock_cache_manager: + mock_cache = MagicMock() + mock_cache_manager.return_value = mock_cache + + tkmp_cache = TKMPCache() + + # 파일 목록 캐시 저장 + files = [{"id": 1, "name": "test.xlsx"}] + tkmp_cache.set_file_list(files, "TEST-001", False) + + # 캐시 호출 확인 + mock_cache.set.assert_called_once() + + # 파일 목록 캐시 조회 + mock_cache.get.return_value = files + result = tkmp_cache.get_file_list("TEST-001", False) + + assert result == files + mock_cache.get.assert_called_once() + + +@pytest.mark.asyncio +class TestFileProcessor: + """파일 프로세서 테스트""" + + def test_file_info_analysis(self, sample_excel_file): + """파일 정보 분석 테스트""" + from app.utils.file_processor import file_processor + + info = file_processor.get_file_info(sample_excel_file) + + assert "file_path" in info + assert "file_size" in info + assert "file_extension" in info + assert info["file_extension"] == ".xlsx" + assert info["file_type"] == "excel" + assert "recommended_chunk_size" in info + + def test_dataframe_memory_optimization(self): + """DataFrame 메모리 최적화 테스트""" + import pandas as pd + from app.utils.file_processor import file_processor + + # 테스트 데이터 생성 + df = pd.DataFrame({ + 'int_col': [1, 2, 3, 4, 5], + 'float_col': [1.1, 2.2, 3.3, 4.4, 5.5], + 'str_col': ['A', 'B', 'A', 'B', 'A'] + }) + + original_memory = df.memory_usage(deep=True).sum() + optimized_df = file_processor.optimize_dataframe_memory(df) + optimized_memory = optimized_df.memory_usage(deep=True).sum() + + # 메모리 사용량이 감소했거나 같아야 함 + assert optimized_memory <= original_memory + + # 데이터 무결성 확인 + assert len(optimized_df) == len(df) + assert list(optimized_df.columns) == list(df.columns) + + def test_optimal_chunk_size_calculation(self): + """최적 청크 크기 계산 테스트""" + from app.utils.file_processor import file_processor + + # 작은 파일 (1MB 미만) + chunk_size = file_processor._calculate_optimal_chunk_size(500 * 1024) # 500KB + assert chunk_size == 500 + + # 중간 파일 (10MB 미만) + chunk_size = file_processor._calculate_optimal_chunk_size(5 * 1024 * 1024) # 5MB + assert chunk_size == 1000 + + # 큰 파일 (50MB 이상) + chunk_size = file_processor._calculate_optimal_chunk_size(100 * 1024 * 1024) # 100MB + assert chunk_size == 5000 diff --git a/deploy-synology.sh b/deploy-synology.sh new file mode 100755 index 0000000..d38017b --- /dev/null +++ b/deploy-synology.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# TK-MP-Project 시놀로지 배포 스크립트 +# 사용법: ./deploy-synology.sh [NAS_IP] [SSH_PORT] + +NAS_IP=${1:-"192.168.0.3"} +SSH_PORT=${2:-"22"} +NAS_USER="admin" +PROJECT_NAME="tk-mp-project" +REMOTE_PATH="/volume1/docker/${PROJECT_NAME}" + +echo "🚀 TK-MP-Project 시놀로지 배포 시작..." +echo "📡 대상 NAS: ${NAS_IP}:${SSH_PORT}" + +# 1. 프로젝트 압축 +echo "📦 프로젝트 압축 중..." +tar -czf ${PROJECT_NAME}.tar.gz \ + --exclude='node_modules' \ + --exclude='venv' \ + --exclude='*.pyc' \ + --exclude='__pycache__' \ + --exclude='.git' \ + --exclude='uploads' \ + . + +# 2. NAS로 파일 전송 +echo "📤 NAS로 파일 전송 중..." +scp -P ${SSH_PORT} ${PROJECT_NAME}.tar.gz ${NAS_USER}@${NAS_IP}:/tmp/ + +# 3. NAS에서 배포 실행 +echo "🔧 NAS에서 배포 실행 중..." +ssh -p ${SSH_PORT} ${NAS_USER}@${NAS_IP} << EOF + # 디렉토리 생성 + sudo mkdir -p ${REMOTE_PATH} + cd ${REMOTE_PATH} + + # 기존 컨테이너 정지 및 제거 + sudo docker-compose -f docker-compose.synology.yml down || true + + # 프로젝트 압축 해제 + sudo tar -xzf /tmp/${PROJECT_NAME}.tar.gz -C ${REMOTE_PATH} + sudo rm /tmp/${PROJECT_NAME}.tar.gz + + # Docker 이미지 빌드 및 실행 + sudo docker-compose -f docker-compose.synology.yml build + sudo docker-compose -f docker-compose.synology.yml up -d + + echo "✅ 배포 완료!" + echo "🌐 프론트엔드: http://${NAS_IP}:10173" + echo "🔧 백엔드 API: http://${NAS_IP}:10080" + echo "🗄️ 데이터베이스: ${NAS_IP}:15432" + echo "🔄 Redis: ${NAS_IP}:16379" +EOF + +# 4. 로컬 임시 파일 정리 +rm ${PROJECT_NAME}.tar.gz + +echo "🎉 배포가 완료되었습니다!" +echo "" +echo "📋 서비스 URL:" +echo " 프론트엔드: http://${NAS_IP}:10173" +echo " 백엔드 API: http://${NAS_IP}:10080/docs" +echo "" +echo "🔍 컨테이너 상태 확인:" +echo " ssh -p ${SSH_PORT} ${NAS_USER}@${NAS_IP} 'sudo docker ps'" \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a092ebe..d641d91 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,10 +4,10 @@ version: '3.8' services: frontend: environment: - - VITE_API_URL=http://localhost:8000 + - VITE_API_URL=http://localhost:18000 build: args: - - VITE_API_URL=http://localhost:8000 + - VITE_API_URL=http://localhost:18000 backend: volumes: diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml new file mode 100644 index 0000000..d4b6921 --- /dev/null +++ b/docker-compose.synology.yml @@ -0,0 +1,76 @@ +version: '3.8' + +services: + # PostgreSQL 데이터베이스 + tk-mp-postgres: + image: postgres:15-alpine + container_name: tk-mp-postgres + restart: unless-stopped + environment: + POSTGRES_DB: tk_mp_bom + POSTGRES_USER: tkmp_user + POSTGRES_PASSWORD: tkmp_password_2025 + POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + ports: + - "15432:5432" + volumes: + - tk_mp_postgres_data:/var/lib/postgresql/data + - ./database/init:/docker-entrypoint-initdb.d + networks: + - tk-mp-network + + # Redis (캐시 및 세션 관리용) + tk-mp-redis: + image: redis:7-alpine + container_name: tk-mp-redis + restart: unless-stopped + ports: + - "16379:6379" + volumes: + - tk_mp_redis_data:/data + networks: + - tk-mp-network + + # 백엔드 FastAPI 서비스 + tk-mp-backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: tk-mp-backend + restart: unless-stopped + ports: + - "10080:10080" + environment: + - DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@tk-mp-postgres:5432/tk_mp_bom + - REDIS_URL=redis://tk-mp-redis:6379 + - PYTHONPATH=/app + depends_on: + - tk-mp-postgres + - tk-mp-redis + networks: + - tk-mp-network + volumes: + - tk_mp_uploads:/app/uploads + + # 프론트엔드 Nginx 서비스 + tk-mp-frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: tk-mp-frontend + restart: unless-stopped + ports: + - "10173:10173" + depends_on: + - tk-mp-backend + networks: + - tk-mp-network + +volumes: + tk_mp_postgres_data: + tk_mp_redis_data: + tk_mp_uploads: + +networks: + tk-mp-network: + driver: bridge \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index fd10111..840c6e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,7 @@ services: container_name: tk-mp-backend restart: unless-stopped ports: - - "8000:8000" + - "18000:8000" environment: - DATABASE_URL=postgresql://tkmp_user:tkmp_password_2025@postgres:5432/tk_mp_bom - REDIS_URL=redis://redis:6379 @@ -79,7 +79,7 @@ services: container_name: tk-mp-frontend restart: unless-stopped ports: - - "3000:3000" + - "13000:3000" environment: - VITE_API_URL=${VITE_API_URL:-/api} depends_on: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1d76bfb --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,18 @@ +node_modules +.git +.gitignore +README.md +.env +.env.local +.env.production +.env.development +dist +coverage +.nyc_output +.cache +.vscode +.idea +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf index b6ab2bf..55fa86c 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -11,7 +11,7 @@ server { # API 프록시 설정 location /api/ { - proxy_pass http://tk-mp-backend:8000/; + proxy_pass http://backend:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..90c0c6c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,42 +1,249 @@ +/* 전역 스타일 리셋 */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #2d3748; + background-color: #f7fafc; +} + #root { - max-width: 1280px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.main-content { + flex: 1; + padding: 0; + background: #f7fafc; +} + +.page-container { + max-width: 1400px; margin: 0 auto; - padding: 2rem; + padding: 24px; +} + +/* 로딩 스피너 */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f7fafc; + color: #718096; +} + +.loading-spinner-large { + width: 48px; + height: 48px; + border: 4px solid #e2e8f0; + border-top: 4px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #e2e8f0; + border-top: 2px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 접근 거부 페이지 */ +.access-denied-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f7fafc; + padding: 24px; +} + +.access-denied-content { text-align: center; + max-width: 500px; + background: white; + padding: 48px 32px; + border-radius: 16px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.access-denied-icon { + font-size: 64px; + margin-bottom: 24px; } -@keyframes logo-spin { - from { - transform: rotate(0deg); +.access-denied-content h2 { + color: #2d3748; + font-size: 24px; + font-weight: 700; + margin-bottom: 16px; +} + +.access-denied-content p { + color: #718096; + font-size: 16px; + margin-bottom: 16px; + line-height: 1.6; +} + +.permission-info, +.role-info { + background: #f7fafc; + padding: 12px 16px; + border-radius: 8px; + margin: 16px 0; + font-size: 14px; +} + +.permission-info code, +.role-info code { + background: #e2e8f0; + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 13px; + color: #2d3748; +} + +.user-info { + background: #edf2f7; + padding: 16px; + border-radius: 8px; + margin: 20px 0; + text-align: left; +} + +.user-info p { + margin-bottom: 8px; + font-size: 14px; +} + +.user-info strong { + color: #2d3748; +} + +.back-button { + padding: 12px 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + margin-top: 24px; +} + +.back-button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3); +} + +/* 유틸리티 클래스 */ +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.mb-0 { margin-bottom: 0; } +.mb-1 { margin-bottom: 8px; } +.mb-2 { margin-bottom: 16px; } +.mb-3 { margin-bottom: 24px; } +.mb-4 { margin-bottom: 32px; } + +.mt-0 { margin-top: 0; } +.mt-1 { margin-top: 8px; } +.mt-2 { margin-top: 16px; } +.mt-3 { margin-top: 24px; } +.mt-4 { margin-top: 32px; } + +.p-0 { padding: 0; } +.p-1 { padding: 8px; } +.p-2 { padding: 16px; } +.p-3 { padding: 24px; } +.p-4 { padding: 32px; } + +/* 반응형 유틸리티 */ +.hidden-mobile { + display: block; +} + +.hidden-desktop { + display: none; +} + +@media (max-width: 768px) { + .hidden-mobile { + display: none; } - to { - transform: rotate(360deg); + + .hidden-desktop { + display: block; + } + + .page-container { + padding: 16px; } } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +/* 스크롤바 스타일링 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; } -.card { - padding: 2em; +::-webkit-scrollbar-track { + background: #f1f5f9; } -.read-the-docs { - color: #888; +::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 4px; } + +::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +/* 포커스 스타일 */ +*:focus { + outline: 2px solid #667eea; + outline-offset: 2px; +} + +button:focus, +input:focus, +select:focus, +textarea:focus { + outline: 2px solid #667eea; + outline-offset: 2px; +} + +/* 선택 스타일 */ +::selection { + background: rgba(102, 126, 234, 0.2); + color: #2d3748; +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b3285af..4dab90c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,26 +1,204 @@ -import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import ProjectSelectionPage from './pages/ProjectSelectionPage'; -import MaterialsPage from './pages/MaterialsPage'; +import React, { useState, useEffect } from 'react'; +import SimpleLogin from './SimpleLogin'; +import NavigationMenu from './components/NavigationMenu'; +import DashboardPage from './pages/DashboardPage'; +import ProjectsPage from './pages/ProjectsPage'; import BOMStatusPage from './pages/BOMStatusPage'; -import PurchaseConfirmationPage from './pages/PurchaseConfirmationPage'; +import SimpleMaterialsPage from './pages/SimpleMaterialsPage'; import MaterialComparisonPage from './pages/MaterialComparisonPage'; import RevisionPurchasePage from './pages/RevisionPurchasePage'; +import JobSelectionPage from './pages/JobSelectionPage'; +import './App.css'; function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [user, setUser] = useState(null); + const [currentPage, setCurrentPage] = useState('dashboard'); + const [pageParams, setPageParams] = useState({}); + + useEffect(() => { + // 저장된 토큰 확인 + const token = localStorage.getItem('access_token'); + const userData = localStorage.getItem('user_data'); + + if (token && userData) { + setIsAuthenticated(true); + setUser(JSON.parse(userData)); + } + + setIsLoading(false); + }, []); + + // 로그인 성공 시 호출될 함수 + const handleLoginSuccess = () => { + const userData = localStorage.getItem('user_data'); + if (userData) { + setUser(JSON.parse(userData)); + } + setIsAuthenticated(true); + }; + + // 로그아웃 함수 + const handleLogout = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('user_data'); + setIsAuthenticated(false); + setUser(null); + setCurrentPage('dashboard'); + }; + + // 페이지 네비게이션 함수 + const navigateToPage = (page, params = {}) => { + setCurrentPage(page); + setPageParams(params); + }; + + // 페이지 렌더링 함수 + const renderCurrentPage = () => { + switch (currentPage) { + case 'dashboard': + return ; + case 'projects': + return ; + case 'bom': + return + navigateToPage('bom-status', { job_no: jobNo, job_name: jobName }) + } />; + case 'bom-status': + return ; + case 'materials': + return ; + case 'material-comparison': + return ; + case 'revision-purchase': + return ; + case 'quotes': + return
💰 견적 관리 페이지 (곧 구현 예정)
; + case 'procurement': + return
🛒 구매 관리 페이지 (곧 구현 예정)
; + case 'production': + return
🏭 생산 관리 페이지 (곧 구현 예정)
; + case 'shipment': + return
🚚 출하 관리 페이지 (곧 구현 예정)
; + case 'users': + return
👥 사용자 관리 페이지 (곧 구현 예정)
; + case 'system': + return
⚙️ 시스템 설정 페이지 (곧 구현 예정)
; + default: + return ; + } + }; + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + return ( - - - } /> - {/* BOM 관리는 /bom-status로 통일 */} - } /> - } /> - } /> - } /> - } /> - } /> - - +
+ navigateToPage(page, {})} + /> + + {/* 메인 콘텐츠 영역 */} +
+ {/* 상단 헤더 */} +
+
+

+ {currentPage === 'dashboard' && '대시보드'} + {currentPage === 'projects' && '프로젝트 관리'} + {currentPage === 'bom' && 'BOM 관리'} + {currentPage === 'materials' && '자재 관리'} + {currentPage === 'quotes' && '견적 관리'} + {currentPage === 'procurement' && '구매 관리'} + {currentPage === 'production' && '생산 관리'} + {currentPage === 'shipment' && '출하 관리'} + {currentPage === 'users' && '사용자 관리'} + {currentPage === 'system' && '시스템 설정'} +

+
+ + +
+ + {/* 페이지 콘텐츠 */} +
+ {renderCurrentPage()} +
+
+
); } diff --git a/frontend/src/SimpleDashboard.jsx b/frontend/src/SimpleDashboard.jsx new file mode 100644 index 0000000..15c3b4d --- /dev/null +++ b/frontend/src/SimpleDashboard.jsx @@ -0,0 +1,220 @@ +import React, { useState, useEffect } from 'react'; + +const SimpleDashboard = () => { + const [user, setUser] = useState(null); + + useEffect(() => { + // 저장된 사용자 정보 불러오기 + const userData = localStorage.getItem('user_data'); + if (userData) { + setUser(JSON.parse(userData)); + } + }, []); + + const handleLogout = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('user_data'); + window.location.reload(); // 페이지 새로고침으로 로그인 페이지로 돌아가기 + }; + + if (!user) { + return ( +
+
사용자 정보를 불러오는 중...
+
+ ); + } + + return ( +
+ {/* 네비게이션 바 */} + + + {/* 메인 콘텐츠 */} +
+
+ {/* 환영 메시지 */} +
+

+ 환영합니다, {user.name}님! 🎉 +

+

+ TK-MP 통합 프로젝트 관리 시스템에 성공적으로 로그인하셨습니다. +

+
+ + {/* 사용자 정보 카드 */} +
+

+ 👤 사용자 정보 +

+ +
+
+ 사용자명: +
{user.username}
+
+
+ 이메일: +
{user.email}
+
+
+ 역할: +
{user.role}
+
+
+ 접근 레벨: +
{user.access_level}
+
+ {user.department && ( +
+ 부서: +
{user.department}
+
+ )} + {user.position && ( +
+ 직책: +
{user.position}
+
+ )} +
+
+ + {/* 권한 정보 */} +
+

+ 🔐 보유 권한 +

+ +
+ {user.permissions && user.permissions.length > 0 ? ( + user.permissions.map(permission => ( + + {permission} + + )) + ) : ( + 권한 정보가 없습니다. + )} +
+
+ + {/* 다음 단계 안내 */} +
+

+ 🚀 다음 단계 +

+

+ 이제 복잡한 인증 시스템과 네비게이션을 단계적으로 추가할 준비가 되었습니다! +

+
+
+
+
+ ); +}; + +export default SimpleDashboard; diff --git a/frontend/src/SimpleLogin.jsx b/frontend/src/SimpleLogin.jsx new file mode 100644 index 0000000..3ccde25 --- /dev/null +++ b/frontend/src/SimpleLogin.jsx @@ -0,0 +1,212 @@ +import React, { useState } from 'react'; +import api from './api'; + +const SimpleLogin = ({ onLoginSuccess }) => { + const [formData, setFormData] = useState({ + username: '', + password: '' + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + if (error) setError(''); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.username || !formData.password) { + setError('사용자명과 비밀번호를 입력해주세요.'); + return; + } + + setIsLoading(true); + setError(''); + + try { + const response = await api.post('/auth/login', formData); + const data = response.data; + + if (data.success) { + // 토큰과 사용자 정보 저장 + localStorage.setItem('access_token', data.access_token); + localStorage.setItem('user_data', JSON.stringify(data.user)); + + setSuccess('로그인 성공! 대시보드로 이동합니다...'); + + // 잠깐 성공 메시지 보여준 후 대시보드로 이동 + setTimeout(() => { + if (onLoginSuccess) { + onLoginSuccess(); + } + }, 1000); + } else { + setError(data.error?.message || '로그인에 실패했습니다.'); + } + } catch (err) { + console.error('Login error:', err); + setError(err.response?.data?.message || '서버 연결에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ 🚀 TK-MP System +

+

+ 통합 프로젝트 관리 시스템 +

+
+ +
+
+ + +
+ +
+ + +
+ + {error && ( +
+ ⚠️ + {error} +
+ )} + + {success && ( +
+ ✅ {success} +
+ )} + + +
+ +
+

+ 테스트 계정: admin / admin123 또는 testuser / test123 +

+
+ + TK-MP Project Management System v2.0 + +
+
+
+
+ ); +}; + +export default SimpleLogin; diff --git a/frontend/src/TestApp.jsx b/frontend/src/TestApp.jsx new file mode 100644 index 0000000..e9d5daa --- /dev/null +++ b/frontend/src/TestApp.jsx @@ -0,0 +1,43 @@ +import React from 'react'; + +const TestApp = () => { + return ( +
+
+

🚀 TK-MP System

+

시스템이 정상적으로 작동 중입니다!

+
+ +
+
+
+ ); +}; + +export default TestApp; diff --git a/frontend/src/api.js b/frontend/src/api.js index 80508f9..0509096 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -173,4 +173,7 @@ export function getMaterialPurchaseStatus(jobNo, revision = null, status = null) return api.get('/materials/purchase-status', { params: { job_no: jobNo, revision, status } }); -} \ No newline at end of file +} + +// Default export for convenience +export default api; \ No newline at end of file diff --git a/frontend/src/components/BOMFileTable.jsx b/frontend/src/components/BOMFileTable.jsx new file mode 100644 index 0000000..9c880da --- /dev/null +++ b/frontend/src/components/BOMFileTable.jsx @@ -0,0 +1,157 @@ +import React from 'react'; + +const BOMFileTable = ({ + files, + loading, + groupFilesByBOM, + handleViewMaterials, + openRevisionDialog, + handleDelete +}) => { + if (loading) { + return ( +
+ 로딩 중... +
+ ); + } + + if (files.length === 0) { + return ( +
+ 업로드된 BOM이 없습니다. +
+ ); + } + + return ( +
+ + + + + + + + + + + + + {Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => ( + bomFiles.map((file, index) => ( + + + + + + + + + )) + ))} + +
BOM 이름파일명리비전자재 수업로드 일시작업
+
+ {file.bom_name || bomKey} +
+ {index === 0 && bomFiles.length > 1 && ( +
+ 최신 리비전 (총 {bomFiles.length}개) +
+ )} +
+ {file.original_filename || file.filename} + + + {file.revision || 'Rev.0'} + + + {file.parsed_count || file.material_count || 0}개 + + {file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'} + +
+ + + {index === 0 && ( + + )} + + +
+
+
+ ); +}; + +export default BOMFileTable; diff --git a/frontend/src/components/BOMFileUpload.jsx b/frontend/src/components/BOMFileUpload.jsx new file mode 100644 index 0000000..80b8149 --- /dev/null +++ b/frontend/src/components/BOMFileUpload.jsx @@ -0,0 +1,118 @@ +import React from 'react'; + +const BOMFileUpload = ({ + bomName, + setBomName, + selectedFile, + setSelectedFile, + uploading, + handleUpload, + error +}) => { + return ( +
+

+ 새 BOM 업로드 +

+ +
+ + setBomName(e.target.value)} + placeholder="예: PIPING_BOM_A구역" + style={{ + width: '100%', + padding: '12px', + border: '1px solid #e2e8f0', + borderRadius: '8px', + fontSize: '14px' + }} + /> +

+ 동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다 +

+
+ +
+ setSelectedFile(e.target.files[0])} + style={{ flex: 1 }} + /> + +
+ + {selectedFile && ( +

+ 선택된 파일: {selectedFile.name} +

+ )} + + {error && ( +
+ {error} +
+ )} +
+ ); +}; + +export default BOMFileUpload; diff --git a/frontend/src/components/NavigationBar.css b/frontend/src/components/NavigationBar.css new file mode 100644 index 0000000..311efaa --- /dev/null +++ b/frontend/src/components/NavigationBar.css @@ -0,0 +1,537 @@ +.navigation-bar { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-container { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + height: 70px; +} + +/* 브랜드 로고 */ +.nav-brand { + display: flex; + align-items: center; + gap: 12px; + color: white; + text-decoration: none; +} + +.brand-logo { + font-size: 32px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.brand-text h1 { + font-size: 20px; + font-weight: 700; + margin: 0; + line-height: 1.2; +} + +.brand-text span { + font-size: 12px; + opacity: 0.9; + display: block; + line-height: 1; +} + +/* 모바일 메뉴 토글 */ +.mobile-menu-toggle { + display: none; + flex-direction: column; + background: none; + border: none; + cursor: pointer; + padding: 8px; + gap: 4px; +} + +.mobile-menu-toggle span { + width: 24px; + height: 3px; + background: white; + border-radius: 2px; + transition: all 0.3s ease; +} + +/* 메인 메뉴 */ +.nav-menu { + display: flex; + align-items: center; + gap: 24px; + flex: 1; + justify-content: center; +} + +.menu-items { + display: flex; + align-items: center; + gap: 8px; +} + +.menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: none; + border: none; + color: white; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-radius: 8px; + transition: all 0.2s ease; + position: relative; + white-space: nowrap; +} + +.menu-item:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); +} + +.menu-item.active { + background: rgba(255, 255, 255, 0.2); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.menu-item.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 50%; + transform: translateX(-50%); + width: 20px; + height: 3px; + background: white; + border-radius: 2px; +} + +.menu-icon { + font-size: 16px; +} + +.menu-label { + font-weight: 600; +} + +.admin-badge { + background: rgba(255, 255, 255, 0.2); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + font-weight: 600; + text-transform: uppercase; +} + +/* 사용자 메뉴 */ +.user-menu-container { + position: relative; +} + +.user-menu-trigger { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 12px; + color: white; + cursor: pointer; + transition: all 0.2s ease; +} + +.user-menu-trigger:hover { + background: rgba(255, 255, 255, 0.2); +} + +.user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; +} + +.user-info { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; +} + +.user-name { + font-size: 14px; + font-weight: 600; + line-height: 1.2; +} + +.user-role { + font-size: 11px; + opacity: 0.9; + line-height: 1; +} + +.dropdown-arrow { + font-size: 10px; + transition: transform 0.2s ease; +} + +.user-menu-trigger:hover .dropdown-arrow { + transform: rotate(180deg); +} + +/* 사용자 드롭다운 */ +.user-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 320px; + background: white; + border-radius: 16px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); + overflow: hidden; + animation: dropdownSlide 0.3s ease-out; + z-index: 1000; +} + +@keyframes dropdownSlide { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.user-dropdown-header { + padding: 24px; + background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%); + display: flex; + gap: 16px; + align-items: flex-start; +} + +.user-avatar-large { + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 18px; + color: white; + flex-shrink: 0; +} + +.user-details { + flex: 1; + min-width: 0; +} + +.user-details .user-name { + font-size: 16px; + font-weight: 700; + color: #2d3748; + margin-bottom: 4px; +} + +.user-username { + font-size: 14px; + color: #718096; + margin-bottom: 4px; +} + +.user-email { + font-size: 13px; + color: #4a5568; + margin-bottom: 8px; + word-break: break-all; +} + +.user-meta { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.role-badge, +.access-badge { + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.role-badge { background: #bee3f8; color: #2b6cb0; } +.access-badge { background: #c6f6d5; color: #2f855a; } + +.user-department { + font-size: 12px; + color: #718096; + font-style: italic; +} + +.user-dropdown-menu { + padding: 8px 0; +} + +.dropdown-item { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: none; + border: none; + color: #4a5568; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.dropdown-item:hover { + background: #f7fafc; + color: #2d3748; +} + +.dropdown-item.logout-item { + color: #e53e3e; +} + +.dropdown-item.logout-item:hover { + background: #fed7d7; + color: #c53030; +} + +.item-icon { + font-size: 16px; + width: 20px; + text-align: center; +} + +.dropdown-divider { + height: 1px; + background: #e2e8f0; + margin: 8px 0; +} + +.user-dropdown-footer { + padding: 16px 24px; + background: #f7fafc; + border-top: 1px solid #e2e8f0; +} + +.permissions-info { + display: flex; + flex-direction: column; + gap: 8px; +} + +.permissions-label { + font-size: 12px; + font-weight: 600; + color: #718096; + text-transform: uppercase; +} + +.permissions-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.permission-tag { + padding: 2px 6px; + background: #e2e8f0; + color: #4a5568; + font-size: 10px; + border-radius: 8px; + font-weight: 500; +} + +.permission-more { + padding: 2px 6px; + background: #cbd5e0; + color: #2d3748; + font-size: 10px; + border-radius: 8px; + font-weight: 600; +} + +/* 모바일 오버레이 */ +.mobile-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; +} + +/* 반응형 디자인 */ +@media (max-width: 1024px) { + .menu-items { + gap: 4px; + } + + .menu-item { + padding: 8px 12px; + font-size: 13px; + } + + .menu-label { + display: none; + } + + .menu-icon { + font-size: 18px; + } +} + +@media (max-width: 768px) { + .nav-container { + padding: 0 16px; + height: 60px; + } + + .brand-text h1 { + font-size: 18px; + } + + .brand-text span { + font-size: 11px; + } + + .mobile-menu-toggle { + display: flex; + } + + .nav-menu { + position: fixed; + top: 60px; + left: 0; + right: 0; + background: white; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-100%); + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; + } + + .nav-menu.mobile-open { + transform: translateY(0); + opacity: 1; + visibility: visible; + } + + .mobile-overlay { + display: block; + } + + .menu-items { + flex-direction: column; + gap: 8px; + width: 100%; + margin-bottom: 16px; + } + + .menu-item { + width: 100%; + justify-content: flex-start; + padding: 16px; + color: #2d3748; + border-radius: 12px; + background: #f7fafc; + } + + .menu-item:hover { + background: #edf2f7; + transform: none; + } + + .menu-item.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + + .menu-label { + display: block; + } + + .user-menu-container { + width: 100%; + } + + .user-menu-trigger { + width: 100%; + justify-content: flex-start; + background: #f7fafc; + color: #2d3748; + border-radius: 12px; + padding: 16px; + } + + .user-avatar { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + + .user-dropdown { + position: static; + width: 100%; + margin-top: 8px; + box-shadow: none; + border: 1px solid #e2e8f0; + } +} + +@media (max-width: 480px) { + .nav-container { + padding: 0 12px; + } + + .brand-text h1 { + font-size: 16px; + } + + .user-dropdown { + width: calc(100vw - 24px); + left: 12px; + right: 12px; + } +} diff --git a/frontend/src/components/NavigationBar.jsx b/frontend/src/components/NavigationBar.jsx new file mode 100644 index 0000000..087c7da --- /dev/null +++ b/frontend/src/components/NavigationBar.jsx @@ -0,0 +1,270 @@ +import React, { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import './NavigationBar.css'; + +const NavigationBar = ({ currentPage, onNavigate }) => { + const { user, logout, hasPermission, isAdmin, isManager } = useAuth(); + const [showUserMenu, setShowUserMenu] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + + // 메뉴 항목 정의 (권한별) + const menuItems = [ + { + id: 'dashboard', + label: '대시보드', + icon: '📊', + path: '/dashboard', + permission: null, // 모든 사용자 접근 가능 + description: '전체 현황 보기' + }, + { + id: 'projects', + label: '프로젝트 관리', + icon: '📋', + path: '/projects', + permission: 'project.view', + description: '프로젝트 등록 및 관리' + }, + { + id: 'bom', + label: 'BOM 관리', + icon: '📄', + path: '/bom', + permission: 'bom.view', + description: 'BOM 파일 업로드 및 분석' + }, + { + id: 'materials', + label: '자재 관리', + icon: '🔧', + path: '/materials', + permission: 'bom.view', + description: '자재 목록 및 비교' + }, + { + id: 'purchase', + label: '구매 관리', + icon: '💰', + path: '/purchase', + permission: 'project.view', + description: '구매 확인 및 관리' + }, + { + id: 'files', + label: '파일 관리', + icon: '📁', + path: '/files', + permission: 'file.upload', + description: '파일 업로드 및 관리' + }, + { + id: 'users', + label: '사용자 관리', + icon: '👥', + path: '/users', + permission: 'user.view', + description: '사용자 계정 관리', + adminOnly: true + }, + { + id: 'system', + label: '시스템 설정', + icon: '⚙️', + path: '/system', + permission: 'system.admin', + description: '시스템 환경 설정', + adminOnly: true + } + ]; + + // 사용자가 접근 가능한 메뉴만 필터링 + const accessibleMenuItems = menuItems.filter(item => { + // 관리자 전용 메뉴 체크 + if (item.adminOnly && !isAdmin() && !isManager()) { + return false; + } + + // 권한 체크 + if (item.permission && !hasPermission(item.permission)) { + return false; + } + + return true; + }); + + const handleLogout = async () => { + try { + await logout(); + setShowUserMenu(false); + } catch (error) { + console.error('Logout failed:', error); + } + }; + + const handleMenuClick = (item) => { + onNavigate(item.id); + setIsMobileMenuOpen(false); + }; + + const getRoleDisplayName = (role) => { + const roleMap = { + 'admin': '관리자', + 'system': '시스템', + 'leader': '팀장', + 'support': '지원', + 'user': '사용자' + }; + return roleMap[role] || role; + }; + + const getAccessLevelDisplayName = (level) => { + const levelMap = { + 'manager': '관리자', + 'leader': '팀장', + 'worker': '작업자', + 'viewer': '조회자' + }; + return levelMap[level] || level; + }; + + return ( + + ); +}; + +export default NavigationBar; diff --git a/frontend/src/components/NavigationMenu.css b/frontend/src/components/NavigationMenu.css new file mode 100644 index 0000000..4ce3dc3 --- /dev/null +++ b/frontend/src/components/NavigationMenu.css @@ -0,0 +1,250 @@ +/* 네비게이션 메뉴 스타일 */ +.navigation-menu { + position: relative; + z-index: 1000; +} + +/* 모바일 햄버거 버튼 */ +.mobile-menu-toggle { + display: none; + flex-direction: column; + justify-content: space-around; + width: 24px; + height: 24px; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + z-index: 1001; +} + +.mobile-menu-toggle span { + width: 24px; + height: 3px; + background: #4a5568; + border-radius: 2px; + transition: all 0.3s ease; +} + +/* 메뉴 오버레이 (모바일) */ +.menu-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.5); + z-index: 999; +} + +/* 사이드바 */ +.sidebar { + position: fixed; + top: 0; + left: 0; + width: 280px; + height: 100vh; + background: #ffffff; + border-right: 1px solid #e2e8f0; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + z-index: 1000; + transition: transform 0.3s ease; +} + +/* 사이드바 헤더 */ +.sidebar-header { + padding: 24px 20px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + font-size: 28px; +} + +.logo-text h2 { + margin: 0; + font-size: 20px; + font-weight: 700; +} + +.logo-text span { + font-size: 12px; + opacity: 0.9; +} + +/* 메뉴 섹션 */ +.menu-section { + flex: 1; + padding: 20px 0; + overflow-y: auto; +} + +.menu-section-title { + padding: 0 20px 12px; + font-size: 12px; + font-weight: 600; + color: #718096; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.menu-list { + list-style: none; + margin: 0; + padding: 0; +} + +.menu-item { + margin: 0; +} + +.menu-button { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + background: none; + border: none; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + color: #4a5568; + font-size: 14px; +} + +.menu-button:hover { + background: #f7fafc; + color: #2d3748; +} + +.menu-button.active { + background: #edf2f7; + color: #667eea; + font-weight: 600; +} + +.menu-button.active::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: #667eea; +} + +.menu-icon { + font-size: 18px; + width: 20px; + text-align: center; +} + +.menu-title { + flex: 1; +} + +.active-indicator { + width: 6px; + height: 6px; + background: #667eea; + border-radius: 50%; +} + +/* 사이드바 푸터 */ +.sidebar-footer { + padding: 20px; + border-top: 1px solid #e2e8f0; + background: #f7fafc; +} + +.user-info { + display: flex; + align-items: center; + gap: 12px; +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 16px; +} + +.user-details { + flex: 1; +} + +.user-name { + font-size: 14px; + font-weight: 600; + color: #2d3748; + margin-bottom: 2px; +} + +.user-role { + font-size: 12px; + color: #718096; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .mobile-menu-toggle { + display: flex; + } + + .menu-overlay { + display: block; + } + + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } +} + +/* 데스크톱에서 사이드바가 있을 때 메인 콘텐츠 여백 */ +@media (min-width: 769px) { + .main-content-with-sidebar { + margin-left: 280px; + } +} + +/* 스크롤바 스타일링 */ +.menu-section::-webkit-scrollbar { + width: 6px; +} + +.menu-section::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.menu-section::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.menu-section::-webkit-scrollbar-thumb:hover { + background: #a1a1a1; +} diff --git a/frontend/src/components/NavigationMenu.jsx b/frontend/src/components/NavigationMenu.jsx new file mode 100644 index 0000000..b1e4caf --- /dev/null +++ b/frontend/src/components/NavigationMenu.jsx @@ -0,0 +1,174 @@ +import React, { useState } from 'react'; +import './NavigationMenu.css'; + +const NavigationMenu = ({ user, currentPage, onPageChange }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + // 권한별 메뉴 정의 + const getMenuItems = () => { + const baseMenus = [ + { + id: 'dashboard', + title: '대시보드', + icon: '🏠', + description: '시스템 현황 및 개요', + requiredPermission: null // 모든 사용자 + } + ]; + + const menuItems = [ + { + id: 'projects', + title: '프로젝트 관리', + icon: '📋', + description: '프로젝트 등록 및 관리', + requiredPermission: 'project_management' + }, + { + id: 'bom', + title: 'BOM 관리', + icon: '🔧', + description: 'Bill of Materials 관리', + requiredPermission: 'bom_management' + }, + { + id: 'materials', + title: '자재 관리', + icon: '📦', + description: '자재 정보 및 재고 관리', + requiredPermission: 'material_management' + }, + { + id: 'quotes', + title: '견적 관리', + icon: '💰', + description: '견적서 작성 및 관리', + requiredPermission: 'quote_management' + }, + { + id: 'procurement', + title: '구매 관리', + icon: '🛒', + description: '구매 요청 및 발주 관리', + requiredPermission: 'procurement_management' + }, + { + id: 'production', + title: '생산 관리', + icon: '🏭', + description: '생산 계획 및 진행 관리', + requiredPermission: 'production_management' + }, + { + id: 'shipment', + title: '출하 관리', + icon: '🚚', + description: '출하 계획 및 배송 관리', + requiredPermission: 'shipment_management' + }, + { + id: 'users', + title: '사용자 관리', + icon: '👥', + description: '사용자 계정 및 권한 관리', + requiredPermission: 'user_management' + }, + { + id: 'system', + title: '시스템 설정', + icon: '⚙️', + description: '시스템 환경 설정', + requiredPermission: 'system_admin' + } + ]; + + // 사용자 권한에 따라 메뉴 필터링 + const userPermissions = user?.permissions || []; + const filteredMenus = menuItems.filter(menu => + !menu.requiredPermission || + userPermissions.includes(menu.requiredPermission) || + user?.role === 'admin' // 관리자는 모든 메뉴 접근 가능 + ); + + return [...baseMenus, ...filteredMenus]; + }; + + const menuItems = getMenuItems(); + + const handleMenuClick = (menuId) => { + onPageChange(menuId); + setIsMenuOpen(false); // 모바일에서 메뉴 닫기 + }; + + return ( +
+ {/* 모바일 햄버거 버튼 */} + + + {/* 메뉴 오버레이 (모바일) */} + {isMenuOpen && ( +
setIsMenuOpen(false)} + /> + )} + + {/* 사이드바 메뉴 */} + +
+ ); +}; + +export default NavigationMenu; diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..60a4303 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import LoginPage from '../pages/LoginPage'; + +const ProtectedRoute = ({ + children, + requiredPermission = null, + requiredRole = null, + fallback = null +}) => { + const { isAuthenticated, isLoading, user, hasPermission, hasRole } = useAuth(); + + // 로딩 중일 때 + if (isLoading) { + return ( +
+
+

인증 정보를 확인하는 중...

+
+ ); + } + + // 인증되지 않은 경우 + if (!isAuthenticated) { + return ; + } + + // 특정 권한이 필요한 경우 + if (requiredPermission && !hasPermission(requiredPermission)) { + return fallback || ( +
+
+
🔒
+

접근 권한이 없습니다

+

이 페이지에 접근하기 위한 권한이 없습니다.

+

+ 필요한 권한: {requiredPermission} +

+
+

현재 사용자: {user?.name} ({user?.username})

+

역할: {user?.role}

+

접근 레벨: {user?.access_level}

+
+ +
+
+ ); + } + + // 특정 역할이 필요한 경우 + if (requiredRole && !hasRole(requiredRole)) { + return fallback || ( +
+
+
👤
+

역할 권한이 없습니다

+

이 페이지에 접근하기 위한 역할 권한이 없습니다.

+

+ 필요한 역할: {requiredRole} +

+
+

현재 사용자: {user?.name} ({user?.username})

+

현재 역할: {user?.role}

+
+ +
+
+ ); + } + + // 모든 조건을 만족하는 경우 자식 컴포넌트 렌더링 + return children; +}; + +// 관리자 전용 라우트 +export const AdminRoute = ({ children, fallback = null }) => { + return ( + + {children} + + ); +}; + +// 시스템 관리자 전용 라우트 +export const SystemRoute = ({ children, fallback = null }) => { + const { hasRole } = useAuth(); + + if (!hasRole('admin') && !hasRole('system')) { + return fallback || ( +
+
+
⚙️
+

시스템 관리자 권한이 필요합니다

+

이 페이지는 시스템 관리자만 접근할 수 있습니다.

+
+
+ ); + } + + return ( + + {children} + + ); +}; + +// 매니저 이상 권한 라우트 +export const ManagerRoute = ({ children, fallback = null }) => { + const { isManager } = useAuth(); + + if (!isManager()) { + return fallback || ( +
+
+
👔
+

관리자 권한이 필요합니다

+

이 페이지는 관리자 이상의 권한이 필요합니다.

+
+
+ ); + } + + return ( + + {children} + + ); +}; + +export default ProtectedRoute; diff --git a/frontend/src/components/RevisionUploadDialog.jsx b/frontend/src/components/RevisionUploadDialog.jsx new file mode 100644 index 0000000..d4f2e56 --- /dev/null +++ b/frontend/src/components/RevisionUploadDialog.jsx @@ -0,0 +1,82 @@ +import React from 'react'; + +const RevisionUploadDialog = ({ + revisionDialog, + setRevisionDialog, + revisionFile, + setRevisionFile, + handleRevisionUpload, + uploading +}) => { + if (!revisionDialog.open) return null; + + return ( +
+
+

+ 리비전 업로드: {revisionDialog.bomName} +

+ + setRevisionFile(e.target.files[0])} + style={{ + width: '100%', + marginBottom: '16px', + padding: '8px' + }} + /> + +
+ + +
+
+
+ ); +}; + +export default RevisionUploadDialog; diff --git a/frontend/src/components/SimpleFileUpload.jsx b/frontend/src/components/SimpleFileUpload.jsx new file mode 100644 index 0000000..0d5d150 --- /dev/null +++ b/frontend/src/components/SimpleFileUpload.jsx @@ -0,0 +1,301 @@ +import React, { useState } from 'react'; +import api from '../api'; + +const SimpleFileUpload = ({ selectedProject, onUploadComplete }) => { + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadResult, setUploadResult] = useState(null); + const [error, setError] = useState(''); + const [dragActive, setDragActive] = useState(false); + + const handleDrag = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileUpload(e.dataTransfer.files[0]); + } + }; + + const handleFileSelect = (e) => { + if (e.target.files && e.target.files[0]) { + handleFileUpload(e.target.files[0]); + } + }; + + const handleFileUpload = async (file) => { + if (!selectedProject) { + setError('프로젝트를 먼저 선택해주세요.'); + return; + } + + // 파일 유효성 검사 + const allowedTypes = ['.xlsx', '.xls', '.csv']; + const fileExtension = '.' + file.name.split('.').pop().toLowerCase(); + + if (!allowedTypes.includes(fileExtension)) { + setError(`지원하지 않는 파일 형식입니다. 허용된 확장자: ${allowedTypes.join(', ')}`); + return; + } + + if (file.size > 10 * 1024 * 1024) { + setError('파일 크기는 10MB를 초과할 수 없습니다.'); + return; + } + + setUploading(true); + setError(''); + setUploadResult(null); + setUploadProgress(0); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('job_no', selectedProject.job_no); + formData.append('revision', 'Rev.0'); + + // 업로드 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setUploadProgress(prev => { + if (prev >= 90) { + clearInterval(progressInterval); + return 90; + } + return prev + 10; + }); + }, 200); + + const response = await api.post('/files/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + clearInterval(progressInterval); + setUploadProgress(100); + + if (response.data.success) { + setUploadResult({ + success: true, + message: response.data.message, + file: response.data.file, + job: response.data.job, + sampleMaterials: response.data.sample_materials || [] + }); + + // 업로드 완료 콜백 호출 + if (onUploadComplete) { + onUploadComplete(response.data); + } + } else { + throw new Error(response.data.message || '업로드 실패'); + } + + } catch (err) { + console.error('업로드 에러:', err); + setError(err.response?.data?.detail || err.message || '파일 업로드에 실패했습니다.'); + setUploadProgress(0); + } finally { + setUploading(false); + } + }; + + return ( +
+ {/* 드래그 앤 드롭 영역 */} +
document.getElementById('file-input').click()} + > +
+ {uploading ? '⏳' : '📤'} +
+
+ {uploading ? '업로드 중...' : 'BOM 파일을 업로드하세요'} +
+
+ 파일을 드래그하거나 클릭하여 선택하세요 +
+
+ 지원 형식: Excel (.xlsx, .xls), CSV (.csv) | 최대 크기: 10MB +
+ + +
+ + {/* 업로드 진행률 */} + {uploading && ( +
+
+ + 업로드 진행률 + + + {uploadProgress}% + +
+
+
+
+
+ )} + + {/* 에러 메시지 */} + {error && ( +
+ ⚠️ + {error} + +
+ )} + + {/* 업로드 성공 결과 */} + {uploadResult && uploadResult.success && ( +
+
+ + + 업로드 완료! + +
+ +
+ {uploadResult.message} +
+ + {/* 파일 정보 */} +
+

+ 📄 파일 정보 +

+
+
파일명: {uploadResult.file?.original_filename}
+
분석된 자재: {uploadResult.file?.parsed_count}개
+
저장된 자재: {uploadResult.file?.saved_count}개
+
프로젝트: {uploadResult.job?.job_name}
+
+
+ + {/* 샘플 자재 미리보기 */} + {uploadResult.sampleMaterials && uploadResult.sampleMaterials.length > 0 && ( +
+

+ 🔧 자재 샘플 (처음 3개) +

+
+ {uploadResult.sampleMaterials.map((material, index) => ( +
+ {material.description || material.item_code} + {material.category && ( + + {material.category} + + )} +
+ ))} +
+
+ )} +
+ )} +
+ ); +}; + +export default SimpleFileUpload; diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..e37471a --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,263 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import api from '../api'; + +const AuthContext = createContext(); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + // 토큰 관리 + const getToken = () => localStorage.getItem('access_token'); + const getRefreshToken = () => localStorage.getItem('refresh_token'); + + const setTokens = (accessToken, refreshToken) => { + localStorage.setItem('access_token', accessToken); + if (refreshToken) { + localStorage.setItem('refresh_token', refreshToken); + } + }; + + const clearTokens = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user_data'); + }; + + // 사용자 권한 확인 + const hasPermission = (permission) => { + if (!user || !user.permissions) return false; + return user.permissions.includes(permission); + }; + + const hasRole = (role) => { + if (!user) return false; + return user.role === role; + }; + + const isAdmin = () => hasRole('admin') || hasRole('system'); + const isManager = () => hasRole('admin') || hasRole('system') || hasRole('leader'); + + // 로그인 + const login = async (username, password) => { + try { + const response = await api.post('/auth/login', { + username, + password + }); + + if (response.data.success) { + const { access_token, refresh_token, user: userData } = response.data; + + setTokens(access_token, refresh_token); + setUser(userData); + setIsAuthenticated(true); + + // 사용자 데이터 로컬 저장 + localStorage.setItem('user_data', JSON.stringify(userData)); + + return userData; + } else { + throw new Error(response.data.error?.message || '로그인에 실패했습니다.'); + } + } catch (error) { + console.error('Login error:', error); + + if (error.response?.data?.error?.message) { + throw new Error(error.response.data.error.message); + } else if (error.response?.status === 401) { + throw new Error('아이디 또는 비밀번호가 올바르지 않습니다.'); + } else if (error.response?.status >= 500) { + throw new Error('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + } else { + throw new Error('로그인에 실패했습니다. 네트워크 연결을 확인해주세요.'); + } + } + }; + + // 로그아웃 + const logout = async () => { + try { + const token = getToken(); + if (token) { + await api.post('/auth/logout'); + } + } catch (error) { + console.error('Logout error:', error); + } finally { + clearTokens(); + setUser(null); + setIsAuthenticated(false); + } + }; + + // 토큰 갱신 + const refreshAccessToken = async () => { + try { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await api.post('/auth/refresh', { + refresh_token: refreshToken + }); + + if (response.data.success) { + const { access_token } = response.data; + setTokens(access_token); + return access_token; + } else { + throw new Error('Token refresh failed'); + } + } catch (error) { + console.error('Token refresh error:', error); + await logout(); + throw error; + } + }; + + // 현재 사용자 정보 조회 + const getCurrentUser = async () => { + try { + const token = getToken(); + if (!token) { + throw new Error('No access token available'); + } + + const response = await api.get('/auth/me'); + if (response.data.success) { + const userData = response.data.user; + setUser(userData); + setIsAuthenticated(true); + localStorage.setItem('user_data', JSON.stringify(userData)); + return userData; + } + } catch (error) { + console.error('Get current user error:', error); + if (error.response?.status === 401 || error.response?.status === 403) { + // 토큰이 만료되었거나 인증 실패한 경우 갱신 시도 + try { + await refreshAccessToken(); + return await getCurrentUser(); + } catch (refreshError) { + await logout(); + } + } + throw error; + } + }; + + // 초기 인증 상태 확인 + useEffect(() => { + const initializeAuth = async () => { + try { + const token = getToken(); + const savedUserData = localStorage.getItem('user_data'); + + if (token && savedUserData) { + try { + const userData = JSON.parse(savedUserData); + setUser(userData); + setIsAuthenticated(true); + + // 백그라운드에서 토큰 유효성 검증 (선택적) + getCurrentUser().catch(error => { + console.warn('Background token validation failed:', error); + // 백그라운드 검증 실패는 무시 (사용자 경험 우선) + }); + } catch (parseError) { + console.error('Failed to parse saved user data:', parseError); + clearTokens(); + setUser(null); + setIsAuthenticated(false); + } + } else { + // 토큰이나 사용자 데이터가 없으면 로그아웃 상태로 설정 + setUser(null); + setIsAuthenticated(false); + } + } catch (error) { + console.error('Auth initialization error:', error); + clearTokens(); + setUser(null); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }; + + initializeAuth(); + }, []); + + // API 요청 인터셉터 설정 + useEffect(() => { + const requestInterceptor = api.interceptors.request.use( + (config) => { + const token = getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) + ); + + const responseInterceptor = api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + await refreshAccessToken(); + const token = getToken(); + originalRequest.headers.Authorization = `Bearer ${token}`; + return api(originalRequest); + } catch (refreshError) { + await logout(); + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } + ); + + return () => { + api.interceptors.request.eject(requestInterceptor); + api.interceptors.response.eject(responseInterceptor); + }; + }, []); + + const value = { + user, + isLoading, + isAuthenticated, + login, + logout, + getCurrentUser, + hasPermission, + hasRole, + isAdmin, + isManager, + refreshAccessToken + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx new file mode 100644 index 0000000..ae18713 --- /dev/null +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -0,0 +1,431 @@ +import React, { useState, useEffect } from 'react'; +import SimpleFileUpload from '../components/SimpleFileUpload'; +import MaterialList from '../components/MaterialList'; +import { fetchMaterials, fetchFiles } from '../api'; + +const BOMManagementPage = ({ user }) => { + const [activeTab, setActiveTab] = useState('upload'); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [files, setFiles] = useState([]); + const [materials, setMaterials] = useState([]); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState({ + totalFiles: 0, + totalMaterials: 0, + recentUploads: [] + }); + + useEffect(() => { + loadProjects(); + }, []); + + useEffect(() => { + if (selectedProject) { + loadProjectFiles(); + } + }, [selectedProject]); + + useEffect(() => { + loadStats(); + }, [files, materials]); + + const loadProjects = async () => { + try { + const response = await fetch('/api/jobs/'); + const data = await response.json(); + if (data.success) { + setProjects(data.jobs); + } + } catch (error) { + console.error('프로젝트 로딩 실패:', error); + } + }; + + const loadProjectFiles = async () => { + if (!selectedProject) return; + + setLoading(true); + try { + // 기존 API 함수 사용 - 파일 목록 로딩 + const filesResponse = await fetchFiles({ job_no: selectedProject.job_no }); + setFiles(Array.isArray(filesResponse.data) ? filesResponse.data : []); + + // 기존 API 함수 사용 - 자재 목록 로딩 + const materialsResponse = await fetchMaterials({ job_no: selectedProject.job_no, limit: 1000 }); + setMaterials(materialsResponse.data?.materials || []); + } catch (error) { + console.error('프로젝트 데이터 로딩 실패:', error); + } finally { + setLoading(false); + } + }; + + const loadStats = async () => { + try { + // 실제 통계 계산 - 더미 데이터 없이 + const totalFiles = files.length; + const totalMaterials = materials.length; + + setStats({ + totalFiles, + totalMaterials, + recentUploads: files.slice(0, 5) // 최근 5개 파일 + }); + } catch (error) { + console.error('통계 로딩 실패:', error); + } + }; + + const handleFileUpload = async (uploadData) => { + try { + setLoading(true); + // 기존 FileUpload 컴포넌트의 업로드 로직 활용 + await loadProjectFiles(); // 업로드 후 데이터 새로고침 + await loadStats(); + } catch (error) { + console.error('파일 업로드 후 새로고침 실패:', error); + } finally { + setLoading(false); + } + }; + + const StatCard = ({ title, value, icon, color = '#667eea' }) => ( +
+
+ {icon} +
+
+
+ {value} +
+
+ {title} +
+
+
+ ); + + return ( +
+
+ {/* 헤더 */} +
+

+ 🔧 BOM 관리 +

+

+ Bill of Materials 업로드, 분석 및 관리를 수행하세요. +

+
+ + {/* 통계 카드들 */} +
+ + + +
+ + {/* 탭 네비게이션 */} +
+
+ {[ + { id: 'upload', label: '📤 파일 업로드', icon: '📤' }, + { id: 'files', label: '📁 파일 관리', icon: '📁' }, + { id: 'materials', label: '🔧 자재 목록', icon: '🔧' }, + { id: 'analysis', label: '📊 분석 결과', icon: '📊' } + ].map(tab => ( + + ))} +
+ + {/* 탭 콘텐츠 */} +
+ {activeTab === 'upload' && ( +
+

+ 📤 BOM 파일 업로드 +

+ + {/* 프로젝트 선택 */} +
+ + +
+ + {selectedProject ? ( +
+
+

+ 선택된 프로젝트: {selectedProject.job_name} +

+

+ Job No: {selectedProject.job_no} | + 고객사: {selectedProject.client_name} | + 상태: {selectedProject.status} +

+
+ + +
+ ) : ( +
+ 먼저 프로젝트를 선택해주세요. +
+ )} +
+ )} + + {activeTab === 'files' && ( +
+

+ 📁 업로드된 파일 목록 +

+ + {selectedProject ? ( + loading ? ( +
+ 파일 목록을 불러오는 중... +
+ ) : files.length > 0 ? ( +
+ {files.map((file, index) => ( +
+
+
+ {file.original_filename || file.filename} +
+
+ 업로드: {new Date(file.created_at).toLocaleString()} | + 자재 수: {file.parsed_count || 0}개 +
+
+
+ {file.revision || 'Rev.0'} +
+
+ ))} +
+ ) : ( +
+ 업로드된 파일이 없습니다. +
+ ) + ) : ( +
+ 프로젝트를 선택해주세요. +
+ )} +
+ )} + + {activeTab === 'materials' && ( +
+

+ 🔧 자재 목록 +

+ + {selectedProject ? ( + + ) : ( +
+ 프로젝트를 선택해주세요. +
+ )} +
+ )} + + {activeTab === 'analysis' && ( +
+

+ 📊 분석 결과 +

+ +
+
+ 🚧 분석 결과 페이지는 곧 구현될 예정입니다. +
+
+ 자재 분류, 통계, 비교 분석 기능이 추가됩니다. +
+
+
+ )} +
+
+
+
+ ); +}; + +export default BOMManagementPage; diff --git a/frontend/src/pages/BOMStatusPage.jsx b/frontend/src/pages/BOMStatusPage.jsx index 695bad7..9c1cc80 100644 --- a/frontend/src/pages/BOMStatusPage.jsx +++ b/frontend/src/pages/BOMStatusPage.jsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; -import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi } from '../api'; +import { uploadFile as uploadFileApi, fetchFiles as fetchFilesApi, deleteFile as deleteFileApi, api } from '../api'; +import BOMFileUpload from '../components/BOMFileUpload'; +import BOMFileTable from '../components/BOMFileTable'; +import RevisionUploadDialog from '../components/RevisionUploadDialog'; -const BOMStatusPage = () => { +const BOMStatusPage = ({ jobNo, jobName, onNavigate }) => { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -12,10 +13,24 @@ const BOMStatusPage = () => { const [bomName, setBomName] = useState(''); const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null }); const [revisionFile, setRevisionFile] = useState(null); - const [searchParams] = useSearchParams(); - const jobNo = searchParams.get('job_no'); - const jobName = searchParams.get('job_name'); - const navigate = useNavigate(); + const [purchaseModal, setPurchaseModal] = useState({ open: false, data: null, fileInfo: null }); + + // 카테고리별 색상 함수 + const getCategoryColor = (category) => { + const colors = { + 'pipe': '#4299e1', + 'fitting': '#48bb78', + 'valve': '#ed8936', + 'flange': '#9f7aea', + 'bolt': '#38b2ac', + 'gasket': '#f56565', + 'instrument': '#d69e2e', + 'material': '#718096', + 'integrated': '#319795', + 'unknown': '#a0aec0' + }; + return colors[category?.toLowerCase()] || colors.unknown; + }; // 파일 목록 불러오기 const fetchFiles = async () => { @@ -26,134 +41,167 @@ const BOMStatusPage = () => { const response = await fetchFilesApi({ job_no: jobNo }); console.log('API 응답:', response); - if (Array.isArray(response.data)) { - console.log('데이터 배열 형태:', response.data.length, '개'); + if (response.data && response.data.data && Array.isArray(response.data.data)) { + setFiles(response.data.data); + } else if (response.data && Array.isArray(response.data)) { setFiles(response.data); } else if (response.data && Array.isArray(response.data.files)) { - console.log('데이터.files 배열 형태:', response.data.files.length, '개'); setFiles(response.data.files); } else { - console.log('빈 배열로 설정'); setFiles([]); } - } catch (e) { - setError('파일 목록을 불러오지 못했습니다.'); - console.error('파일 목록 로드 에러:', e); + } catch (err) { + console.error('파일 목록 불러오기 실패:', err); + setError('파일 목록을 불러오는데 실패했습니다.'); } finally { setLoading(false); } }; useEffect(() => { - console.log('useEffect 실행 - jobNo:', jobNo); if (jobNo) { fetchFiles(); - } else { - console.log('jobNo가 없어서 fetchFiles 실행하지 않음'); } - // eslint-disable-next-line }, [jobNo]); - // BOM 이름 중복 체크 - const checkDuplicateBOM = () => { - return files.some(file => - file.bom_name === bomName || - file.original_filename === bomName || - file.filename === bomName - ); - }; - - // 파일 업로드 핸들러 + // 파일 업로드 const handleUpload = async () => { - if (!selectedFile) { - setError('파일을 선택해주세요.'); + if (!selectedFile || !bomName.trim()) { + setError('파일과 BOM 이름을 모두 입력해주세요.'); return; } - - if (!bomName.trim()) { - setError('BOM 이름을 입력해주세요.'); - return; - } - + setUploading(true); setError(''); - + try { - const isDuplicate = checkDuplicateBOM(); - if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) { - setUploading(false); - return; - } - const formData = new FormData(); formData.append('file', selectedFile); formData.append('job_no', jobNo); - formData.append('revision', 'Rev.0'); - formData.append('bom_name', bomName); - formData.append('bom_type', 'excel'); - formData.append('description', ''); + formData.append('bom_name', bomName.trim()); + + const uploadResult = await uploadFileApi(formData); - const response = await uploadFileApi(formData); + // 업로드 성공 후 파일 목록 새로고침 + await fetchFiles(); - if (response.data.success) { - setSelectedFile(null); - setBomName(''); - // 파일 input 초기화 - const fileInput = document.getElementById('file-input'); - if (fileInput) fileInput.value = ''; - - fetchFiles(); - alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`); - } else { - setError(response.data.message || '업로드에 실패했습니다.'); - } - } catch (e) { - console.error('업로드 에러:', e); - if (e.response?.data?.detail) { - setError(e.response.data.detail); - } else { - setError('파일 업로드에 실패했습니다.'); + // 업로드 완료 후 자동으로 구매 수량 계산 실행 + if (uploadResult && uploadResult.file_id) { + // 잠시 후 구매 수량 계산 페이지로 이동 + setTimeout(async () => { + try { + // 구매 수량 계산 API 호출 + const response = await fetch(`/api/purchase/calculate?job_no=${jobNo}&revision=Rev.0&file_id=${uploadResult.file_id}`); + const purchaseData = await response.json(); + + if (purchaseData.success) { + // 구매 수량 계산 결과를 모달로 표시하거나 별도 페이지로 이동 + alert(`업로드 및 분류 완료!\n구매 수량이 계산되었습니다.\n\n파이프: ${purchaseData.purchase_items?.filter(item => item.category === 'PIPE').length || 0}개 항목\n기타 자재: ${purchaseData.purchase_items?.filter(item => item.category !== 'PIPE').length || 0}개 항목`); + } + } catch (error) { + console.error('구매 수량 계산 실패:', error); + } + }, 2000); // 2초 후 실행 (분류 완료 대기) } + + // 폼 초기화 + setSelectedFile(null); + setBomName(''); + document.getElementById('file-input').value = ''; + + } catch (err) { + console.error('파일 업로드 실패:', err); + setError('파일 업로드에 실패했습니다.'); } finally { setUploading(false); } }; - // 리비전 업로드 핸들러 + // 파일 삭제 + const handleDelete = async (fileId) => { + if (!window.confirm('정말로 이 파일을 삭제하시겠습니까?')) { + return; + } + + try { + await deleteFileApi(fileId); + await fetchFiles(); // 목록 새로고침 + } catch (err) { + console.error('파일 삭제 실패:', err); + setError('파일 삭제에 실패했습니다.'); + } + }; + + // 자재 확인 페이지로 이동 + // 구매 수량 계산 (자재 목록 페이지 거치지 않음) + const handleViewMaterials = async (file) => { + try { + setLoading(true); + + // 구매 수량 계산 API 호출 + console.log('구매 수량 계산 API 호출:', { + job_no: file.job_no, + revision: file.revision || 'Rev.0', + file_id: file.id + }); + + const response = await api.get(`/purchase/items/calculate?job_no=${file.job_no}&revision=${file.revision || 'Rev.0'}&file_id=${file.id}`); + + console.log('구매 수량 계산 응답:', response.data); + const purchaseData = response.data; + + if (purchaseData.success && purchaseData.items) { + // 구매 수량 계산 결과를 모달로 표시 + setPurchaseModal({ + open: true, + data: purchaseData.items, + fileInfo: file + }); + } else { + alert('구매 수량 계산에 실패했습니다.'); + } + } catch (error) { + console.error('구매 수량 계산 오류:', error); + alert('구매 수량 계산 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; + + // 리비전 업로드 다이얼로그 열기 + const openRevisionDialog = (bomName, parentId) => { + setRevisionDialog({ open: true, bomName, parentId }); + }; + + // 리비전 업로드 const handleRevisionUpload = async () => { - if (!revisionFile) { + if (!revisionFile || !revisionDialog.bomName) { setError('파일을 선택해주세요.'); return; } - + setUploading(true); setError(''); - + try { const formData = new FormData(); formData.append('file', revisionFile); formData.append('job_no', jobNo); - formData.append('revision', 'Rev.0'); // 백엔드에서 자동 증가 formData.append('bom_name', revisionDialog.bomName); - formData.append('parent_file_id', revisionDialog.parentId); + formData.append('parent_id', revisionDialog.parentId); + + await uploadFileApi(formData); - const response = await uploadFileApi(formData); + // 업로드 성공 후 파일 목록 새로고침 + await fetchFiles(); - if (response.data.success) { - setRevisionDialog({ open: false, bomName: '', parentId: null }); - setRevisionFile(null); - fetchFiles(); - alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`); - } else { - setError(response.data.message || '리비전 업로드에 실패했습니다.'); - } - } catch (e) { - console.error('리비전 업로드 에러:', e); - if (e.response?.data?.detail) { - setError(e.response.data.detail); - } else { - setError('리비전 업로드에 실패했습니다.'); - } + // 다이얼로그 닫기 + setRevisionDialog({ open: false, bomName: '', parentId: null }); + setRevisionFile(null); + + } catch (err) { + console.error('리비전 업로드 실패:', err); + setError('리비전 업로드에 실패했습니다.'); } finally { setUploading(false); } @@ -183,236 +231,274 @@ const BOMStatusPage = () => { }; return ( - - - 📊 BOM 관리 시스템 - {jobNo && jobName && ( - - {jobNo} - {jobName} - - )} - - {/* 파일 업로드 폼 */} - - 새 BOM 업로드 - - setBomName(e.target.value)} - placeholder="예: PIPING_BOM_A구역" - required - size="small" - helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다" - /> - - setSelectedFile(e.target.files[0])} - style={{ flex: 1 }} - /> - - - {selectedFile && ( - - 선택된 파일: {selectedFile.name} - - )} - - - - {error && {error}} - - 업로드된 BOM 목록 - {loading && } - {!loading && files.length === 0 && ( - 업로드된 BOM이 없습니다. - )} - {!loading && files.length > 0 && ( - - - - - BOM 이름 - 파일명 - 리비전 - 자재 수 - 업로드 일시 - 작업 - - - - {Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => ( - bomFiles.map((file, index) => ( - - - - {file.bom_name || bomKey} - - {index === 0 && bomFiles.length > 1 && ( - - (최신 리비전) - - )} - {index > 0 && ( - - (이전 버전) - - )} - - - - {file.filename || file.original_filename} - - - - - {file.revision || 'Rev.0'} - - - - - {file.parsed_count || 0}개 - - - - - {file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'} - - - - - {index === 0 && ( - - )} - {file.revision !== 'Rev.0' && index < 3 && ( - <> - - - - )} - - - - )) - ))} - -
-
- )} - - {/* 리비전 업로드 다이얼로그 */} - setRevisionDialog({ open: false, bomName: '', parentId: null })}> - 리비전 업로드 - - - BOM 이름: {revisionDialog.bomName} - - - 새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다. - - setRevisionFile(e.target.files[0])} - style={{ marginTop: 16 }} - /> - {revisionFile && ( - - 선택된 파일: {revisionFile.name} - - )} - - - - - - -
+ ← 뒤로가기 + + +

+ 📊 BOM 관리 시스템 +

+ + {jobNo && jobName && ( +

+ {jobNo} - {jobName} +

+ )} +
+ + {/* 파일 업로드 컴포넌트 */} + + + {/* BOM 목록 */} +

+ 업로드된 BOM 목록 +

+ + {/* 파일 테이블 컴포넌트 */} + + + {/* 리비전 업로드 다이얼로그 */} + + + {/* 구매 수량 계산 결과 모달 */} + {purchaseModal.open && ( +
+
+
+

+ 🧮 구매 수량 계산 결과 +

+ +
+ +
+
프로젝트: {purchaseModal.fileInfo?.job_no}
+
BOM: {purchaseModal.fileInfo?.bom_name}
+
리비전: {purchaseModal.fileInfo?.revision || 'Rev.0'}
+
+ +
+ + + + + + + + + + + + + + + {purchaseModal.data?.map((item, index) => ( + + + + + + + + + + + ))} + +
카테고리사양사이즈재질BOM 수량구매 수량단위비고
+ + {item.category} + + + {item.specification} + + {/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */} + {item.category !== 'PIPE' && ( + + {item.size_spec || '-'} + + )} + {item.category === 'PIPE' && ( + + 사양에 포함 + + )} + + {/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */} + {item.category !== 'PIPE' && ( + + {item.material_spec || '-'} + + )} + {item.category === 'PIPE' && ( + + 사양에 포함 + + )} + + {item.category === 'PIPE' ? + `${Math.round(item.bom_quantity)}mm` : + item.bom_quantity + } + + {item.category === 'PIPE' ? + `${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` : + item.calculated_qty + } + + {item.unit} + + {item.category === 'PIPE' && ( +
+
절단수: {item.cutting_count}회
+
절단손실: {item.cutting_loss}mm
+
활용률: {Math.round(item.utilization_rate)}%
+
+ )} + {item.category !== 'PIPE' && item.safety_factor && ( +
여유율: {Math.round((item.safety_factor - 1) * 100)}%
+ )} +
+
+ +
+
📋 계산 규칙 (올바른 규칙):
+
PIPE: 6M 단위 올림, 절단당 2mm 손실
+
FITTING: BOM 수량 그대로
+
VALVE: BOM 수량 그대로
+
BOLT: 5% 여유율 후 4의 배수 올림
+
GASKET: 5의 배수 올림
+
INSTRUMENT: BOM 수량 그대로
+
+
+
+ )} +
+ ); }; -export default BOMStatusPage; \ No newline at end of file +export default BOMStatusPage; \ No newline at end of file diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..18013f7 --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -0,0 +1,264 @@ +import React, { useState, useEffect } from 'react'; + +const DashboardPage = ({ user }) => { + const [stats, setStats] = useState({ + totalProjects: 0, + activeProjects: 0, + completedProjects: 0, + totalMaterials: 0, + pendingQuotes: 0, + recentActivities: [] + }); + + useEffect(() => { + // 실제로는 API에서 데이터를 가져올 예정 + // 현재는 더미 데이터 사용 + setStats({ + totalProjects: 25, + activeProjects: 8, + completedProjects: 17, + totalMaterials: 1250, + pendingQuotes: 3, + recentActivities: [ + { id: 1, type: 'project', message: '냉동기 프로젝트 #2024-001 생성됨', time: '2시간 전' }, + { id: 2, type: 'bom', message: 'BOG 시스템 BOM 업데이트됨', time: '4시간 전' }, + { id: 3, type: 'quote', message: '다이아프람 펌프 견적서 승인됨', time: '6시간 전' }, + { id: 4, type: 'material', message: '스테인리스 파이프 재고 부족 알림', time: '1일 전' }, + { id: 5, type: 'shipment', message: '드라이어 시스템 출하 완료', time: '2일 전' } + ] + }); + }, []); + + const getActivityIcon = (type) => { + const icons = { + project: '📋', + bom: '🔧', + quote: '💰', + material: '📦', + shipment: '🚚' + }; + return icons[type] || '📌'; + }; + + const StatCard = ({ title, value, icon, color = '#667eea' }) => ( +
{ + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)'; + }}> +
+
+
+ {title} +
+
+ {value} +
+
+
+ {icon} +
+
+
+ ); + + return ( +
+
+ {/* 헤더 */} +
+

+ 안녕하세요, {user?.name}님! 👋 +

+

+ 오늘도 TK-MP 시스템과 함께 효율적인 업무를 시작해보세요. +

+
+ + {/* 통계 카드들 */} +
+ + + + + +
+ +
+ {/* 최근 활동 */} +
+

+ 📈 최근 활동 +

+
+ {stats.recentActivities.map(activity => ( +
+ + {getActivityIcon(activity.type)} + +
+
+ {activity.message} +
+
+ {activity.time} +
+
+
+ ))} +
+
+ + {/* 빠른 작업 */} +
+

+ ⚡ 빠른 작업 +

+
+ {[ + { title: '새 프로젝트 등록', icon: '➕', color: '#667eea' }, + { title: 'BOM 업로드', icon: '📤', color: '#48bb78' }, + { title: '견적서 작성', icon: '📝', color: '#ed8936' }, + { title: '자재 검색', icon: '🔍', color: '#38b2ac' } + ].map((action, index) => ( + + ))} +
+
+
+
+
+ ); +}; + +export default DashboardPage; diff --git a/frontend/src/pages/JobRegistrationPage.css b/frontend/src/pages/JobRegistrationPage.css new file mode 100644 index 0000000..3fdd7d8 --- /dev/null +++ b/frontend/src/pages/JobRegistrationPage.css @@ -0,0 +1,334 @@ +.job-registration-page { + min-height: 100vh; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + padding: 20px; +} + +.job-registration-container { + max-width: 1000px; + margin: 0 auto; + background: white; + border-radius: 15px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.page-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 30px 40px; + position: relative; +} + +.back-button { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + margin-bottom: 20px; +} + +.back-button:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); +} + +.page-header h1 { + font-size: 2rem; + margin: 0 0 10px 0; + font-weight: 600; +} + +.page-header p { + font-size: 1.1rem; + margin: 0; + opacity: 0.9; +} + +.registration-form { + padding: 40px; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 25px; + margin-bottom: 40px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-group label { + font-weight: 600; + color: #2d3748; + margin-bottom: 8px; + font-size: 0.95rem; +} + +.form-group label.required::after { + content: ' *'; + color: #e53e3e; +} + +.form-group input, +.form-group select, +.form-group textarea { + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + transition: all 0.3s ease; + background: white; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-group input.error, +.form-group select.error, +.form-group textarea.error { + border-color: #e53e3e; +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: #a0aec0; +} + +.form-group textarea { + resize: vertical; + min-height: 100px; + font-family: inherit; +} + +.error-message { + color: #e53e3e; + font-size: 0.85rem; + margin-top: 5px; + font-weight: 500; +} + +.form-actions { + display: flex; + gap: 15px; + justify-content: flex-end; + padding-top: 30px; + border-top: 1px solid #e2e8f0; +} + +.cancel-button, +.submit-button { + padding: 12px 24px; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + border: none; + min-width: 120px; +} + +.cancel-button { + background: #f7fafc; + color: #4a5568; + border: 2px solid #e2e8f0; +} + +.cancel-button:hover { + background: #edf2f7; + border-color: #cbd5e0; +} + +.submit-button { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: 2px solid transparent; +} + +.submit-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); +} + +.submit-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* 모바일 반응형 */ +@media (max-width: 768px) { + .job-registration-page { + padding: 10px; + } + + .registration-form { + padding: 25px 20px; + } + + .page-header { + padding: 25px 20px; + } + + .page-header h1 { + font-size: 1.6rem; + } + + .form-grid { + grid-template-columns: 1fr; + gap: 20px; + } + + .form-actions { + flex-direction: column-reverse; + } + + .cancel-button, + .submit-button { + width: 100%; + } +} + +/* 프로젝트 유형 관리 스타일 */ +.project-type-container { + display: flex; + gap: 8px; + align-items: center; +} + +.project-type-container select { + flex: 1; +} + +.project-type-actions { + display: flex; + gap: 4px; +} + +.add-type-btn, +.remove-type-btn { + width: 32px; + height: 32px; + border: 2px solid #e2e8f0; + background: white; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: bold; + transition: all 0.2s ease; +} + +.add-type-btn { + color: #38a169; + border-color: #38a169; +} + +.add-type-btn:hover { + background: #38a169; + color: white; +} + +.remove-type-btn { + color: #e53e3e; + border-color: #e53e3e; +} + +.remove-type-btn:hover { + background: #e53e3e; + color: white; +} + +.add-project-type-form { + display: flex; + gap: 8px; + margin-top: 8px; + padding: 12px; + background: #f7fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +.add-project-type-form input { + flex: 1; + padding: 8px 12px; + border: 1px solid #cbd5e0; + border-radius: 4px; + font-size: 0.9rem; +} + +.add-project-type-form button { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.add-project-type-form button:first-of-type { + background: #38a169; + color: white; +} + +.add-project-type-form button:first-of-type:hover { + background: #2f855a; +} + +.add-project-type-form button:last-of-type { + background: #e2e8f0; + color: #4a5568; +} + +.add-project-type-form button:last-of-type:hover { + background: #cbd5e0; +} + +/* 태블릿 반응형 */ +@media (max-width: 1024px) and (min-width: 769px) { + .job-registration-container { + margin: 20px; + max-width: none; + } +} + +/* 모바일에서 프로젝트 유형 관리 */ +@media (max-width: 768px) { + .project-type-container { + flex-direction: column; + align-items: stretch; + } + + .project-type-actions { + justify-content: center; + margin-top: 8px; + } + + .add-project-type-form { + flex-direction: column; + } + + .add-project-type-form button { + width: 100%; + } +} \ No newline at end of file diff --git a/frontend/src/pages/JobRegistrationPage.jsx b/frontend/src/pages/JobRegistrationPage.jsx new file mode 100644 index 0000000..e90f9da --- /dev/null +++ b/frontend/src/pages/JobRegistrationPage.jsx @@ -0,0 +1,359 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api'; +import './JobRegistrationPage.css'; + +const JobRegistrationPage = () => { + const navigate = useNavigate(); + + const [formData, setFormData] = useState({ + jobNo: '', + projectName: '', + clientName: '', + location: '', + contractDate: '', + deliveryDate: '', + deliveryMethod: '', + description: '', + projectType: '냉동기', + status: 'PLANNING' + }); + + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState({}); + + const [projectTypes, setProjectTypes] = useState([ + { value: '냉동기', label: '냉동기' }, + { value: 'BOG', label: 'BOG' }, + { value: '다이아프람', label: '다이아프람' }, + { value: '드라이어', label: '드라이어' } + ]); + + const [newProjectType, setNewProjectType] = useState(''); + const [showAddProjectType, setShowAddProjectType] = useState(false); + + const statusOptions = [ + { value: 'PLANNING', label: '계획' }, + { value: 'DESIGN', label: '설계' }, + { value: 'PROCUREMENT', label: '조달' }, + { value: 'CONSTRUCTION', label: '시공' }, + { value: 'COMPLETED', label: '완료' } + ]; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + + // 입력 시 에러 제거 + if (errors[name]) { + setErrors(prev => ({ + ...prev, + [name]: '' + })); + } + }; + + const addProjectType = () => { + if (newProjectType.trim() && !projectTypes.find(type => type.value === newProjectType.trim())) { + const newType = { value: newProjectType.trim(), label: newProjectType.trim() }; + setProjectTypes(prev => [...prev, newType]); + setFormData(prev => ({ ...prev, projectType: newProjectType.trim() })); + setNewProjectType(''); + setShowAddProjectType(false); + } + }; + + const removeProjectType = (valueToRemove) => { + if (projectTypes.length > 1) { // 최소 1개는 유지 + setProjectTypes(prev => prev.filter(type => type.value !== valueToRemove)); + if (formData.projectType === valueToRemove) { + setFormData(prev => ({ ...prev, projectType: projectTypes[0].value })); + } + } + }; + + const validateForm = () => { + const newErrors = {}; + + if (!formData.jobNo.trim()) { + newErrors.jobNo = 'Job No.는 필수 입력 항목입니다.'; + } + + if (!formData.projectName.trim()) { + newErrors.projectName = '프로젝트명은 필수 입력 항목입니다.'; + } + + if (!formData.clientName.trim()) { + newErrors.clientName = '고객사명은 필수 입력 항목입니다.'; + } + + if (formData.contractDate && formData.deliveryDate && new Date(formData.contractDate) > new Date(formData.deliveryDate)) { + newErrors.deliveryDate = '납기일은 수주일 이후여야 합니다.'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setLoading(true); + + try { + // Job 생성 API 호출 + const response = await api.post('/jobs', { + job_no: formData.jobNo, + job_name: formData.projectName, + client_name: formData.clientName, + project_site: formData.location || null, + contract_date: formData.contractDate || null, + delivery_date: formData.deliveryDate || null, + delivery_terms: formData.deliveryMethod || null, + description: formData.description || null, + project_type: formData.projectType, + status: formData.status + }); + + if (response.data.success) { + alert('프로젝트가 성공적으로 등록되었습니다!'); + navigate('/project-selection'); + } else { + alert('등록에 실패했습니다: ' + response.data.message); + } + } catch (error) { + console.error('Job 등록 오류:', error); + if (error.response?.data?.detail) { + alert('등록 실패: ' + error.response.data.detail); + } else { + alert('등록 중 오류가 발생했습니다.'); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +

프로젝트 기본정보 등록

+

새로운 프로젝트의 Job No. 및 기본 정보를 입력해주세요

+
+ +
+
+
+ + + {errors.jobNo && {errors.jobNo}} +
+ +
+ + + {errors.projectName && {errors.projectName}} +
+ +
+ + + {errors.clientName && {errors.clientName}} +
+ +
+ + +
+ +
+ +
+ +
+ + {projectTypes.length > 1 && ( + + )} +
+
+ + {showAddProjectType && ( +
+ setNewProjectType(e.target.value)} + placeholder="새 프로젝트 유형 입력" + onKeyPress={(e) => e.key === 'Enter' && addProjectType()} + /> + + +
+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + + {errors.deliveryDate && {errors.deliveryDate}} +
+ +
+ + +
+ +
+ +