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

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