feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
Hyungi Ahn
2025-08-30 14:23:01 +09:00
parent 78d90c7a8f
commit 4f8e395f87
84 changed files with 16297 additions and 2161 deletions

View File

@@ -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 코드 리뷰 기반 개선/확장/운영 권장사항 정리*

123
README.md
View File

@@ -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

View File

@@ -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 코드 리뷰 및 구조 요약*

1033
RULES.md

File diff suppressed because it is too large Load Diff

25
backend/.dockerignore Normal file
View File

@@ -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

View File

@@ -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 복사 및 의존성 설치

View File

@@ -1 +1,4 @@
# API 라우터 패키지
"""
API 모듈
분리된 API 엔드포인트들
"""

View File

@@ -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"]
)

View File

@@ -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'
]

View File

@@ -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="사용자 삭제 중 오류가 발생했습니다"
)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

354
backend/app/auth/models.py Normal file
View File

@@ -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"<User(username='{self.username}', name='{self.name}', role='{self.role}')>"
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"<LoginLog(user_id={self.user_id}, status='{self.login_status}', time='{self.login_time}')>"
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"<UserSession(user_id={self.user_id}, expires_at='{self.expires_at}')>"
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"<Permission(name='{self.permission_name}', module='{self.module}')>"
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"<RolePermission(role='{self.role}', permission_id={self.permission_id})>"
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

284
backend/app/config.py Normal file
View File

@@ -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

View File

@@ -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 테이블은 더 이상 사용하지 않음

View File

@@ -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")

View File

@@ -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)):
"""파일 삭제"""

View File

@@ -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
}
}

View File

@@ -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)}")

View File

@@ -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"
]

View File

@@ -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
]

View File

@@ -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)

View File

@@ -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'
}

View File

@@ -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"
]

View File

@@ -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()

View File

@@ -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("에러 핸들러 등록 완료")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

25
backend/env.example Normal file
View File

@@ -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

60
backend/pytest.ini Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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 $$;

View File

@@ -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 $$;

View File

@@ -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 $$;

View File

@@ -0,0 +1,4 @@
"""
테스트 모듈
자동화된 테스트 케이스들
"""

160
backend/tests/conftest.py Normal file
View File

@@ -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 = []

View File

@@ -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

View File

@@ -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

65
deploy-synology.sh Executable file
View File

@@ -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'"

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

18
frontend/.dockerignore Normal file
View File

@@ -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*

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 <DashboardPage user={user} />;
case 'projects':
return <ProjectsPage user={user} />;
case 'bom':
return <JobSelectionPage onJobSelect={(jobNo, jobName) =>
navigateToPage('bom-status', { job_no: jobNo, job_name: jobName })
} />;
case 'bom-status':
return <BOMStatusPage
jobNo={pageParams.job_no}
jobName={pageParams.job_name}
onNavigate={navigateToPage}
/>;
case 'materials':
return <SimpleMaterialsPage
fileId={pageParams.file_id}
jobNo={pageParams.jobNo}
bomName={pageParams.bomName}
revision={pageParams.revision}
filename={pageParams.filename}
onNavigate={navigateToPage}
/>;
case 'material-comparison':
return <MaterialComparisonPage
jobNo={pageParams.job_no}
currentRevision={pageParams.current_revision}
previousRevision={pageParams.previous_revision}
filename={pageParams.filename}
onNavigate={navigateToPage}
/>;
case 'revision-purchase':
return <RevisionPurchasePage
jobNo={pageParams.job_no}
revision={pageParams.revision}
onNavigate={navigateToPage}
/>;
case 'quotes':
return <div style={{ padding: '32px' }}>💰 견적 관리 페이지 ( 구현 예정)</div>;
case 'procurement':
return <div style={{ padding: '32px' }}>🛒 구매 관리 페이지 ( 구현 예정)</div>;
case 'production':
return <div style={{ padding: '32px' }}>🏭 생산 관리 페이지 ( 구현 예정)</div>;
case 'shipment':
return <div style={{ padding: '32px' }}>🚚 출하 관리 페이지 ( 구현 예정)</div>;
case 'users':
return <div style={{ padding: '32px' }}>👥 사용자 관리 페이지 ( 구현 예정)</div>;
case 'system':
return <div style={{ padding: '32px' }}> 시스템 설정 페이지 ( 구현 예정)</div>;
default:
return <DashboardPage user={user} />;
}
};
if (isLoading) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f7fafc'
}}>
<div>로딩 ...</div>
</div>
);
}
if (!isAuthenticated) {
return <SimpleLogin onLoginSuccess={handleLoginSuccess} />;
}
return (
<Router>
<Routes>
<Route path="/" element={<ProjectSelectionPage />} />
{/* BOM 관리는 /bom-status로 통일 */}
<Route path="/bom-manager" element={<Navigate to="/bom-status" replace />} />
<Route path="/bom-status" element={<BOMStatusPage />} />
<Route path="/materials" element={<MaterialsPage />} />
<Route path="/purchase-confirmation" element={<PurchaseConfirmationPage />} />
<Route path="/material-comparison" element={<MaterialComparisonPage />} />
<Route path="/revision-purchase" element={<RevisionPurchasePage />} />
</Routes>
</Router>
<div style={{ display: 'flex', minHeight: '100vh' }}>
<NavigationMenu
user={user}
currentPage={currentPage}
onPageChange={(page) => navigateToPage(page, {})}
/>
{/* 메인 콘텐츠 영역 */}
<div style={{
flex: 1,
marginLeft: '280px', // 사이드바 너비만큼 여백
background: '#f7fafc'
}}>
{/* 상단 헤더 */}
<header style={{
background: 'white',
borderBottom: '1px solid #e2e8f0',
padding: '16px 32px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<h2 style={{
margin: '0',
fontSize: '18px',
fontWeight: '600',
color: '#2d3748'
}}>
{currentPage === 'dashboard' && '대시보드'}
{currentPage === 'projects' && '프로젝트 관리'}
{currentPage === 'bom' && 'BOM 관리'}
{currentPage === 'materials' && '자재 관리'}
{currentPage === 'quotes' && '견적 관리'}
{currentPage === 'procurement' && '구매 관리'}
{currentPage === 'production' && '생산 관리'}
{currentPage === 'shipment' && '출하 관리'}
{currentPage === 'users' && '사용자 관리'}
{currentPage === 'system' && '시스템 설정'}
</h2>
</div>
<button
onClick={handleLogout}
style={{
padding: '8px 16px',
background: '#e53e3e',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<span>🚪</span>
로그아웃
</button>
</header>
{/* 페이지 콘텐츠 */}
<main>
{renderCurrentPage()}
</main>
</div>
</div>
);
}

View File

@@ -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 (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f7fafc'
}}>
<div>사용자 정보를 불러오는 ...</div>
</div>
);
}
return (
<div style={{
minHeight: '100vh',
background: '#f7fafc',
fontFamily: 'Arial, sans-serif'
}}>
{/* 네비게이션 바 */}
<nav style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '16px 24px',
color: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '24px' }}>🚀</span>
<div>
<h1 style={{ margin: '0', fontSize: '20px', fontWeight: '700' }}>TK-MP System</h1>
<span style={{ fontSize: '12px', opacity: '0.9' }}>통합 프로젝트 관리</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '14px', fontWeight: '600' }}>{user.name}</div>
<div style={{ fontSize: '12px', opacity: '0.9' }}>
{user.role} · {user.access_level}
</div>
</div>
<button
onClick={handleLogout}
style={{
padding: '8px 16px',
background: 'rgba(255, 255, 255, 0.2)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
로그아웃
</button>
</div>
</nav>
{/* 메인 콘텐츠 */}
<main style={{ padding: '32px 24px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 환영 메시지 */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
marginBottom: '32px'
}}>
<h2 style={{
color: '#2d3748',
fontSize: '24px',
fontWeight: '700',
margin: '0 0 16px 0'
}}>
환영합니다, {user.name}! 🎉
</h2>
<p style={{ color: '#718096', fontSize: '16px', margin: '0' }}>
TK-MP 통합 프로젝트 관리 시스템에 성공적으로 로그인하셨습니다.
</p>
</div>
{/* 사용자 정보 카드 */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
marginBottom: '32px'
}}>
<h3 style={{
color: '#2d3748',
fontSize: '20px',
fontWeight: '600',
margin: '0 0 24px 0'
}}>
👤 사용자 정보
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '16px' }}>
<div>
<strong style={{ color: '#4a5568' }}>사용자명:</strong>
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.username}</div>
</div>
<div>
<strong style={{ color: '#4a5568' }}>이메일:</strong>
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.email}</div>
</div>
<div>
<strong style={{ color: '#4a5568' }}>역할:</strong>
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.role}</div>
</div>
<div>
<strong style={{ color: '#4a5568' }}>접근 레벨:</strong>
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.access_level}</div>
</div>
{user.department && (
<div>
<strong style={{ color: '#4a5568' }}>부서:</strong>
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.department}</div>
</div>
)}
{user.position && (
<div>
<strong style={{ color: '#4a5568' }}>직책:</strong>
<div style={{ color: '#2d3748', marginTop: '4px' }}>{user.position}</div>
</div>
)}
</div>
</div>
{/* 권한 정보 */}
<div style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)'
}}>
<h3 style={{
color: '#2d3748',
fontSize: '20px',
fontWeight: '600',
margin: '0 0 24px 0'
}}>
🔐 보유 권한
</h3>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
}}>
{user.permissions && user.permissions.length > 0 ? (
user.permissions.map(permission => (
<span
key={permission}
style={{
padding: '6px 12px',
background: '#e2e8f0',
color: '#4a5568',
borderRadius: '20px',
fontSize: '12px',
fontWeight: '500'
}}
>
{permission}
</span>
))
) : (
<span style={{ color: '#718096' }}>권한 정보가 없습니다.</span>
)}
</div>
</div>
{/* 다음 단계 안내 */}
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '16px',
padding: '32px',
color: 'white',
marginTop: '32px',
textAlign: 'center'
}}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '20px', fontWeight: '600' }}>
🚀 다음 단계
</h3>
<p style={{ margin: '0', fontSize: '16px', opacity: '0.9' }}>
이제 복잡한 인증 시스템과 네비게이션을 단계적으로 추가할 준비가 되었습니다!
</p>
</div>
</div>
</main>
</div>
);
};
export default SimpleDashboard;

View File

@@ -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 (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '20px',
fontFamily: 'Arial, sans-serif'
}}>
<div style={{
background: 'white',
borderRadius: '16px',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)',
padding: '40px',
width: '100%',
maxWidth: '400px'
}}>
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<h1 style={{ color: '#2d3748', fontSize: '28px', fontWeight: '700', margin: '0 0 8px 0' }}>
🚀 TK-MP System
</h1>
<p style={{ color: '#718096', fontSize: '14px', margin: '0' }}>
통합 프로젝트 관리 시스템
</p>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
color: '#2d3748',
fontWeight: '600',
fontSize: '14px',
marginBottom: '8px'
}}>
사용자명
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="사용자명을 입력하세요"
disabled={isLoading}
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e2e8f0',
borderRadius: '8px',
fontSize: '16px',
boxSizing: 'border-box'
}}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
color: '#2d3748',
fontWeight: '600',
fontSize: '14px',
marginBottom: '8px'
}}>
비밀번호
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="비밀번호를 입력하세요"
disabled={isLoading}
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e2e8f0',
borderRadius: '8px',
fontSize: '16px',
boxSizing: 'border-box'
}}
/>
</div>
{error && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 16px',
background: '#fed7d7',
border: '1px solid #feb2b2',
borderRadius: '8px',
color: '#c53030',
fontSize: '14px',
marginBottom: '20px'
}}>
<span></span>
{error}
</div>
)}
{success && (
<div style={{
padding: '12px 16px',
background: '#c6f6d5',
border: '1px solid #9ae6b4',
borderRadius: '8px',
color: '#2f855a',
fontSize: '14px',
marginBottom: '20px'
}}>
{success}
</div>
)}
<button
type="submit"
disabled={isLoading}
style={{
width: '100%',
padding: '14px 24px',
background: isLoading ? '#a0aec0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
marginTop: '8px'
}}
>
{isLoading ? '로그인 중...' : '로그인'}
</button>
</form>
<div style={{ marginTop: '32px', textAlign: 'center' }}>
<p style={{ color: '#718096', fontSize: '14px', margin: '0 0 16px 0' }}>
테스트 계정: admin / admin123 또는 testuser / test123
</p>
<div>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
TK-MP Project Management System v2.0
</small>
</div>
</div>
</div>
</div>
);
};
export default SimpleLogin;

43
frontend/src/TestApp.jsx Normal file
View File

@@ -0,0 +1,43 @@
import React from 'react';
const TestApp = () => {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
background: '#f7fafc',
color: '#2d3748',
fontFamily: 'Arial, sans-serif'
}}>
<div style={{
textAlign: 'center',
padding: '40px',
background: 'white',
borderRadius: '16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}>
<h1>🚀 TK-MP System</h1>
<p>시스템이 정상적으로 작동 중입니다!</p>
<div style={{ marginTop: '20px' }}>
<button
style={{
padding: '12px 24px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer'
}}
onClick={() => alert('테스트 성공!')}
>
테스트 버튼
</button>
</div>
</div>
</div>
);
};
export default TestApp;

View File

@@ -173,4 +173,7 @@ export function getMaterialPurchaseStatus(jobNo, revision = null, status = null)
return api.get('/materials/purchase-status', {
params: { job_no: jobNo, revision, status }
});
}
}
// Default export for convenience
export default api;

View File

@@ -0,0 +1,157 @@
import React from 'react';
const BOMFileTable = ({
files,
loading,
groupFilesByBOM,
handleViewMaterials,
openRevisionDialog,
handleDelete
}) => {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
로딩 ...
</div>
);
}
if (files.length === 0) {
return (
<div style={{
background: '#bee3f8',
border: '1px solid #63b3ed',
borderRadius: '8px',
padding: '12px 16px',
color: '#2c5282'
}}>
업로드된 BOM이 없습니다.
</div>
);
}
return (
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>BOM 이름</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>파일명</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>리비전</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>자재 </th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>업로드 일시</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>작업</th>
</tr>
</thead>
<tbody>
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
bomFiles.map((file, index) => (
<tr
key={file.id}
style={{
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
}}
>
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
<div style={{
fontWeight: index === 0 ? 'bold' : 'normal',
fontSize: '14px'
}}>
{file.bom_name || bomKey}
</div>
{index === 0 && bomFiles.length > 1 && (
<div style={{
fontSize: '12px',
color: '#4299e1',
marginTop: '4px'
}}>
최신 리비전 ( {bomFiles.length})
</div>
)}
</td>
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
{file.original_filename || file.filename}
</td>
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
<span style={{
background: index === 0 ? '#4299e1' : '#e2e8f0',
color: index === 0 ? 'white' : '#4a5568',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{file.revision || 'Rev.0'}
</span>
</td>
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
{file.parsed_count || file.material_count || 0}
</td>
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0', fontSize: '14px' }}>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
</td>
<td style={{ padding: '12px', borderBottom: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => handleViewMaterials(file)}
style={{
padding: '6px 12px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
🧮 구매수량 계산
</button>
{index === 0 && (
<button
onClick={() => openRevisionDialog(file.bom_name || bomKey, file.id)}
style={{
padding: '6px 12px',
background: 'white',
color: '#4299e1',
border: '1px solid #4299e1',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
리비전
</button>
)}
<button
onClick={() => handleDelete(file.id)}
style={{
padding: '6px 12px',
background: '#f56565',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
삭제
</button>
</div>
</td>
</tr>
))
))}
</tbody>
</table>
</div>
);
};
export default BOMFileTable;

View File

@@ -0,0 +1,118 @@
import React from 'react';
const BOMFileUpload = ({
bomName,
setBomName,
selectedFile,
setSelectedFile,
uploading,
handleUpload,
error
}) => {
return (
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px',
marginBottom: '24px'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 16px 0'
}}>
BOM 업로드
</h3>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
BOM 이름
</label>
<input
type="text"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="예: PIPING_BOM_A구역"
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px'
}}
/>
<p style={{
fontSize: '12px',
color: '#718096',
margin: '4px 0 0 0'
}}>
동일한 BOM 이름으로 재업로드 리비전이 자동 증가합니다
</p>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px'
}}>
<input
id="file-input"
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setSelectedFile(e.target.files[0])}
style={{ flex: 1 }}
/>
<button
onClick={handleUpload}
disabled={!selectedFile || !bomName.trim() || uploading}
style={{
padding: '12px 24px',
background: (!selectedFile || !bomName.trim() || uploading) ? '#e2e8f0' : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: (!selectedFile || !bomName.trim() || uploading) ? '#a0aec0' : 'white',
border: 'none',
borderRadius: '8px',
cursor: (!selectedFile || !bomName.trim() || uploading) ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
{uploading ? '업로드 중...' : '업로드'}
</button>
</div>
{selectedFile && (
<p style={{
fontSize: '14px',
color: '#718096',
margin: '0'
}}>
선택된 파일: {selectedFile.name}
</p>
)}
{error && (
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
marginTop: '16px',
color: '#c53030'
}}>
{error}
</div>
)}
</div>
);
};
export default BOMFileUpload;

View File

@@ -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;
}
}

View File

@@ -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 (
<nav className="navigation-bar">
<div className="nav-container">
{/* 로고 및 브랜드 */}
<div className="nav-brand">
<div className="brand-logo">🚀</div>
<div className="brand-text">
<h1>TK-MP System</h1>
<span>통합 프로젝트 관리</span>
</div>
</div>
{/* 모바일 메뉴 토글 */}
<button
className="mobile-menu-toggle"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<span></span>
<span></span>
<span></span>
</button>
{/* 메인 메뉴 */}
<div className={`nav-menu ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="menu-items">
{accessibleMenuItems.map(item => (
<button
key={item.id}
className={`menu-item ${currentPage === item.id ? 'active' : ''}`}
onClick={() => handleMenuClick(item)}
title={item.description}
>
<span className="menu-icon">{item.icon}</span>
<span className="menu-label">{item.label}</span>
{item.adminOnly && (
<span className="admin-badge">관리자</span>
)}
</button>
))}
</div>
{/* 사용자 메뉴 */}
<div className="user-menu-container">
<button
className="user-menu-trigger"
onClick={() => setShowUserMenu(!showUserMenu)}
>
<div className="user-avatar">
{user?.name?.charAt(0) || '👤'}
</div>
<div className="user-info">
<span className="user-name">{user?.name}</span>
<span className="user-role">
{getRoleDisplayName(user?.role)} · {getAccessLevelDisplayName(user?.access_level)}
</span>
</div>
<span className="dropdown-arrow"></span>
</button>
{showUserMenu && (
<div className="user-dropdown">
<div className="user-dropdown-header">
<div className="user-avatar-large">
{user?.name?.charAt(0) || '👤'}
</div>
<div className="user-details">
<div className="user-name">{user?.name}</div>
<div className="user-username">@{user?.username}</div>
<div className="user-email">{user?.email}</div>
<div className="user-meta">
<span className="role-badge role-{user?.role}">
{getRoleDisplayName(user?.role)}
</span>
<span className="access-badge access-{user?.access_level}">
{getAccessLevelDisplayName(user?.access_level)}
</span>
</div>
{user?.department && (
<div className="user-department">{user.department}</div>
)}
</div>
</div>
<div className="user-dropdown-menu">
<button className="dropdown-item">
<span className="item-icon">👤</span>
프로필 설정
</button>
<button className="dropdown-item">
<span className="item-icon">🔐</span>
비밀번호 변경
</button>
<button className="dropdown-item">
<span className="item-icon">🔔</span>
알림 설정
</button>
<div className="dropdown-divider"></div>
<button
className="dropdown-item logout-item"
onClick={handleLogout}
>
<span className="item-icon">🚪</span>
로그아웃
</button>
</div>
<div className="user-dropdown-footer">
<div className="permissions-info">
<span className="permissions-label">권한:</span>
<div className="permissions-list">
{user?.permissions?.slice(0, 3).map(permission => (
<span key={permission} className="permission-tag">
{permission}
</span>
))}
{user?.permissions?.length > 3 && (
<span className="permission-more">
+{user.permissions.length - 3}
</span>
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* 모바일 오버레이 */}
{isMobileMenuOpen && (
<div
className="mobile-overlay"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
</nav>
);
};
export default NavigationBar;

View File

@@ -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;
}

View File

@@ -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 (
<div className="navigation-menu">
{/* 모바일 햄버거 버튼 */}
<button
className="mobile-menu-toggle"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label="메뉴 토글"
>
<span></span>
<span></span>
<span></span>
</button>
{/* 메뉴 오버레이 (모바일) */}
{isMenuOpen && (
<div
className="menu-overlay"
onClick={() => setIsMenuOpen(false)}
/>
)}
{/* 사이드바 메뉴 */}
<nav className={`sidebar ${isMenuOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<div className="logo">
<span className="logo-icon">🚀</span>
<div className="logo-text">
<h2>TK-MP</h2>
<span>통합 관리 시스템</span>
</div>
</div>
</div>
<div className="menu-section">
<div className="menu-section-title">메인 메뉴</div>
<ul className="menu-list">
{menuItems.map(item => (
<li key={item.id} className="menu-item">
<button
className={`menu-button ${currentPage === item.id ? 'active' : ''}`}
onClick={() => handleMenuClick(item.id)}
title={item.description}
>
<span className="menu-icon">{item.icon}</span>
<span className="menu-title">{item.title}</span>
{currentPage === item.id && (
<span className="active-indicator"></span>
)}
</button>
</li>
))}
</ul>
</div>
{/* 사용자 정보 */}
<div className="sidebar-footer">
<div className="user-info">
<div className="user-avatar">
{user?.name?.charAt(0) || '?'}
</div>
<div className="user-details">
<div className="user-name">{user?.name}</div>
<div className="user-role">{user?.role}</div>
</div>
</div>
</div>
</nav>
</div>
);
};
export default NavigationMenu;

View File

@@ -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 (
<div className="loading-container" style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
background: '#f7fafc',
color: '#718096'
}}>
<div className="loading-spinner-large" style={{
width: '48px',
height: '48px',
border: '4px solid #e2e8f0',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '16px'
}}></div>
<p>인증 정보를 확인하는 ...</p>
</div>
);
}
// 인증되지 않은 경우
if (!isAuthenticated) {
return <LoginPage />;
}
// 특정 권한이 필요한 경우
if (requiredPermission && !hasPermission(requiredPermission)) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon">🔒</div>
<h2>접근 권한이 없습니다</h2>
<p> 페이지에 접근하기 위한 권한이 없습니다.</p>
<p className="permission-info">
필요한 권한: <code>{requiredPermission}</code>
</p>
<div className="user-info">
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
<p>역할: <strong>{user?.role}</strong></p>
<p>접근 레벨: <strong>{user?.access_level}</strong></p>
</div>
<button
className="back-button"
onClick={() => window.history.back()}
>
이전 페이지로 돌아가기
</button>
</div>
</div>
);
}
// 특정 역할이 필요한 경우
if (requiredRole && !hasRole(requiredRole)) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon">👤</div>
<h2>역할 권한이 없습니다</h2>
<p> 페이지에 접근하기 위한 역할 권한이 없습니다.</p>
<p className="role-info">
필요한 역할: <code>{requiredRole}</code>
</p>
<div className="user-info">
<p>현재 사용자: <strong>{user?.name}</strong> ({user?.username})</p>
<p>현재 역할: <strong>{user?.role}</strong></p>
</div>
<button
className="back-button"
onClick={() => window.history.back()}
>
이전 페이지로 돌아가기
</button>
</div>
</div>
);
}
// 모든 조건을 만족하는 경우 자식 컴포넌트 렌더링
return children;
};
// 관리자 전용 라우트
export const AdminRoute = ({ children, fallback = null }) => {
return (
<ProtectedRoute
requiredRole="admin"
fallback={fallback}
>
{children}
</ProtectedRoute>
);
};
// 시스템 관리자 전용 라우트
export const SystemRoute = ({ children, fallback = null }) => {
const { hasRole } = useAuth();
if (!hasRole('admin') && !hasRole('system')) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon"></div>
<h2>시스템 관리자 권한이 필요합니다</h2>
<p> 페이지는 시스템 관리자만 접근할 있습니다.</p>
</div>
</div>
);
}
return (
<ProtectedRoute>
{children}
</ProtectedRoute>
);
};
// 매니저 이상 권한 라우트
export const ManagerRoute = ({ children, fallback = null }) => {
const { isManager } = useAuth();
if (!isManager()) {
return fallback || (
<div className="access-denied-container">
<div className="access-denied-content">
<div className="access-denied-icon">👔</div>
<h2>관리자 권한이 필요합니다</h2>
<p> 페이지는 관리자 이상의 권한이 필요합니다.</p>
</div>
</div>
);
}
return (
<ProtectedRoute>
{children}
</ProtectedRoute>
);
};
export default ProtectedRoute;

View File

@@ -0,0 +1,82 @@
import React from 'react';
const RevisionUploadDialog = ({
revisionDialog,
setRevisionDialog,
revisionFile,
setRevisionFile,
handleRevisionUpload,
uploading
}) => {
if (!revisionDialog.open) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '500px',
width: '90%'
}}>
<h3 style={{ margin: '0 0 16px 0' }}>
리비전 업로드: {revisionDialog.bomName}
</h3>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setRevisionFile(e.target.files[0])}
style={{
width: '100%',
marginBottom: '16px',
padding: '8px'
}}
/>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}
style={{
padding: '8px 16px',
background: '#e2e8f0',
color: '#4a5568',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
취소
</button>
<button
onClick={handleRevisionUpload}
disabled={!revisionFile || uploading}
style={{
padding: '8px 16px',
background: (!revisionFile || uploading) ? '#e2e8f0' : '#4299e1',
color: (!revisionFile || uploading) ? '#a0aec0' : 'white',
border: 'none',
borderRadius: '6px',
cursor: (!revisionFile || uploading) ? 'not-allowed' : 'pointer'
}}
>
{uploading ? '업로드 중...' : '업로드'}
</button>
</div>
</div>
</div>
);
};
export default RevisionUploadDialog;

View File

@@ -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 (
<div>
{/* 드래그 앤 드롭 영역 */}
<div
style={{
border: `2px dashed ${dragActive ? '#667eea' : '#e2e8f0'}`,
borderRadius: '12px',
padding: '40px 20px',
textAlign: 'center',
background: dragActive ? '#f7fafc' : 'white',
transition: 'all 0.2s ease',
cursor: 'pointer',
marginBottom: '20px'
}}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => document.getElementById('file-input').click()}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
{uploading ? '⏳' : '📤'}
</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', marginBottom: '8px' }}>
{uploading ? '업로드 중...' : 'BOM 파일을 업로드하세요'}
</div>
<div style={{ fontSize: '14px', color: '#718096', marginBottom: '16px' }}>
파일을 드래그하거나 클릭하여 선택하세요
</div>
<div style={{ fontSize: '12px', color: '#a0aec0' }}>
지원 형식: Excel (.xlsx, .xls), CSV (.csv) | 최대 크기: 10MB
</div>
<input
id="file-input"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileSelect}
style={{ display: 'none' }}
disabled={uploading}
/>
</div>
{/* 업로드 진행률 */}
{uploading && (
<div style={{ marginBottom: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<span style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
업로드 진행률
</span>
<span style={{ fontSize: '14px', color: '#667eea' }}>
{uploadProgress}%
</span>
</div>
<div style={{
width: '100%',
height: '8px',
background: '#e2e8f0',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${uploadProgress}%`,
height: '100%',
background: 'linear-gradient(90deg, #667eea, #764ba2)',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)}
{/* 에러 메시지 */}
{error && (
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{ color: '#c53030', fontSize: '16px' }}></span>
<span style={{ color: '#c53030', fontSize: '14px' }}>{error}</span>
<button
onClick={() => setError('')}
style={{
marginLeft: 'auto',
background: 'none',
border: 'none',
color: '#c53030',
cursor: 'pointer',
fontSize: '16px'
}}
>
</button>
</div>
)}
{/* 업로드 성공 결과 */}
{uploadResult && uploadResult.success && (
<div style={{
background: '#c6f6d5',
border: '1px solid #68d391',
borderRadius: '12px',
padding: '20px',
marginBottom: '20px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<span style={{ color: '#2f855a', fontSize: '20px' }}></span>
<span style={{ color: '#2f855a', fontSize: '16px', fontWeight: '600' }}>
업로드 완료!
</span>
</div>
<div style={{ color: '#2f855a', fontSize: '14px', marginBottom: '16px' }}>
{uploadResult.message}
</div>
{/* 파일 정보 */}
<div style={{
background: 'white',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px'
}}>
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
📄 파일 정보
</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '12px' }}>
<div><strong>파일명:</strong> {uploadResult.file?.original_filename}</div>
<div><strong>분석된 자재:</strong> {uploadResult.file?.parsed_count}</div>
<div><strong>저장된 자재:</strong> {uploadResult.file?.saved_count}</div>
<div><strong>프로젝트:</strong> {uploadResult.job?.job_name}</div>
</div>
</div>
{/* 샘플 자재 미리보기 */}
{uploadResult.sampleMaterials && uploadResult.sampleMaterials.length > 0 && (
<div style={{
background: 'white',
borderRadius: '8px',
padding: '16px'
}}>
<h4 style={{ margin: '0 0 12px 0', color: '#2d3748', fontSize: '14px' }}>
🔧 자재 샘플 (처음 3)
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{uploadResult.sampleMaterials.map((material, index) => (
<div key={index} style={{
padding: '8px 12px',
background: '#f7fafc',
borderRadius: '6px',
fontSize: '12px',
color: '#4a5568'
}}>
<strong>{material.description || material.item_code}</strong>
{material.category && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#667eea',
color: 'white',
borderRadius: '3px',
fontSize: '10px'
}}>
{material.category}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
export default SimpleFileUpload;

View File

@@ -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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -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' }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: '16px'
}}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: color + '20',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px'
}}>
{icon}
</div>
<div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: '#2d3748',
marginBottom: '4px'
}}>
{value}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{title}
</div>
</div>
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
🔧 BOM 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
Bill of Materials 업로드, 분석 관리를 수행하세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<StatCard
title="총 업로드 파일"
value={stats.totalFiles}
icon="📄"
color="#667eea"
/>
<StatCard
title="분석된 자재"
value={stats.totalMaterials}
icon="🔧"
color="#48bb78"
/>
<StatCard
title="활성 프로젝트"
value={projects.length}
icon="📋"
color="#ed8936"
/>
</div>
{/* 탭 네비게이션 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
borderBottom: '1px solid #e2e8f0'
}}>
{[
{ id: 'upload', label: '📤 파일 업로드', icon: '📤' },
{ id: 'files', label: '📁 파일 관리', icon: '📁' },
{ id: 'materials', label: '🔧 자재 목록', icon: '🔧' },
{ id: 'analysis', label: '📊 분석 결과', icon: '📊' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
flex: 1,
padding: '16px 20px',
background: activeTab === tab.id ? '#f7fafc' : 'transparent',
border: 'none',
borderBottom: activeTab === tab.id ? '2px solid #667eea' : '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
fontWeight: activeTab === tab.id ? '600' : '500',
color: activeTab === tab.id ? '#667eea' : '#4a5568',
transition: 'all 0.2s ease'
}}
>
{tab.label}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div style={{ padding: '24px' }}>
{activeTab === 'upload' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📤 BOM 파일 업로드
</h3>
{/* 프로젝트 선택 */}
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
프로젝트 선택
</label>
<select
value={selectedProject?.job_no || ''}
onChange={(e) => {
const project = projects.find(p => p.job_no === e.target.value);
setSelectedProject(project);
}}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="">프로젝트를 선택하세요</option>
{projects.map(project => (
<option key={project.job_no} value={project.job_no}>
{project.job_no} - {project.job_name}
</option>
))}
</select>
</div>
{selectedProject ? (
<div>
<div style={{
background: '#f7fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px'
}}>
<h4 style={{ margin: '0 0 8px 0', color: '#2d3748' }}>
선택된 프로젝트: {selectedProject.job_name}
</h4>
<p style={{ margin: '0', fontSize: '14px', color: '#718096' }}>
Job No: {selectedProject.job_no} |
고객사: {selectedProject.client_name} |
상태: {selectedProject.status}
</p>
</div>
<SimpleFileUpload
selectedProject={selectedProject}
onUploadComplete={handleFileUpload}
/>
</div>
) : (
<div style={{
textAlign: 'center',
padding: '40px',
color: '#718096'
}}>
먼저 프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'files' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📁 업로드된 파일 목록
</h3>
{selectedProject ? (
loading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
파일 목록을 불러오는 ...
</div>
) : files.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{files.map((file, index) => (
<div key={index} style={{
background: '#f7fafc',
border: '1px solid #e2e8f0',
borderRadius: '8px',
padding: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<div style={{ fontWeight: '600', color: '#2d3748' }}>
{file.original_filename || file.filename}
</div>
<div style={{ fontSize: '12px', color: '#718096' }}>
업로드: {new Date(file.created_at).toLocaleString()} |
자재 : {file.parsed_count || 0}
</div>
</div>
<div style={{
padding: '4px 8px',
background: '#48bb78',
color: 'white',
borderRadius: '4px',
fontSize: '12px'
}}>
{file.revision || 'Rev.0'}
</div>
</div>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
업로드된 파일이 없습니다.
</div>
)
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'materials' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
🔧 자재 목록
</h3>
{selectedProject ? (
<MaterialList
selectedProject={selectedProject}
key={selectedProject.job_no} // 프로젝트 변경 컴포넌트 재렌더링
/>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#718096' }}>
프로젝트를 선택해주세요.
</div>
)}
</div>
)}
{activeTab === 'analysis' && (
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📊 분석 결과
</h3>
<div style={{
background: '#fff3cd',
border: '1px solid #ffeaa7',
borderRadius: '8px',
padding: '16px',
textAlign: 'center'
}}>
<div style={{ fontSize: '16px', color: '#856404' }}>
🚧 분석 결과 페이지는 구현될 예정입니다.
</div>
<div style={{ fontSize: '14px', color: '#856404', marginTop: '8px' }}>
자재 분류, 통계, 비교 분석 기능이 추가됩니다.
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default BOMManagementPage;

View File

@@ -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 (
<Box sx={{ maxWidth: 900, mx: 'auto', mt: 4 }}>
<Button variant="outlined" onClick={() => navigate('/')} sx={{ mb: 2 }}>
뒤로가기
</Button>
<Typography variant="h4" gutterBottom>📊 BOM 관리 시스템</Typography>
{jobNo && jobName && (
<Typography variant="h6" color="primary" sx={{ mb: 3 }}>
{jobNo} - {jobName}
</Typography>
)}
{/* 파일 업로드 폼 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> BOM 업로드</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="BOM 이름"
value={bomName}
onChange={(e) => setBomName(e.target.value)}
placeholder="예: PIPING_BOM_A구역"
required
size="small"
helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다"
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<input
id="file-input"
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setSelectedFile(e.target.files[0])}
style={{ flex: 1 }}
/>
<Button
variant="contained"
onClick={handleUpload}
disabled={!selectedFile || !bomName.trim() || uploading}
>
{uploading ? '업로드 중...' : '업로드'}
</Button>
</Box>
{selectedFile && (
<Typography variant="body2" color="textSecondary">
선택된 파일: {selectedFile.name}
</Typography>
)}
</Box>
</Paper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>업로드된 BOM 목록</Typography>
{loading && <CircularProgress />}
{!loading && files.length === 0 && (
<Alert severity="info">업로드된 BOM이 없습니다.</Alert>
)}
{!loading && files.length > 0 && (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>BOM 이름</TableCell>
<TableCell>파일명</TableCell>
<TableCell>리비전</TableCell>
<TableCell>자재 </TableCell>
<TableCell>업로드 일시</TableCell>
<TableCell>작업</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => (
bomFiles.map((file, index) => (
<TableRow key={file.id} sx={{
backgroundColor: index === 0 ? 'rgba(25, 118, 210, 0.08)' : 'rgba(0, 0, 0, 0.02)'
}}>
<TableCell>
<Typography variant="body2" fontWeight={index === 0 ? 'bold' : 'normal'}>
{file.bom_name || bomKey}
</Typography>
{index === 0 && bomFiles.length > 1 && (
<Typography variant="caption" color="primary">
(최신 리비전)
</Typography>
)}
{index > 0 && (
<Typography variant="caption" color="textSecondary">
(이전 버전)
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.filename || file.original_filename}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
color={index === 0 ? 'primary' : 'textSecondary'}
fontWeight={index === 0 ? 'bold' : 'normal'}
>
{file.revision || 'Rev.0'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.parsed_count || 0}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color={index === 0 ? 'textPrimary' : 'textSecondary'}>
{file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'}
</Typography>
</TableCell>
<TableCell>
<Button
size="small"
variant={index === 0 ? "contained" : "outlined"}
onClick={() => navigate(`/materials?file_id=${file.id}&job_no=${jobNo}&filename=${encodeURIComponent(file.filename || file.original_filename)}`)}
sx={{ mr: 1 }}
>
자재확인
</Button>
{index === 0 && (
<Button
size="small"
variant="outlined"
color="primary"
onClick={() => setRevisionDialog({
open: true,
bomName: file.bom_name || bomKey,
parentId: file.id
})}
sx={{ mr: 1 }}
>
리비전
</Button>
)}
{file.revision !== 'Rev.0' && index < 3 && (
<>
<Button
size="small"
variant="outlined"
color="secondary"
onClick={() => navigate(`/material-comparison?job_no=${jobNo}&revision=${file.revision}&filename=${encodeURIComponent(file.original_filename)}`)}
sx={{ mr: 1 }}
>
비교
</Button>
<Button
size="small"
variant="outlined"
color="success"
onClick={() => navigate(`/revision-purchase?job_no=${jobNo}&current_revision=${file.revision}&bom_name=${encodeURIComponent(file.bom_name || bomKey)}`)}
sx={{ mr: 1 }}
>
구매 필요
</Button>
</>
)}
<Button
size="small"
color="error"
onClick={async () => {
if (confirm(`정말 "${file.revision || 'Rev.0'}"을 삭제하시겠습니까?`)) {
try {
const response = await deleteFileApi(file.id);
if (response.data.success) {
fetchFiles();
alert('삭제되었습니다.');
} else {
alert('삭제 실패: ' + (response.data.message || '알 수 없는 오류'));
}
} catch (e) {
console.error('삭제 오류:', e);
alert('삭제 중 오류가 발생했습니다.');
}
}
}}
>
삭제
</Button>
</TableCell>
</TableRow>
))
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* 리비전 업로드 다이얼로그 */}
<Dialog open={revisionDialog.open} onClose={() => setRevisionDialog({ open: false, bomName: '', parentId: null })}>
<DialogTitle>리비전 업로드</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
BOM 이름: <strong>{revisionDialog.bomName}</strong>
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다.
</Typography>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => setRevisionFile(e.target.files[0])}
style={{ marginTop: 16 }}
/>
{revisionFile && (
<Typography variant="body2" sx={{ mt: 1 }}>
선택된 파일: {revisionFile.name}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => {
setRevisionDialog({ open: false, bomName: '', parentId: null });
setRevisionFile(null);
}}>
취소
</Button>
<Button
variant="contained"
onClick={handleRevisionUpload}
disabled={!revisionFile || uploading}
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<button
onClick={() => onNavigate && onNavigate('bom')}
style={{
padding: '8px 16px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
cursor: 'pointer',
marginBottom: '16px'
}}
>
{uploading ? '업로드 중...' : '업로드'}
</Button>
</DialogActions>
</Dialog>
</Box>
뒤로가기
</button>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📊 BOM 관리 시스템
</h1>
{jobNo && jobName && (
<h2 style={{
fontSize: '20px',
fontWeight: '600',
color: '#4299e1',
margin: '0 0 24px 0'
}}>
{jobNo} - {jobName}
</h2>
)}
</div>
{/* 파일 업로드 컴포넌트 */}
<BOMFileUpload
bomName={bomName}
setBomName={setBomName}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
uploading={uploading}
handleUpload={handleUpload}
error={error}
/>
{/* BOM 목록 */}
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '32px 0 16px 0'
}}>
업로드된 BOM 목록
</h3>
{/* 파일 테이블 컴포넌트 */}
<BOMFileTable
files={files}
loading={loading}
groupFilesByBOM={groupFilesByBOM}
handleViewMaterials={handleViewMaterials}
openRevisionDialog={openRevisionDialog}
handleDelete={handleDelete}
/>
{/* 리비전 업로드 다이얼로그 */}
<RevisionUploadDialog
revisionDialog={revisionDialog}
setRevisionDialog={setRevisionDialog}
revisionFile={revisionFile}
setRevisionFile={setRevisionFile}
handleRevisionUpload={handleRevisionUpload}
uploading={uploading}
/>
{/* 구매 수량 계산 결과 모달 */}
{purchaseModal.open && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '1000px',
maxHeight: '80vh',
overflow: 'auto',
margin: '20px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '700',
color: '#2d3748',
margin: 0
}}>
🧮 구매 수량 계산 결과
</h3>
<button
onClick={() => setPurchaseModal({ open: false, data: null, fileInfo: null })}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
padding: '8px 12px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
<div style={{ marginBottom: '16px', color: '#4a5568' }}>
<div><strong>프로젝트:</strong> {purchaseModal.fileInfo?.job_no}</div>
<div><strong>BOM:</strong> {purchaseModal.fileInfo?.bom_name}</div>
<div><strong>리비전:</strong> {purchaseModal.fileInfo?.revision || 'Rev.0'}</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
</tr>
</thead>
<tbody>
{purchaseModal.data?.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontSize: '14px' }}>
<span style={{
background: getCategoryColor(item.category),
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{item.category}
</span>
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.specification}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.size_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#fef7e0',
color: '#92400e',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.material_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
{item.category === 'PIPE' ?
`${Math.round(item.bom_quantity)}mm` :
item.bom_quantity
}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
{item.category === 'PIPE' ?
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
item.calculated_qty
}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.unit}
</td>
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
{item.category === 'PIPE' && (
<div>
<div>절단수: {item.cutting_count}</div>
<div>절단손실: {item.cutting_loss}mm</div>
<div>활용률: {Math.round(item.utilization_rate)}%</div>
</div>
)}
{item.category !== 'PIPE' && item.safety_factor && (
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{
marginTop: '24px',
padding: '16px',
background: '#f7fafc',
borderRadius: '8px',
fontSize: '14px',
color: '#4a5568'
}}>
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
<div> <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
<div> <strong>FITTING:</strong> BOM 수량 그대로</div>
<div> <strong>VALVE:</strong> BOM 수량 그대로</div>
<div> <strong>BOLT:</strong> 5% 여유율 4 배수 올림</div>
<div> <strong>GASKET:</strong> 5 배수 올림</div>
<div> <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default BOMStatusPage;
export default BOMStatusPage;

View File

@@ -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' }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}}
onMouseEnter={(e) => {
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)';
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{
fontSize: '14px',
color: '#718096',
marginBottom: '8px',
fontWeight: '500'
}}>
{title}
</div>
<div style={{
fontSize: '32px',
fontWeight: '700',
color: '#2d3748'
}}>
{value}
</div>
</div>
<div style={{
fontSize: '32px',
opacity: 0.8
}}>
{icon}
</div>
</div>
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
안녕하세요, {user?.name}! 👋
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
오늘도 TK-MP 시스템과 함께 효율적인 업무를 시작해보세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '24px',
marginBottom: '32px'
}}>
<StatCard
title="전체 프로젝트"
value={stats.totalProjects}
icon="📋"
color="#667eea"
/>
<StatCard
title="진행중인 프로젝트"
value={stats.activeProjects}
icon="🚀"
color="#48bb78"
/>
<StatCard
title="완료된 프로젝트"
value={stats.completedProjects}
icon="✅"
color="#38b2ac"
/>
<StatCard
title="등록된 자재"
value={stats.totalMaterials}
icon="📦"
color="#ed8936"
/>
<StatCard
title="대기중인 견적"
value={stats.pendingQuotes}
icon="💰"
color="#9f7aea"
/>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
gap: '24px'
}}>
{/* 최근 활동 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
📈 최근 활동
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{stats.recentActivities.map(activity => (
<div key={activity.id} style={{
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
padding: '12px',
borderRadius: '8px',
background: '#f7fafc',
border: '1px solid #e2e8f0'
}}>
<span style={{ fontSize: '16px' }}>
{getActivityIcon(activity.type)}
</span>
<div style={{ flex: 1 }}>
<div style={{
fontSize: '14px',
color: '#2d3748',
marginBottom: '4px'
}}>
{activity.message}
</div>
<div style={{
fontSize: '12px',
color: '#718096'
}}>
{activity.time}
</div>
</div>
</div>
))}
</div>
</div>
{/* 빠른 작업 */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 20px 0'
}}>
빠른 작업
</h3>
<div style={{ display: 'grid', gap: '12px' }}>
{[
{ title: '새 프로젝트 등록', icon: '', color: '#667eea' },
{ title: 'BOM 업로드', icon: '📤', color: '#48bb78' },
{ title: '견적서 작성', icon: '📝', color: '#ed8936' },
{ title: '자재 검색', icon: '🔍', color: '#38b2ac' }
].map((action, index) => (
<button key={index} style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: 'transparent',
border: '1px solid #e2e8f0',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '14px',
color: '#4a5568'
}}
onMouseEnter={(e) => {
e.target.style.background = '#f7fafc';
e.target.style.borderColor = action.color;
}}
onMouseLeave={(e) => {
e.target.style.background = 'transparent';
e.target.style.borderColor = '#e2e8f0';
}}>
<span style={{ fontSize: '16px' }}>{action.icon}</span>
<span>{action.title}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@@ -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%;
}
}

View File

@@ -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 (
<div className="job-registration-page">
<div className="job-registration-container">
<header className="page-header">
<button
className="back-button"
onClick={() => navigate('/')}
>
메인으로 돌아가기
</button>
<h1>프로젝트 기본정보 등록</h1>
<p>새로운 프로젝트의 Job No. 기본 정보를 입력해주세요</p>
</header>
<form className="registration-form" onSubmit={handleSubmit}>
<div className="form-grid">
<div className="form-group">
<label htmlFor="jobNo" className="required">Job No.</label>
<input
type="text"
id="jobNo"
name="jobNo"
value={formData.jobNo}
onChange={handleInputChange}
placeholder="예: TK-2025-001"
className={errors.jobNo ? 'error' : ''}
/>
{errors.jobNo && <span className="error-message">{errors.jobNo}</span>}
</div>
<div className="form-group">
<label htmlFor="projectName" className="required">프로젝트명</label>
<input
type="text"
id="projectName"
name="projectName"
value={formData.projectName}
onChange={handleInputChange}
placeholder="프로젝트명을 입력하세요"
className={errors.projectName ? 'error' : ''}
/>
{errors.projectName && <span className="error-message">{errors.projectName}</span>}
</div>
<div className="form-group">
<label htmlFor="clientName" className="required">고객사명</label>
<input
type="text"
id="clientName"
name="clientName"
value={formData.clientName}
onChange={handleInputChange}
placeholder="고객사명을 입력하세요"
className={errors.clientName ? 'error' : ''}
/>
{errors.clientName && <span className="error-message">{errors.clientName}</span>}
</div>
<div className="form-group">
<label htmlFor="location">프로젝트 위치</label>
<input
type="text"
id="location"
name="location"
value={formData.location}
onChange={handleInputChange}
placeholder="예: 울산광역시 남구"
/>
</div>
<div className="form-group">
<label htmlFor="projectType">프로젝트 유형</label>
<div className="project-type-container">
<select
id="projectType"
name="projectType"
value={formData.projectType}
onChange={handleInputChange}
>
{projectTypes.map(type => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<div className="project-type-actions">
<button
type="button"
className="add-type-btn"
onClick={() => setShowAddProjectType(true)}
title="프로젝트 유형 추가"
>
+
</button>
{projectTypes.length > 1 && (
<button
type="button"
className="remove-type-btn"
onClick={() => removeProjectType(formData.projectType)}
title="현재 선택된 유형 삭제"
>
-
</button>
)}
</div>
</div>
{showAddProjectType && (
<div className="add-project-type-form">
<input
type="text"
value={newProjectType}
onChange={(e) => setNewProjectType(e.target.value)}
placeholder="새 프로젝트 유형 입력"
onKeyPress={(e) => e.key === 'Enter' && addProjectType()}
/>
<button type="button" onClick={addProjectType}>추가</button>
<button type="button" onClick={() => setShowAddProjectType(false)}>취소</button>
</div>
)}
</div>
<div className="form-group">
<label htmlFor="status">프로젝트 상태</label>
<select
id="status"
name="status"
value={formData.status}
onChange={handleInputChange}
>
{statusOptions.map(status => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="contractDate">수주일</label>
<input
type="date"
id="contractDate"
name="contractDate"
value={formData.contractDate}
onChange={handleInputChange}
/>
</div>
<div className="form-group">
<label htmlFor="deliveryDate">납기일</label>
<input
type="date"
id="deliveryDate"
name="deliveryDate"
value={formData.deliveryDate}
onChange={handleInputChange}
className={errors.deliveryDate ? 'error' : ''}
/>
{errors.deliveryDate && <span className="error-message">{errors.deliveryDate}</span>}
</div>
<div className="form-group">
<label htmlFor="deliveryMethod">납품 방법</label>
<select
id="deliveryMethod"
name="deliveryMethod"
value={formData.deliveryMethod}
onChange={handleInputChange}
>
<option value="">납품 방법 선택</option>
<option value="FOB">FOB (Free On Board)</option>
<option value="CIF">CIF (Cost, Insurance and Freight)</option>
<option value="EXW">EXW (Ex Works)</option>
<option value="DDP">DDP (Delivered Duty Paid)</option>
<option value="직접납품">직접납품</option>
<option value="택배">택배</option>
<option value="기타">기타</option>
</select>
</div>
<div className="form-group full-width">
<label htmlFor="description">프로젝트 설명</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="프로젝트에 대한 상세 설명을 입력하세요"
rows="4"
/>
</div>
</div>
<div className="form-actions">
<button
type="button"
className="cancel-button"
onClick={() => navigate('/')}
>
취소
</button>
<button
type="submit"
className="submit-button"
disabled={loading}
>
{loading ? '등록 중...' : '프로젝트 등록'}
</button>
</div>
</form>
</div>
</div>
);
};
export default JobRegistrationPage;

View File

@@ -1,15 +1,12 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Button, CircularProgress, Alert } from '@mui/material';
import { fetchJobs } from '../api';
import { useNavigate } from 'react-router-dom';
const JobSelectionPage = () => {
const JobSelectionPage = ({ onJobSelect }) => {
const [jobs, setJobs] = useState([]);
const [selectedJobNo, setSelectedJobNo] = useState('');
const [selectedJobName, setSelectedJobName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
useEffect(() => {
async function loadJobs() {
@@ -39,47 +36,123 @@ const JobSelectionPage = () => {
};
const handleConfirm = () => {
if (selectedJobNo && selectedJobName) {
navigate(`/bom-status?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`);
if (selectedJobNo && selectedJobName && onJobSelect) {
onJobSelect(selectedJobNo, selectedJobName);
}
};
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
<Typography variant="h4" gutterBottom>프로젝트 선택</Typography>
{loading && <CircularProgress sx={{ mt: 4 }} />}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
<InputLabel>프로젝트</InputLabel>
<Select
value={selectedJobNo}
label="프로젝트"
onChange={handleSelect}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_no} value={job.job_no}>
{job.job_no} ({job.job_name})
</MenuItem>
))}
</Select>
</FormControl>
{selectedJobNo && (
<Alert severity="info" sx={{ mt: 3 }}>
선택된 프로젝트: <b>{selectedJobNo} ({selectedJobName})</b>
</Alert>
)}
<Button
variant="contained"
sx={{ mt: 4, minWidth: 120 }}
disabled={!selectedJobNo}
onClick={handleConfirm}
>
확인
</Button>
</Box>
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📋 프로젝트 선택
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0 0 32px 0'
}}>
BOM 관리할 프로젝트를 선택하세요.
</p>
{loading && (
<div style={{ textAlign: 'center', padding: '40px' }}>
로딩 ...
</div>
)}
{error && (
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
color: '#c53030'
}}>
{error}
</div>
)}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px'
}}>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
프로젝트 선택
</label>
<select
value={selectedJobNo}
onChange={handleSelect}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white',
marginBottom: '16px'
}}
>
<option value="">프로젝트를 선택하세요</option>
{jobs.map(job => (
<option key={job.job_no} value={job.job_no}>
{job.job_no} - {job.job_name}
</option>
))}
</select>
{selectedJobNo && selectedJobName && (
<div style={{
background: '#c6f6d5',
border: '1px solid #68d391',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '20px',
color: '#2f855a'
}}>
선택된 프로젝트: <strong>{selectedJobNo} - {selectedJobName}</strong>
</div>
)}
<button
onClick={handleConfirm}
disabled={!selectedJobNo}
style={{
width: '100%',
padding: '12px 24px',
background: selectedJobNo ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : '#e2e8f0',
color: selectedJobNo ? 'white' : '#a0aec0',
border: 'none',
borderRadius: '8px',
cursor: selectedJobNo ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: '600'
}}
>
확인
</button>
</div>
</div>
</div>
);
};
export default JobSelectionPage;
export default JobSelectionPage;

View File

@@ -0,0 +1,219 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
color: #2d3748;
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.login-header p {
color: #718096;
font-size: 14px;
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
color: #2d3748;
font-weight: 600;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 16px;
transition: all 0.2s ease;
background: #f7fafc;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fed7d7;
border: 1px solid #feb2b2;
border-radius: 8px;
color: #c53030;
font-size: 14px;
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.error-icon {
font-size: 16px;
}
.login-button {
padding: 14px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.login-button:active:not(:disabled) {
transform: translateY(0);
}
.login-button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.login-footer {
margin-top: 32px;
text-align: center;
}
.login-footer p {
color: #718096;
font-size: 14px;
margin: 0 0 16px 0;
}
.system-info small {
color: #a0aec0;
font-size: 12px;
}
/* 반응형 디자인 */
@media (max-width: 480px) {
.login-container {
padding: 16px;
}
.login-card {
padding: 24px;
}
.login-header h1 {
font-size: 24px;
}
}
/* 다크모드 지원 */
@media (prefers-color-scheme: dark) {
.login-card {
background: #1a202c;
color: white;
}
.login-header h1 {
color: white;
}
.login-header p {
color: #a0aec0;
}
.form-group label {
color: #e2e8f0;
}
.form-group input {
background: #2d3748;
border-color: #4a5568;
color: white;
}
.form-group input:focus {
background: #2d3748;
border-color: #667eea;
}
}

View File

@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import './LoginPage.css';
const LoginPage = () => {
const [formData, setFormData] = useState({
username: '',
password: ''
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
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 {
await login(formData.username, formData.password);
} catch (err) {
setError(err.message || '로그인에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>🚀 TK-MP System</h1>
<p>통합 프로젝트 관리 시스템</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="username">사용자명</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="사용자명을 입력하세요"
disabled={isLoading}
autoComplete="username"
/>
</div>
<div className="form-group">
<label htmlFor="password">비밀번호</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="비밀번호를 입력하세요"
disabled={isLoading}
autoComplete="current-password"
/>
</div>
{error && (
<div className="error-message">
<span className="error-icon"></span>
{error}
</div>
)}
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? (
<>
<span className="loading-spinner"></span>
로그인 ...
</>
) : (
'로그인'
)}
</button>
</form>
<div className="login-footer">
<p>계정이 없으신가요? 관리자에게 문의하세요.</p>
<div className="system-info">
<small>TK-MP Project Management System v2.0</small>
</div>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,215 @@
.main-page {
min-height: 100vh;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.main-container {
max-width: 1200px;
width: 100%;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.main-header {
text-align: center;
padding: 60px 40px;
background: white;
border-bottom: 1px solid #e2e8f0;
}
.main-header h1 {
font-size: 2.25rem;
color: #1a202c;
margin: 0 0 12px 0;
font-weight: 600;
letter-spacing: -0.025em;
}
.main-header p {
font-size: 1rem;
color: #64748b;
margin: 0;
font-weight: 400;
}
.main-content {
padding: 48px;
}
.banner-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 60px;
}
.main-banner {
background: #ffffff;
border-radius: 8px;
padding: 32px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid #e2e8f0;
display: flex;
align-items: flex-start;
gap: 24px;
min-height: 160px;
}
.main-banner:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08);
border-color: #cbd5e0;
}
.job-registration-banner:hover {
border-color: #10b981;
}
.bom-management-banner:hover {
border-color: #3b82f6;
}
.banner-icon {
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
margin-top: 4px;
}
.job-registration-banner .banner-icon {
background: #10b981;
}
.bom-management-banner .banner-icon {
background: #3b82f6;
}
.banner-content {
flex: 1;
}
.banner-content h2 {
font-size: 1.25rem;
color: #1a202c;
margin: 0 0 8px 0;
font-weight: 600;
letter-spacing: -0.025em;
}
.banner-content p {
color: #64748b;
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 16px 0;
}
.banner-action {
color: #475569;
font-weight: 500;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 4px;
}
.job-registration-banner .banner-action {
color: #10b981;
}
.bom-management-banner .banner-action {
color: #3b82f6;
}
.feature-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-top: 48px;
padding-top: 48px;
border-top: 1px solid #f1f5f9;
}
.feature-item {
text-align: left;
padding: 24px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.feature-item h3 {
font-size: 1rem;
color: #1a202c;
margin: 0 0 8px 0;
font-weight: 600;
}
.feature-item p {
color: #64748b;
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.main-footer {
text-align: center;
padding: 24px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.main-footer p {
color: #64748b;
font-size: 0.875rem;
margin: 0;
font-weight: 400;
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.main-page {
padding: 10px;
}
.main-header h1 {
font-size: 2rem;
}
.banner-container {
grid-template-columns: 1fr;
gap: 20px;
}
.main-banner {
flex-direction: column;
text-align: center;
padding: 25px 20px;
min-height: auto;
}
.banner-icon {
margin-bottom: 10px;
}
.feature-info {
grid-template-columns: 1fr;
gap: 20px;
}
.main-content {
padding: 25px 20px;
}
}

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import './MainPage.css';
const MainPage = () => {
const navigate = useNavigate();
return (
<div className="main-page">
<div className="main-container">
<header className="main-header">
<h1>TK Material Planning System</h1>
<p>자재 계획 BOM 관리 시스템</p>
</header>
<div className="main-content">
<div className="banner-container">
<div
className="main-banner job-registration-banner"
onClick={() => navigate('/job-registration')}
>
<div className="banner-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
<div className="banner-content">
<h2>기본정보 등록</h2>
<p>새로운 프로젝트의 Job No. 기본 정보를 등록합니다</p>
<div className="banner-action">등록하기 </div>
</div>
</div>
<div
className="main-banner bom-management-banner"
onClick={() => navigate('/project-selection')}
>
<div className="banner-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M7 2v4"/>
<path d="M17 2v4"/>
<path d="M14 12h.01"/>
<path d="M10 12h.01"/>
<path d="M16 16h.01"/>
<path d="M12 16h.01"/>
<path d="M8 16h.01"/>
</svg>
</div>
<div className="banner-content">
<h2>BOM 관리</h2>
<p>기존 프로젝트의 BOM 자료를 관리하고 분석합니다</p>
<div className="banner-action">관리하기 </div>
</div>
</div>
</div>
<div className="feature-info">
<div className="feature-item">
<h3>📊 자재 분석</h3>
<p>엑셀 파일 업로드를 통한 자동 자재 분류 분석</p>
</div>
<div className="feature-item">
<h3>💰 구매 최적화</h3>
<p>리비전별 자재 비교 구매 확정 관리</p>
</div>
<div className="feature-item">
<h3>🔧 Tubing 관리</h3>
<p>제조사별 튜빙 규격 품목번호 통합 관리</p>
</div>
</div>
</div>
<footer className="main-footer">
<p>&copy; 2025 Technical Korea. All rights reserved.</p>
</footer>
</div>
</div>
);
};
export default MainPage;

View File

@@ -0,0 +1,486 @@
import React, { useState, useEffect } from 'react';
import MaterialList from '../components/MaterialList';
import { fetchMaterials } from '../api';
const MaterialsManagementPage = ({ user }) => {
const [materials, setMaterials] = useState([]);
const [filteredMaterials, setFilteredMaterials] = useState([]);
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState({
project: '',
category: '',
status: '',
search: ''
});
const [stats, setStats] = useState({
totalMaterials: 0,
categorizedMaterials: 0,
uncategorizedMaterials: 0,
categories: {}
});
useEffect(() => {
loadProjects();
loadAllMaterials();
}, []);
useEffect(() => {
applyFilters();
}, [materials, filters]);
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 loadAllMaterials = async () => {
setLoading(true);
try {
// 기존 API 함수 사용 - 모든 자재 데이터 로딩
const response = await fetchMaterials({ limit: 10000 }); // 충분히 큰 limit
const materialsData = response.data?.materials || [];
setMaterials(materialsData);
calculateStats(materialsData);
} catch (error) {
console.error('자재 데이터 로딩 실패:', error);
} finally {
setLoading(false);
}
};
const calculateStats = (materialsData) => {
const totalMaterials = materialsData.length;
const categorizedMaterials = materialsData.filter(m => m.classified_category && m.classified_category !== 'Unknown').length;
const uncategorizedMaterials = totalMaterials - categorizedMaterials;
// 카테고리별 통계
const categories = {};
materialsData.forEach(material => {
const category = material.classified_category || 'Unknown';
categories[category] = (categories[category] || 0) + 1;
});
setStats({
totalMaterials,
categorizedMaterials,
uncategorizedMaterials,
categories
});
};
const applyFilters = () => {
let filtered = [...materials];
// 프로젝트 필터
if (filters.project) {
filtered = filtered.filter(m => m.job_no === filters.project);
}
// 카테고리 필터
if (filters.category) {
filtered = filtered.filter(m => m.classified_category === filters.category);
}
// 상태 필터
if (filters.status) {
if (filters.status === 'categorized') {
filtered = filtered.filter(m => m.classified_category && m.classified_category !== 'Unknown');
} else if (filters.status === 'uncategorized') {
filtered = filtered.filter(m => !m.classified_category || m.classified_category === 'Unknown');
}
}
// 검색 필터
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
filtered = filtered.filter(m =>
(m.original_description && m.original_description.toLowerCase().includes(searchTerm)) ||
(m.size_spec && m.size_spec.toLowerCase().includes(searchTerm)) ||
(m.classified_category && m.classified_category.toLowerCase().includes(searchTerm))
);
}
setFilteredMaterials(filtered);
};
const handleFilterChange = (filterType, value) => {
setFilters(prev => ({
...prev,
[filterType]: value
}));
};
const clearFilters = () => {
setFilters({
project: '',
category: '',
status: '',
search: ''
});
};
const StatCard = ({ title, value, icon, color = '#667eea', subtitle }) => (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: color + '20',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px'
}}>
{icon}
</div>
<div>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: '#2d3748'
}}>
{value.toLocaleString()}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{title}
</div>
</div>
</div>
{subtitle && (
<div style={{
fontSize: '12px',
color: '#718096',
marginTop: '4px'
}}>
{subtitle}
</div>
)}
</div>
);
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '32px' }}>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📦 자재 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
전체 프로젝트의 자재 정보를 통합 관리하고 분석하세요.
</p>
</div>
{/* 통계 카드들 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '20px',
marginBottom: '32px'
}}>
<StatCard
title="전체 자재"
value={stats.totalMaterials}
icon="📦"
color="#667eea"
subtitle={`${projects.length}개 프로젝트`}
/>
<StatCard
title="분류 완료"
value={stats.categorizedMaterials}
icon="✅"
color="#48bb78"
subtitle={`${Math.round((stats.categorizedMaterials / stats.totalMaterials) * 100) || 0}% 완료`}
/>
<StatCard
title="미분류"
value={stats.uncategorizedMaterials}
icon="⚠️"
color="#ed8936"
subtitle="분류 작업 필요"
/>
<StatCard
title="카테고리"
value={Object.keys(stats.categories).length}
icon="🏷️"
color="#9f7aea"
subtitle="자재 분류 유형"
/>
</div>
{/* 필터 섹션 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px',
marginBottom: '24px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0'
}}>
🔍 필터 검색
</h3>
<button
onClick={clearFilters}
style={{
padding: '8px 16px',
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
color: '#4a5568'
}}
>
필터 초기화
</button>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
{/* 프로젝트 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
프로젝트
</label>
<select
value={filters.project}
onChange={(e) => handleFilterChange('project', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체 프로젝트</option>
{projects.map(project => (
<option key={project.job_no} value={project.job_no}>
{project.job_no} - {project.job_name}
</option>
))}
</select>
</div>
{/* 카테고리 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
카테고리
</label>
<select
value={filters.category}
onChange={(e) => handleFilterChange('category', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체 카테고리</option>
{Object.keys(stats.categories).map(category => (
<option key={category} value={category}>
{category} ({stats.categories[category]})
</option>
))}
</select>
</div>
{/* 상태 필터 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
분류 상태
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
>
<option value="">전체</option>
<option value="categorized">분류 완료</option>
<option value="uncategorized">미분류</option>
</select>
</div>
{/* 검색 */}
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '6px'
}}>
검색
</label>
<input
type="text"
placeholder="자재명, 코드, 카테고리 검색..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
style={{
width: '100%',
padding: '10px',
border: '1px solid #e2e8f0',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
</div>
{/* 필터 결과 요약 */}
<div style={{
marginTop: '16px',
padding: '12px',
background: '#f7fafc',
borderRadius: '6px',
fontSize: '14px',
color: '#4a5568'
}}>
<strong>{filteredMaterials.length.toLocaleString()}</strong>개의 자재가 검색되었습니다.
{filters.project && ` (프로젝트: ${filters.project})`}
{filters.category && ` (카테고리: ${filters.category})`}
{filters.status && ` (상태: ${filters.status === 'categorized' ? '분류완료' : '미분류'})`}
{filters.search && ` (검색: "${filters.search}")`}
</div>
</div>
{/* 자재 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
background: '#f7fafc',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{
margin: '0',
fontSize: '16px',
fontWeight: '600',
color: '#2d3748'
}}>
자재 목록 ({filteredMaterials.length.toLocaleString()})
</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
style={{
padding: '8px 16px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📊 분석 리포트
</button>
<button
style={{
padding: '8px 16px',
background: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📤 Excel 내보내기
</button>
</div>
</div>
<MaterialList
selectedProject={null} // 전체 자재 보기
showProjectInfo={true}
enableSelection={true}
key="all-materials" // 전체 자재 모드
/>
</div>
</div>
</div>
);
};
export default MaterialsManagementPage;

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, CircularProgress, Alert, Button } from '@mui/material';
import { fetchJobs } from '../api';
import { useNavigate } from 'react-router-dom';
const ProjectSelectionPage = () => {
const [jobs, setJobs] = useState([]);
const [selectedJob, setSelectedJob] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
useEffect(() => {
async function loadJobs() {
setLoading(true);
setError('');
try {
const res = await fetchJobs({});
if (res.data && Array.isArray(res.data.jobs)) {
setJobs(res.data.jobs);
} else {
setJobs([]);
}
} catch (e) {
setError('프로젝트 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}
loadJobs();
}, []);
return (
<Box sx={{ maxWidth: 500, mx: 'auto', mt: 8 }}>
<Typography variant="h4" gutterBottom>프로젝트(Job No) 선택</Typography>
{loading && <CircularProgress sx={{ mt: 4 }} />}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<FormControl size="small" fullWidth sx={{ mt: 3 }}>
<InputLabel>Job No</InputLabel>
<Select
value={selectedJob}
label="Job No"
onChange={e => setSelectedJob(e.target.value)}
displayEmpty
>
<MenuItem value="">선택</MenuItem>
{jobs.map(job => (
<MenuItem key={job.job_no} value={job.job_no}>
{job.job_no} ({job.job_name})
</MenuItem>
))}
</Select>
</FormControl>
{selectedJob && (
<Alert severity="info" sx={{ mt: 3 }}>
선택된 Job No: <b>{selectedJob}</b>
</Alert>
)}
<Button
variant="contained"
sx={{ mt: 4, minWidth: 120 }}
disabled={!selectedJob}
onClick={() => navigate(`/bom-status?job_no=${selectedJob}`)}
>
확인
</Button>
</Box>
);
};
export default ProjectSelectionPage;

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect } from 'react';
const ProjectsPage = ({ user }) => {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
// 실제로는 API에서 프로젝트 데이터를 가져올 예정
// 현재는 더미 데이터 사용
setTimeout(() => {
setProjects([
{
id: 1,
name: '냉동기 시스템 개발',
type: '냉동기',
status: '진행중',
startDate: '2024-01-15',
endDate: '2024-06-30',
deliveryMethod: 'FOB',
progress: 65,
manager: '김철수'
},
{
id: 2,
name: 'BOG 처리 시스템',
type: 'BOG',
status: '계획',
startDate: '2024-02-01',
endDate: '2024-08-15',
deliveryMethod: 'CIF',
progress: 15,
manager: '이영희'
},
{
id: 3,
name: '다이아프람 펌프 제작',
type: '다이아프람',
status: '완료',
startDate: '2023-10-01',
endDate: '2024-01-31',
deliveryMethod: 'FOB',
progress: 100,
manager: '박민수'
}
]);
setLoading(false);
}, 1000);
}, []);
const getStatusColor = (status) => {
const colors = {
'계획': '#ed8936',
'진행중': '#48bb78',
'완료': '#38b2ac',
'보류': '#e53e3e'
};
return colors[status] || '#718096';
};
const getProgressColor = (progress) => {
if (progress >= 80) return '#48bb78';
if (progress >= 50) return '#ed8936';
return '#e53e3e';
};
if (loading) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
fontSize: '16px',
color: '#718096'
}}>
프로젝트 목록을 불러오는 ...
</div>
);
}
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px'
}}>
<div>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📋 프로젝트 관리
</h1>
<p style={{
color: '#718096',
fontSize: '16px',
margin: '0'
}}>
전체 프로젝트를 관리하고 진행 상황을 확인하세요.
</p>
</div>
<button
onClick={() => setShowCreateForm(true)}
style={{
padding: '12px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'transform 0.2s ease'
}}
onMouseEnter={(e) => e.target.style.transform = 'translateY(-1px)'}
onMouseLeave={(e) => e.target.style.transform = 'translateY(0)'}
>
<span></span>
프로젝트
</button>
</div>
{/* 프로젝트 통계 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px',
marginBottom: '32px'
}}>
{[
{ label: '전체', count: projects.length, color: '#667eea' },
{ label: '진행중', count: projects.filter(p => p.status === '진행중').length, color: '#48bb78' },
{ label: '완료', count: projects.filter(p => p.status === '완료').length, color: '#38b2ac' },
{ label: '계획', count: projects.filter(p => p.status === '계획').length, color: '#ed8936' }
].map((stat, index) => (
<div key={index} style={{
background: 'white',
padding: '20px',
borderRadius: '12px',
border: '1px solid #e2e8f0',
textAlign: 'center'
}}>
<div style={{
fontSize: '24px',
fontWeight: '700',
color: stat.color,
marginBottom: '4px'
}}>
{stat.count}
</div>
<div style={{
fontSize: '14px',
color: '#718096'
}}>
{stat.label}
</div>
</div>
))}
</div>
{/* 프로젝트 목록 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
background: '#f7fafc'
}}>
<h3 style={{
margin: '0',
fontSize: '16px',
fontWeight: '600',
color: '#2d3748'
}}>
프로젝트 목록 ({projects.length})
</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse'
}}>
<thead>
<tr style={{ background: '#f7fafc' }}>
{['프로젝트명', '유형', '상태', '수주일', '납기일', '납품방법', '진행률', '담당자'].map(header => (
<th key={header} style={{
padding: '12px 16px',
textAlign: 'left',
fontSize: '12px',
fontWeight: '600',
color: '#4a5568',
borderBottom: '1px solid #e2e8f0'
}}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{projects.map(project => (
<tr key={project.id} style={{
borderBottom: '1px solid #e2e8f0',
transition: 'background 0.2s ease'
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#f7fafc'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
<td style={{ padding: '16px', fontWeight: '600', color: '#2d3748' }}>
{project.name}
</td>
<td style={{ padding: '16px' }}>
<span style={{
padding: '4px 8px',
background: '#edf2f7',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500',
color: '#4a5568'
}}>
{project.type}
</span>
</td>
<td style={{ padding: '16px' }}>
<span style={{
padding: '4px 8px',
background: getStatusColor(project.status) + '20',
color: getStatusColor(project.status),
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{project.status}
</span>
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.startDate}
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.endDate}
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.deliveryMethod}
</td>
<td style={{ padding: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
flex: 1,
height: '6px',
background: '#e2e8f0',
borderRadius: '3px',
overflow: 'hidden'
}}>
<div style={{
width: `${project.progress}%`,
height: '100%',
background: getProgressColor(project.progress),
transition: 'width 0.3s ease'
}} />
</div>
<span style={{
fontSize: '12px',
fontWeight: '600',
color: getProgressColor(project.progress),
minWidth: '35px'
}}>
{project.progress}%
</span>
</div>
</td>
<td style={{ padding: '16px', color: '#4a5568' }}>
{project.manager}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 프로젝트가 없을 때 */}
{projects.length === 0 && (
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '60px 40px',
textAlign: 'center'
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📋</div>
<h3 style={{
fontSize: '18px',
fontWeight: '600',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
등록된 프로젝트가 없습니다
</h3>
<p style={{
color: '#718096',
margin: '0 0 24px 0'
}}>
번째 프로젝트를 등록해보세요.
</p>
<button
onClick={() => setShowCreateForm(true)}
style={{
padding: '12px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '600'
}}
>
프로젝트 등록
</button>
</div>
)}
</div>
{/* 프로젝트 생성 폼 모달 (향후 구현) */}
{showCreateForm && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '32px',
maxWidth: '500px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h3 style={{ margin: '0 0 16px 0' }}> 프로젝트 등록</h3>
<p style={{ color: '#718096', margin: '0 0 24px 0' }}>
기능은 구현될 예정입니다.
</p>
<button
onClick={() => setShowCreateForm(false)}
style={{
padding: '8px 16px',
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
</div>
)}
</div>
);
};
export default ProjectsPage;

View File

@@ -0,0 +1,742 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
const SimpleMaterialsPage = ({ fileId, jobNo: propJobNo, bomName: propBomName, revision: propRevision, filename: propFilename, onNavigate }) => {
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fileName, setFileName] = useState('');
const [jobNo, setJobNo] = useState('');
const [bomName, setBomName] = useState('');
const [currentRevision, setCurrentRevision] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('all');
const [filterConfidence, setFilterConfidence] = useState('all');
const [showPurchaseCalculation, setShowPurchaseCalculation] = useState(false);
const [purchaseData, setPurchaseData] = useState(null);
const [calculatingPurchase, setCalculatingPurchase] = useState(false);
useEffect(() => {
// Props로 받은 값들을 초기화
if (propJobNo) setJobNo(propJobNo);
if (propBomName) setBomName(propBomName);
if (propRevision) setCurrentRevision(propRevision);
if (propFilename) setFileName(propFilename);
if (fileId) {
loadMaterials(fileId);
} else {
setLoading(false);
setError('파일 ID가 지정되지 않았습니다.');
}
}, [fileId, propJobNo, propBomName, propRevision, propFilename]);
const loadMaterials = async (id) => {
try {
setLoading(true);
const response = await api.get('/files/materials', {
params: { file_id: parseInt(id), limit: 10000 }
});
if (response.data && response.data.materials) {
setMaterials(response.data.materials);
// 파일 정보 설정
if (response.data.materials.length > 0) {
const firstMaterial = response.data.materials[0];
setFileName(firstMaterial.filename || '');
setJobNo(firstMaterial.project_code || '');
setBomName(firstMaterial.filename || '');
setCurrentRevision('Rev.0'); // API에서 revision 정보가 없으므로 기본값
}
} else {
setMaterials([]);
}
} catch (err) {
console.error('자재 목록 로드 실패:', err);
setError('자재 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// 구매 수량 계산 함수 (기존 BOM 규칙 적용)
const calculatePurchaseQuantities = async () => {
if (!jobNo || !currentRevision) {
alert('프로젝트 정보가 없습니다.');
return;
}
setCalculatingPurchase(true);
try {
const response = await api.get(`/purchase/calculate`, {
params: {
job_no: jobNo,
revision: currentRevision,
file_id: fileId
}
});
if (response.data && response.data.success) {
setPurchaseData(response.data.purchase_items);
setShowPurchaseCalculation(true);
} else {
throw new Error('구매 수량 계산 실패');
}
} catch (error) {
console.error('구매 수량 계산 오류:', error);
alert('구매 수량 계산에 실패했습니다.');
} finally {
setCalculatingPurchase(false);
}
};
// 필터링된 자재 목록 (기존 BOM 규칙 적용)
const filteredMaterials = materials.filter(material => {
const matchesSearch = !searchTerm ||
material.original_description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.size_spec?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.material_grade?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = filterCategory === 'all' ||
material.classified_category === filterCategory;
// 신뢰도 필터링 (기존 BOM 규칙)
const matchesConfidence = filterConfidence === 'all' ||
(filterConfidence === 'high' && material.classification_confidence >= 0.9) ||
(filterConfidence === 'medium' && material.classification_confidence >= 0.7 && material.classification_confidence < 0.9) ||
(filterConfidence === 'low' && material.classification_confidence < 0.7);
return matchesSearch && matchesCategory && matchesConfidence;
});
// 카테고리별 통계
const categoryStats = materials.reduce((acc, material) => {
const category = material.classified_category || 'unknown';
acc[category] = (acc[category] || 0) + 1;
return acc;
}, {});
const categories = Object.keys(categoryStats).sort();
// 카테고리별 색상 함수
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;
};
// 신뢰도 배지 함수 (기존 BOM 규칙 적용)
const getConfidenceBadge = (confidence) => {
if (!confidence) return '-';
const conf = parseFloat(confidence);
let color, text;
if (conf >= 0.9) {
color = '#48bb78'; // 녹색
text = '높음';
} else if (conf >= 0.7) {
color = '#ed8936'; // 주황색
text = '보통';
} else {
color = '#f56565'; // 빨간색
text = '낮음';
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
background: color,
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: '600'
}}>
{text}
</span>
<span style={{ fontSize: '11px', color: '#718096' }}>
{Math.round(conf * 100)}%
</span>
</div>
);
};
// 상세정보 표시 함수 (기존 BOM 규칙 적용)
const getDetailInfo = (material) => {
const details = [];
// PIPE 상세정보
if (material.pipe_details) {
const pipe = material.pipe_details;
if (pipe.schedule) details.push(`SCH ${pipe.schedule}`);
if (pipe.manufacturing_method) details.push(pipe.manufacturing_method);
if (pipe.end_preparation) details.push(pipe.end_preparation);
}
// FITTING 상세정보
if (material.fitting_details) {
const fitting = material.fitting_details;
if (fitting.fitting_type) details.push(fitting.fitting_type);
if (fitting.connection_method && fitting.connection_method !== 'UNKNOWN') {
details.push(fitting.connection_method);
}
if (fitting.pressure_rating && fitting.pressure_rating !== 'UNKNOWN') {
details.push(fitting.pressure_rating);
}
}
// VALVE 상세정보
if (material.valve_details) {
const valve = material.valve_details;
if (valve.valve_type) details.push(valve.valve_type);
if (valve.connection_type) details.push(valve.connection_type);
if (valve.pressure_rating) details.push(valve.pressure_rating);
}
// BOLT 상세정보
if (material.bolt_details) {
const bolt = material.bolt_details;
if (bolt.fastener_type) details.push(bolt.fastener_type);
if (bolt.thread_specification) details.push(bolt.thread_specification);
if (bolt.length_mm) details.push(`L${bolt.length_mm}mm`);
}
// FLANGE 상세정보
if (material.flange_details) {
const flange = material.flange_details;
if (flange.flange_type) details.push(flange.flange_type);
if (flange.pressure_rating) details.push(flange.pressure_rating);
if (flange.facing_type) details.push(flange.facing_type);
}
return details.length > 0 ? (
<div style={{ fontSize: '11px', color: '#4a5568' }}>
{details.slice(0, 2).map((detail, idx) => (
<div key={idx} style={{
background: '#f7fafc',
padding: '2px 4px',
borderRadius: '3px',
marginBottom: '2px',
display: 'inline-block',
marginRight: '4px'
}}>
{detail}
</div>
))}
{details.length > 2 && (
<span style={{ color: '#718096' }}>+{details.length - 2}</span>
)}
</div>
) : '-';
};
if (loading) {
return (
<div style={{
padding: '32px',
textAlign: 'center',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ padding: '40px' }}>
로딩 ...
</div>
</div>
);
}
if (error) {
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{
background: '#fed7d7',
border: '1px solid #fc8181',
borderRadius: '8px',
padding: '12px 16px',
color: '#c53030'
}}>
{error}
</div>
</div>
);
}
return (
<div style={{
padding: '32px',
background: '#f7fafc',
minHeight: '100vh'
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
{/* 헤더 */}
<div style={{ marginBottom: '24px' }}>
<button
onClick={() => onNavigate && onNavigate('bom-status', { job_no: jobNo, job_name: bomName })}
style={{
padding: '8px 16px',
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: '6px',
cursor: 'pointer',
marginBottom: '16px'
}}
>
뒤로가기
</button>
<h1 style={{
fontSize: '28px',
fontWeight: '700',
color: '#2d3748',
margin: '0 0 8px 0'
}}>
📦 자재 목록
</h1>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end',
margin: '0 0 24px 0'
}}>
<div style={{
fontSize: '16px',
color: '#718096'
}}>
<div><strong>프로젝트:</strong> {jobNo}</div>
<div><strong>BOM:</strong> {bomName}</div>
<div><strong>리비전:</strong> {currentRevision}</div>
<div><strong> 자재 :</strong> {materials.length}</div>
</div>
<button
onClick={calculatePurchaseQuantities}
disabled={calculatingPurchase}
style={{
background: calculatingPurchase ? '#a0aec0' : '#48bb78',
color: 'white',
padding: '12px 20px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
border: 'none',
cursor: calculatingPurchase ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{calculatingPurchase ? '계산중...' : '🧮 구매수량 계산'}
</button>
</div>
</div>
{/* 검색 및 필터 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
padding: '24px',
marginBottom: '24px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 200px 200px',
gap: '16px',
alignItems: 'end'
}}>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
자재 검색
</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="자재명, 규격, 설명으로 검색..."
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
카테고리 필터
</label>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="all">전체 ({materials.length})</option>
{categories.map(category => (
<option key={category} value={category}>
{category} ({categoryStats[category]})
</option>
))}
</select>
</div>
<div>
<label style={{
display: 'block',
fontSize: '14px',
fontWeight: '600',
color: '#4a5568',
marginBottom: '8px'
}}>
신뢰도 필터
</label>
<select
value={filterConfidence}
onChange={(e) => setFilterConfidence(e.target.value)}
style={{
width: '100%',
padding: '12px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
fontSize: '14px',
background: 'white'
}}
>
<option value="all">전체</option>
<option value="high">높음 (90%+)</option>
<option value="medium">보통 (70-89%)</option>
<option value="low">낮음 (70% 미만)</option>
</select>
</div>
</div>
</div>
{/* 통계 카드 */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
{categories.slice(0, 6).map(category => (
<div key={category} style={{
background: 'white',
borderRadius: '8px',
border: '1px solid #e2e8f0',
padding: '16px',
textAlign: 'center'
}}>
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4299e1' }}>
{categoryStats[category]}
</div>
<div style={{ fontSize: '14px', color: '#718096', marginTop: '4px' }}>
{category}
</div>
</div>
))}
</div>
{/* 자재 테이블 */}
<div style={{
background: 'white',
borderRadius: '12px',
border: '1px solid #e2e8f0',
overflow: 'hidden'
}}>
<div style={{ padding: '20px', borderBottom: '1px solid #e2e8f0' }}>
<h3 style={{ margin: '0', fontSize: '18px', fontWeight: '600' }}>
자재 목록 ({filteredMaterials.length})
</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>No.</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>자재명</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>규격</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>수량</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>신뢰도</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>상세정보</th>
</tr>
</thead>
<tbody>
{filteredMaterials.map((material, index) => (
<tr key={material.id || index} style={{
borderBottom: '1px solid #e2e8f0'
}}>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.line_number || index + 1}
</td>
<td style={{ padding: '12px', fontSize: '14px', fontWeight: '500' }}>
{material.original_description || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.size_spec || material.main_nom || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
{material.quantity || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.unit || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
<span style={{
background: getCategoryColor(material.classified_category),
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{material.classified_category || 'unknown'}
</span>
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{material.material_grade || '-'}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{getConfidenceBadge(material.classification_confidence)}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{getDetailInfo(material)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredMaterials.length === 0 && (
<div style={{
padding: '40px',
textAlign: 'center',
color: '#718096'
}}>
검색 조건에 맞는 자재가 없습니다.
</div>
)}
</div>
{/* 구매 수량 계산 결과 모달 */}
{showPurchaseCalculation && purchaseData && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '1000px',
maxHeight: '80vh',
overflow: 'auto',
margin: '20px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '700',
color: '#2d3748',
margin: 0
}}>
🧮 구매 수량 계산 결과
</h3>
<button
onClick={() => setShowPurchaseCalculation(false)}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: '6px',
padding: '8px 12px',
cursor: 'pointer'
}}
>
닫기
</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f7fafc' }}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>카테고리</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사양</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>사이즈</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>재질</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>BOM 수량</th>
<th style={{ padding: '12px', textAlign: 'right', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>구매 수량</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>단위</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #e2e8f0', fontSize: '14px', fontWeight: '600' }}>비고</th>
</tr>
</thead>
<tbody>
{purchaseData.map((item, index) => (
<tr key={index} style={{ borderBottom: '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', fontSize: '14px' }}>
<span style={{
background: getCategoryColor(item.category),
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '600'
}}>
{item.category}
</span>
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.specification}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 사이즈 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#e6fffa',
color: '#065f46',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.size_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{/* PIPE는 사양에 모든 정보가 포함되므로 재질 컬럼 비움 */}
{item.category !== 'PIPE' && (
<span style={{
background: '#fef7e0',
color: '#92400e',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{item.material_spec || '-'}
</span>
)}
{item.category === 'PIPE' && (
<span style={{ color: '#a0aec0', fontSize: '12px' }}>
사양에 포함
</span>
)}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right' }}>
{item.category === 'PIPE' ?
`${Math.round(item.bom_quantity)}mm` :
item.bom_quantity
}
</td>
<td style={{ padding: '12px', fontSize: '14px', textAlign: 'right', fontWeight: '600' }}>
{item.category === 'PIPE' ?
`${item.pipes_count}본 (${Math.round(item.calculated_qty)}mm)` :
item.calculated_qty
}
</td>
<td style={{ padding: '12px', fontSize: '14px' }}>
{item.unit}
</td>
<td style={{ padding: '12px', fontSize: '12px', color: '#718096' }}>
{item.category === 'PIPE' && (
<div>
<div>절단수: {item.cutting_count}</div>
<div>절단손실: {item.cutting_loss}mm</div>
<div>활용률: {Math.round(item.utilization_rate)}%</div>
</div>
)}
{item.category !== 'PIPE' && item.safety_factor && (
<div>여유율: {Math.round((item.safety_factor - 1) * 100)}%</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{
marginTop: '24px',
padding: '16px',
background: '#f7fafc',
borderRadius: '8px',
fontSize: '14px',
color: '#4a5568'
}}>
<div style={{ fontWeight: '600', marginBottom: '8px' }}>📋 계산 규칙 (올바른 규칙):</div>
<div> <strong>PIPE:</strong> 6M 단위 올림, 절단당 2mm 손실</div>
<div> <strong>FITTING:</strong> BOM 수량 그대로</div>
<div> <strong>VALVE:</strong> BOM 수량 그대로</div>
<div> <strong>BOLT:</strong> 5% 여유율 4 배수 올림</div>
<div> <strong>GASKET:</strong> 5 배수 올림</div>
<div> <strong>INSTRUMENT:</strong> BOM 수량 그대로</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default SimpleMaterialsPage;

View File

@@ -0,0 +1,430 @@
.user-management-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 2px solid #e2e8f0;
}
.page-header h1 {
color: #2d3748;
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.page-header p {
color: #718096;
font-size: 16px;
margin: 0;
}
.create-user-btn {
padding: 12px 24px;
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.create-user-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(72, 187, 120, 0.3);
}
.error-message {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fed7d7;
border: 1px solid #feb2b2;
border-radius: 8px;
color: #c53030;
margin-bottom: 24px;
}
.close-error {
margin-left: auto;
background: none;
border: none;
color: #c53030;
cursor: pointer;
font-size: 16px;
}
.access-denied {
text-align: center;
padding: 64px 24px;
color: #718096;
}
.access-denied h2 {
color: #2d3748;
margin-bottom: 16px;
}
/* 모달 스타일 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.user-form-modal {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
border-bottom: 1px solid #e2e8f0;
}
.modal-header h2 {
color: #2d3748;
font-size: 24px;
font-weight: 700;
margin: 0;
}
.close-modal {
background: none;
border: none;
font-size: 24px;
color: #718096;
cursor: pointer;
padding: 4px;
}
.close-modal:hover {
color: #2d3748;
}
/* 폼 스타일 */
.user-form-modal form {
padding: 32px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
color: #2d3748;
font-weight: 600;
font-size: 14px;
}
.form-group input,
.form-group select {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 16px;
transition: all 0.2s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
}
.checkbox-group {
flex-direction: row;
align-items: center;
gap: 12px;
}
.checkbox-group input {
width: auto;
margin: 0;
}
/* 권한 설정 섹션 */
.permissions-section {
margin-bottom: 32px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
}
.permissions-section h3 {
color: #2d3748;
font-size: 18px;
font-weight: 600;
margin: 0 0 20px 0;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
}
.permission-category {
background: #f7fafc;
border-radius: 8px;
padding: 16px;
}
.permission-category h4 {
color: #2d3748;
font-size: 14px;
font-weight: 600;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid #e2e8f0;
}
.permission-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 14px;
color: #4a5568;
cursor: pointer;
}
.permission-item input {
margin: 0;
width: auto;
}
.permission-item:hover {
color: #2d3748;
}
/* 폼 액션 버튼 */
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
}
.submit-btn {
padding: 12px 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.cancel-btn {
padding: 12px 32px;
background: #e2e8f0;
color: #4a5568;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background: #cbd5e0;
}
/* 사용자 테이블 */
.users-list {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.users-table {
overflow-x: auto;
}
.users-table table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background: #f7fafc;
color: #2d3748;
font-weight: 600;
font-size: 14px;
padding: 16px 12px;
text-align: left;
border-bottom: 2px solid #e2e8f0;
}
.users-table td {
padding: 16px 12px;
border-bottom: 1px solid #e2e8f0;
font-size: 14px;
color: #4a5568;
}
.users-table tr:hover {
background: #f7fafc;
}
/* 배지 스타일 */
.role-badge,
.access-badge,
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.role-admin { background: #fed7d7; color: #c53030; }
.role-system { background: #fbb6ce; color: #b83280; }
.role-leader { background: #bee3f8; color: #2b6cb0; }
.role-support { background: #c6f6d5; color: #2f855a; }
.role-user { background: #e2e8f0; color: #4a5568; }
.access-manager { background: #fed7d7; color: #c53030; }
.access-leader { background: #bee3f8; color: #2b6cb0; }
.access-worker { background: #c6f6d5; color: #2f855a; }
.access-viewer { background: #faf089; color: #744210; }
.status-badge.active { background: #c6f6d5; color: #2f855a; }
.status-badge.inactive { background: #fed7d7; color: #c53030; }
/* 액션 버튼 */
.action-buttons {
display: flex;
gap: 8px;
}
.edit-btn,
.toggle-btn {
padding: 6px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.edit-btn {
background: #bee3f8;
color: #2b6cb0;
}
.edit-btn:hover {
background: #90cdf4;
}
.toggle-btn.deactivate {
background: #fed7d7;
color: #c53030;
}
.toggle-btn.activate {
background: #c6f6d5;
color: #2f855a;
}
.toggle-btn:hover {
opacity: 0.8;
}
.loading {
text-align: center;
padding: 64px 24px;
color: #718096;
font-size: 16px;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.user-management-page {
padding: 16px;
}
.page-header {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.form-grid {
grid-template-columns: 1fr;
}
.permissions-grid {
grid-template-columns: 1fr;
}
.users-table {
font-size: 12px;
}
.users-table th,
.users-table td {
padding: 8px 6px;
}
.form-actions {
flex-direction: column;
}
.submit-btn,
.cancel-btn {
width: 100%;
}
}

View File

@@ -0,0 +1,499 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import api from '../api';
import './UserManagementPage.css';
const UserManagementPage = () => {
const { user, hasPermission, isAdmin } = useAuth();
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [formData, setFormData] = useState({
username: '',
password: '',
name: '',
email: '',
role: 'user',
access_level: 'worker',
department: '',
position: '',
phone: '',
is_active: true,
permissions: []
});
// 권한 목록 정의
const availablePermissions = [
{ id: 'bom.view', name: 'BOM 조회', category: 'BOM' },
{ id: 'bom.edit', name: 'BOM 편집', category: 'BOM' },
{ id: 'bom.delete', name: 'BOM 삭제', category: 'BOM' },
{ id: 'project.view', name: '프로젝트 조회', category: '프로젝트' },
{ id: 'project.create', name: '프로젝트 생성', category: '프로젝트' },
{ id: 'project.edit', name: '프로젝트 편집', category: '프로젝트' },
{ id: 'project.delete', name: '프로젝트 삭제', category: '프로젝트' },
{ id: 'file.upload', name: '파일 업로드', category: '파일' },
{ id: 'file.download', name: '파일 다운로드', category: '파일' },
{ id: 'file.delete', name: '파일 삭제', category: '파일' },
{ id: 'user.view', name: '사용자 조회', category: '사용자' },
{ id: 'user.create', name: '사용자 생성', category: '사용자' },
{ id: 'user.edit', name: '사용자 편집', category: '사용자' },
{ id: 'user.delete', name: '사용자 삭제', category: '사용자' },
{ id: 'system.admin', name: '시스템 관리', category: '시스템' }
];
const roleOptions = [
{ value: 'admin', label: '관리자', description: '모든 권한' },
{ value: 'system', label: '시스템', description: '시스템 관리' },
{ value: 'leader', label: '팀장', description: '팀 관리' },
{ value: 'support', label: '지원', description: '지원 업무' },
{ value: 'user', label: '사용자', description: '일반 사용자' }
];
const accessLevelOptions = [
{ value: 'manager', label: '관리자', description: '전체 관리 권한' },
{ value: 'leader', label: '팀장', description: '팀 관리 권한' },
{ value: 'worker', label: '작업자', description: '기본 작업 권한' },
{ value: 'viewer', label: '조회자', description: '조회 전용' }
];
// 사용자 목록 조회
const fetchUsers = async () => {
try {
setIsLoading(true);
const response = await api.get('/auth/users');
if (response.data.success) {
setUsers(response.data.users);
}
} catch (error) {
console.error('Failed to fetch users:', error);
setError('사용자 목록을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (hasPermission('user.view') || isAdmin()) {
fetchUsers();
}
}, []);
// 폼 데이터 변경 처리
const handleFormChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
// 권한 변경 처리
const handlePermissionChange = (permissionId, checked) => {
setFormData(prev => ({
...prev,
permissions: checked
? [...prev.permissions, permissionId]
: prev.permissions.filter(p => p !== permissionId)
}));
};
// 사용자 생성
const handleCreateUser = async (e) => {
e.preventDefault();
try {
const response = await api.post('/auth/register', formData);
if (response.data.success) {
setShowCreateForm(false);
setFormData({
username: '',
password: '',
name: '',
email: '',
role: 'user',
access_level: 'worker',
department: '',
position: '',
phone: '',
is_active: true,
permissions: []
});
await fetchUsers();
}
} catch (error) {
console.error('Failed to create user:', error);
setError(error.response?.data?.error?.message || '사용자 생성에 실패했습니다.');
}
};
// 사용자 편집
const handleEditUser = (userData) => {
setEditingUser(userData.user_id);
setFormData({
username: userData.username,
password: '',
name: userData.name,
email: userData.email,
role: userData.role,
access_level: userData.access_level,
department: userData.department || '',
position: userData.position || '',
phone: userData.phone || '',
is_active: userData.is_active,
permissions: userData.permissions || []
});
setShowCreateForm(true);
};
// 사용자 업데이트
const handleUpdateUser = async (e) => {
e.preventDefault();
try {
const updateData = { ...formData };
if (!updateData.password) {
delete updateData.password; // 비밀번호가 비어있으면 제외
}
const response = await api.put(`/auth/users/${editingUser}`, updateData);
if (response.data.success) {
setShowCreateForm(false);
setEditingUser(null);
setFormData({
username: '',
password: '',
name: '',
email: '',
role: 'user',
access_level: 'worker',
department: '',
position: '',
phone: '',
is_active: true,
permissions: []
});
await fetchUsers();
}
} catch (error) {
console.error('Failed to update user:', error);
setError(error.response?.data?.error?.message || '사용자 수정에 실패했습니다.');
}
};
// 사용자 활성화/비활성화
const handleToggleUserStatus = async (userId, currentStatus) => {
try {
const response = await api.put(`/auth/users/${userId}`, {
is_active: !currentStatus
});
if (response.data.success) {
await fetchUsers();
}
} catch (error) {
console.error('Failed to toggle user status:', error);
setError('사용자 상태 변경에 실패했습니다.');
}
};
if (!hasPermission('user.view') && !isAdmin()) {
return (
<div className="access-denied">
<h2>접근 권한이 없습니다</h2>
<p>사용자 관리 페이지에 접근할 권한이 없습니다.</p>
</div>
);
}
return (
<div className="user-management-page">
<div className="page-header">
<h1>👥 사용자 관리</h1>
<p>시스템 사용자 계정을 관리하고 권한을 설정합니다.</p>
{(hasPermission('user.create') || isAdmin()) && (
<button
className="create-user-btn"
onClick={() => setShowCreateForm(true)}
>
사용자 생성
</button>
)}
</div>
{error && (
<div className="error-message">
<span className="error-icon"></span>
{error}
<button onClick={() => setError('')} className="close-error"></button>
</div>
)}
{/* 사용자 생성/편집 폼 */}
{showCreateForm && (
<div className="modal-overlay">
<div className="user-form-modal">
<div className="modal-header">
<h2>{editingUser ? '사용자 편집' : '새 사용자 생성'}</h2>
<button
className="close-modal"
onClick={() => {
setShowCreateForm(false);
setEditingUser(null);
}}
>
</button>
</div>
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
<div className="form-grid">
<div className="form-group">
<label>사용자명 *</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleFormChange}
required
disabled={editingUser} // 편집 사용자명 변경 불가
/>
</div>
<div className="form-group">
<label>비밀번호 {!editingUser && '*'}</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleFormChange}
required={!editingUser}
placeholder={editingUser ? '변경하지 않으려면 비워두세요' : ''}
/>
</div>
<div className="form-group">
<label>이름 *</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleFormChange}
required
/>
</div>
<div className="form-group">
<label>이메일 *</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleFormChange}
required
/>
</div>
<div className="form-group">
<label>역할</label>
<select
name="role"
value={formData.role}
onChange={handleFormChange}
>
{roleOptions.map(role => (
<option key={role.value} value={role.value}>
{role.label} - {role.description}
</option>
))}
</select>
</div>
<div className="form-group">
<label>접근 레벨</label>
<select
name="access_level"
value={formData.access_level}
onChange={handleFormChange}
>
{accessLevelOptions.map(level => (
<option key={level.value} value={level.value}>
{level.label} - {level.description}
</option>
))}
</select>
</div>
<div className="form-group">
<label>부서</label>
<input
type="text"
name="department"
value={formData.department}
onChange={handleFormChange}
/>
</div>
<div className="form-group">
<label>직책</label>
<input
type="text"
name="position"
value={formData.position}
onChange={handleFormChange}
/>
</div>
<div className="form-group">
<label>전화번호</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleFormChange}
/>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleFormChange}
/>
계정 활성화
</label>
</div>
</div>
{/* 권한 설정 */}
<div className="permissions-section">
<h3>권한 설정</h3>
<div className="permissions-grid">
{Object.entries(
availablePermissions.reduce((acc, perm) => {
if (!acc[perm.category]) acc[perm.category] = [];
acc[perm.category].push(perm);
return acc;
}, {})
).map(([category, perms]) => (
<div key={category} className="permission-category">
<h4>{category}</h4>
{perms.map(perm => (
<label key={perm.id} className="permission-item">
<input
type="checkbox"
checked={formData.permissions.includes(perm.id)}
onChange={(e) => handlePermissionChange(perm.id, e.target.checked)}
/>
{perm.name}
</label>
))}
</div>
))}
</div>
</div>
<div className="form-actions">
<button type="submit" className="submit-btn">
{editingUser ? '수정' : '생성'}
</button>
<button
type="button"
className="cancel-btn"
onClick={() => {
setShowCreateForm(false);
setEditingUser(null);
}}
>
취소
</button>
</div>
</form>
</div>
</div>
)}
{/* 사용자 목록 */}
<div className="users-list">
{isLoading ? (
<div className="loading">사용자 목록을 불러오는 ...</div>
) : (
<div className="users-table">
<table>
<thead>
<tr>
<th>사용자명</th>
<th>이름</th>
<th>이메일</th>
<th>역할</th>
<th>접근 레벨</th>
<th>부서</th>
<th>상태</th>
<th>마지막 로그인</th>
<th>작업</th>
</tr>
</thead>
<tbody>
{users.map(userData => (
<tr key={userData.user_id}>
<td>{userData.username}</td>
<td>{userData.name}</td>
<td>{userData.email}</td>
<td>
<span className={`role-badge role-${userData.role}`}>
{roleOptions.find(r => r.value === userData.role)?.label}
</span>
</td>
<td>
<span className={`access-badge access-${userData.access_level}`}>
{accessLevelOptions.find(l => l.value === userData.access_level)?.label}
</span>
</td>
<td>{userData.department || '-'}</td>
<td>
<span className={`status-badge ${userData.is_active ? 'active' : 'inactive'}`}>
{userData.is_active ? '활성' : '비활성'}
</span>
</td>
<td>
{userData.last_login_at
? new Date(userData.last_login_at).toLocaleString('ko-KR')
: '없음'
}
</td>
<td>
<div className="action-buttons">
{(hasPermission('user.edit') || isAdmin()) && (
<button
className="edit-btn"
onClick={() => handleEditUser(userData)}
title="편집"
>
</button>
)}
{(hasPermission('user.edit') || isAdmin()) && (
<button
className={`toggle-btn ${userData.is_active ? 'deactivate' : 'activate'}`}
onClick={() => handleToggleUserStatus(userData.user_id, userData.is_active)}
title={userData.is_active ? '비활성화' : '활성화'}
>
{userData.is_active ? '🔒' : '🔓'}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default UserManagementPage;

View File

@@ -21,21 +21,44 @@ const groupMaterialsByCategory = (materials) => {
/**
* 동일한 자재끼리 합치기 (자재 설명 + 사이즈 기준)
* 엑셀 내보내기용 특별 처리:
* - PIPE: 끝단 정보 제거 (BOE-POE, POE-TOE 등)
* - NIPPLE: 길이별 구분 (75mm, 100mm 등)
*/
const consolidateMaterials = (materials, isComparison = false) => {
const consolidated = {};
materials.forEach(material => {
const category = material.classified_category || material.category || 'UNCATEGORIZED';
const description = material.original_description || material.description || '';
let description = material.original_description || material.description || '';
const sizeSpec = material.size_spec || '';
// 그룹화 키: 카테고리 + 자재설명 + 사이즈
const groupKey = `${category}|${description}|${sizeSpec}`;
// 파이프 끝단 정보 제거 (엑셀 내보내기용)
if (category === 'PIPE') {
description = description
.replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '')
.replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '')
.trim();
}
// 니플의 경우 길이 정보를 그룹화 키에 포함
let lengthInfo = '';
if (category === 'FITTING' && description.toLowerCase().includes('nipple')) {
const lengthMatch = description.match(/(\d+)\s*mm/i);
if (lengthMatch) {
lengthInfo = `_${lengthMatch[1]}mm`;
}
}
// 그룹화 키: 카테고리 + 정제된자재설명 + 사이즈 + 길이정보
const groupKey = `${category}|${description}|${sizeSpec}${lengthInfo}`;
if (!consolidated[groupKey]) {
consolidated[groupKey] = {
...material,
// 정제된 설명으로 덮어쓰기
original_description: description,
description: description,
quantity: 0,
totalLength: 0, // 파이프용
itemCount: 0, // 파이프 개수
@@ -99,12 +122,23 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
const category = material.classified_category || material.category || '-';
const isPipe = category === 'PIPE';
// 엑셀용 자재 설명 정제
let cleanDescription = material.original_description || material.description || '-';
// 파이프 끝단 정보 제거
if (category === 'PIPE') {
cleanDescription = cleanDescription
.replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '')
.replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '')
.trim();
}
// 구매 수량 계산
const purchaseInfo = calculatePurchaseQuantity(material);
const base = {
'카테고리': category,
'자재 설명': material.original_description || material.description || '-',
'자재 설명': cleanDescription,
'사이즈': material.size_spec || '-'
};

View File

@@ -4,12 +4,12 @@
/**
* 파이프 구매 수량 계산
* @param {number} lengthMm - 파이프 총 길이 (mm)
* @param {number} totalLengthMm - 파이프 총 길이 (mm)
* @param {number} quantity - BOM 수량 (개수)
* @returns {object} 구매 계산 결과
*/
export const calculatePipePurchase = (lengthMm, quantity) => {
if (!lengthMm || lengthMm <= 0 || !quantity || quantity <= 0) {
export const calculatePipePurchase = (totalLengthMm, quantity) => {
if (!totalLengthMm || totalLengthMm <= 0 || !quantity || quantity <= 0) {
return {
purchaseQuantity: 0,
standardLength: 6000,
@@ -18,17 +18,18 @@ export const calculatePipePurchase = (lengthMm, quantity) => {
};
}
// 절단 여유분: 조각마다 2mm 추가
const cutLength = lengthMm + (quantity * 2);
// 절단 여유분: 절단 개수만큼 3mm 추가 (백엔드와 동일)
const cuttingLoss = quantity * 3;
const requiredLength = totalLengthMm + cuttingLoss;
// 6,000mm 단위로 올림 계산
const pipeCount = Math.ceil(cutLength / 6000);
const pipeCount = Math.ceil(requiredLength / 6000);
return {
purchaseQuantity: pipeCount,
standardLength: 6000,
cutLength: cutLength,
calculation: `${lengthMm}mm + ${quantity * 2}mm(여유분) = ${cutLength}mm → ${pipeCount}`
cutLength: requiredLength,
calculation: `${totalLengthMm}mm + ${cuttingLoss}mm(절단손실) = ${requiredLength}mm → ${pipeCount}`
};
};

BIN
tk-mp-project.tar.gz Normal file

Binary file not shown.