feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
"""
|
||||
파일 관리 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"]
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,3 +61,19 @@ __all__ = [
|
||||
'RolePermission',
|
||||
'UserRepository'
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -391,3 +391,19 @@ async def delete_user(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="사용자 삭제 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -249,3 +249,19 @@ class JWTService:
|
||||
|
||||
# JWT 서비스 인스턴스
|
||||
jwt_service = JWTService()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -303,3 +303,19 @@ async def get_current_user_optional(
|
||||
except Exception as e:
|
||||
logger.debug(f"Optional auth failed: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -352,3 +352,19 @@ class UserRepository:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -205,8 +205,10 @@ class Settings(BaseSettings):
|
||||
"development": [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:13000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173"
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:13000"
|
||||
],
|
||||
"production": [
|
||||
"https://your-domain.com",
|
||||
|
||||
@@ -18,7 +18,7 @@ settings = get_settings()
|
||||
# 로거 설정
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
# FastAPI 앱 생성 (요청 크기 제한 증가)
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="자재 분류 및 프로젝트 관리 시스템",
|
||||
@@ -26,6 +26,27 @@ app = FastAPI(
|
||||
debug=settings.debug
|
||||
)
|
||||
|
||||
# 요청 크기 제한 설정 (100MB로 증가)
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, max_request_size: int = 100 * 1024 * 1024): # 100MB
|
||||
super().__init__(app)
|
||||
self.max_request_size = max_request_size
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if "content-length" in request.headers:
|
||||
content_length = int(request.headers["content-length"])
|
||||
if content_length > self.max_request_size:
|
||||
return Response("Request Entity Too Large", status_code=413)
|
||||
return await call_next(request)
|
||||
|
||||
# 요청 크기 제한 미들웨어 추가
|
||||
app.add_middleware(RequestSizeLimitMiddleware, max_request_size=100 * 1024 * 1024)
|
||||
|
||||
# 에러 핸들러 설정
|
||||
setup_error_handlers(app)
|
||||
|
||||
@@ -38,10 +59,11 @@ app.add_middleware(
|
||||
|
||||
logger.info(f"CORS origins configured for {settings.environment}: {settings.security.cors_origins}")
|
||||
|
||||
# 라우터들 import 및 등록
|
||||
# 라우터들 import 및 등록 - files 라우터를 최우선으로 등록
|
||||
try:
|
||||
from .routers import files
|
||||
app.include_router(files.router, prefix="/files", tags=["files"])
|
||||
logger.info("FILES 라우터 등록 완료 - 최우선")
|
||||
except ImportError:
|
||||
logger.warning("files 라우터를 찾을 수 없습니다")
|
||||
|
||||
@@ -63,19 +85,26 @@ try:
|
||||
except ImportError:
|
||||
logger.warning("material_comparison 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import dashboard
|
||||
app.include_router(dashboard.router, tags=["dashboard"])
|
||||
except ImportError:
|
||||
logger.warning("dashboard 라우터를 찾을 수 없습니다")
|
||||
|
||||
try:
|
||||
from .routers import tubing
|
||||
app.include_router(tubing.router, prefix="/tubing", tags=["tubing"])
|
||||
except ImportError:
|
||||
logger.warning("tubing 라우터를 찾을 수 없습니다")
|
||||
|
||||
# 파일 관리 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 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
|
||||
# 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}")
|
||||
logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용)")
|
||||
|
||||
# 인증 API 라우터 등록
|
||||
try:
|
||||
|
||||
427
backend/app/routers/dashboard.py
Normal file
427
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
대시보드 API
|
||||
사용자별 맞춤형 대시보드 데이터 제공
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, func
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..database import get_db
|
||||
from ..auth.middleware import get_current_user
|
||||
from ..services.activity_logger import ActivityLogger
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_dashboard_stats(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자별 맞춤형 대시보드 통계 데이터 조회
|
||||
|
||||
Returns:
|
||||
dict: 사용자 역할에 맞는 통계 데이터
|
||||
"""
|
||||
try:
|
||||
username = current_user.get('username')
|
||||
user_role = current_user.get('role', 'user')
|
||||
|
||||
# 역할별 맞춤 통계 생성
|
||||
if user_role == 'admin':
|
||||
stats = await get_admin_stats(db)
|
||||
elif user_role == 'manager':
|
||||
stats = await get_manager_stats(db, username)
|
||||
elif user_role == 'designer':
|
||||
stats = await get_designer_stats(db, username)
|
||||
elif user_role == 'purchaser':
|
||||
stats = await get_purchaser_stats(db, username)
|
||||
else:
|
||||
stats = await get_user_stats(db, username)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_role": user_role,
|
||||
"stats": stats
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dashboard stats error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"대시보드 통계 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/activities")
|
||||
async def get_user_activities(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 활동 이력 조회
|
||||
|
||||
Args:
|
||||
limit: 조회할 활동 수 (1-50)
|
||||
|
||||
Returns:
|
||||
dict: 사용자 활동 이력
|
||||
"""
|
||||
try:
|
||||
username = current_user.get('username')
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
activities = activity_logger.get_user_activities(
|
||||
username=username,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"activities": activities,
|
||||
"total": len(activities)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User activities error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"활동 이력 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/recent-activities")
|
||||
async def get_recent_activities(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
days: int = Query(7, ge=1, le=30),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
최근 전체 활동 조회 (관리자/매니저용)
|
||||
|
||||
Args:
|
||||
days: 조회 기간 (일)
|
||||
limit: 조회할 활동 수
|
||||
|
||||
Returns:
|
||||
dict: 최근 활동 이력
|
||||
"""
|
||||
try:
|
||||
user_role = current_user.get('role', 'user')
|
||||
|
||||
# 관리자와 매니저만 전체 활동 조회 가능
|
||||
if user_role not in ['admin', 'manager']:
|
||||
raise HTTPException(status_code=403, detail="권한이 없습니다")
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
activities = activity_logger.get_recent_activities(
|
||||
days=days,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"activities": activities,
|
||||
"period_days": days,
|
||||
"total": len(activities)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Recent activities error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"최근 활동 조회 실패: {str(e)}")
|
||||
|
||||
|
||||
async def get_admin_stats(db: Session) -> Dict[str, Any]:
|
||||
"""관리자용 통계"""
|
||||
try:
|
||||
# 전체 프로젝트 수
|
||||
total_projects_query = text("SELECT COUNT(*) FROM jobs WHERE status != 'deleted'")
|
||||
total_projects = db.execute(total_projects_query).scalar()
|
||||
|
||||
# 활성 사용자 수 (최근 30일 로그인)
|
||||
active_users_query = text("""
|
||||
SELECT COUNT(DISTINCT username)
|
||||
FROM user_activity_logs
|
||||
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
""")
|
||||
active_users = db.execute(active_users_query).scalar() or 0
|
||||
|
||||
# 오늘 업로드된 파일 수
|
||||
today_uploads_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM files
|
||||
WHERE DATE(upload_date) = CURRENT_DATE
|
||||
""")
|
||||
today_uploads = db.execute(today_uploads_query).scalar() or 0
|
||||
|
||||
# 전체 자재 수
|
||||
total_materials_query = text("SELECT COUNT(*) FROM materials")
|
||||
total_materials = db.execute(total_materials_query).scalar() or 0
|
||||
|
||||
return {
|
||||
"title": "시스템 관리자",
|
||||
"subtitle": "전체 시스템을 관리하고 모니터링합니다",
|
||||
"metrics": [
|
||||
{"label": "전체 프로젝트 수", "value": total_projects, "icon": "📋", "color": "#667eea"},
|
||||
{"label": "활성 사용자 수", "value": active_users, "icon": "👥", "color": "#48bb78"},
|
||||
{"label": "시스템 상태", "value": "정상", "icon": "🟢", "color": "#38b2ac"},
|
||||
{"label": "오늘 업로드", "value": today_uploads, "icon": "📤", "color": "#ed8936"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Admin stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_manager_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""매니저용 통계"""
|
||||
try:
|
||||
# 담당 프로젝트 수 (향후 assigned_to 필드 활용)
|
||||
assigned_projects_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM jobs
|
||||
WHERE (assigned_to = :username OR created_by = :username)
|
||||
AND status != 'deleted'
|
||||
""")
|
||||
assigned_projects = db.execute(assigned_projects_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 이번 주 완료된 작업 (활동 로그 기반)
|
||||
week_completed_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM user_activity_logs
|
||||
WHERE activity_type IN ('PROJECT_CREATE', 'PURCHASE_CONFIRM')
|
||||
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||||
""")
|
||||
week_completed = db.execute(week_completed_query).scalar() or 0
|
||||
|
||||
# 승인 대기 (구매 확정 대기 등)
|
||||
pending_approvals_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status = 'PENDING'
|
||||
OR purchase_status = 'REQUESTED'
|
||||
""")
|
||||
pending_approvals = db.execute(pending_approvals_query).scalar() or 0
|
||||
|
||||
return {
|
||||
"title": "프로젝트 매니저",
|
||||
"subtitle": "팀 프로젝트를 관리하고 진행상황을 모니터링합니다",
|
||||
"metrics": [
|
||||
{"label": "담당 프로젝트", "value": assigned_projects, "icon": "📋", "color": "#667eea"},
|
||||
{"label": "팀 진행률", "value": "87%", "icon": "📈", "color": "#48bb78"},
|
||||
{"label": "승인 대기", "value": pending_approvals, "icon": "⏳", "color": "#ed8936"},
|
||||
{"label": "이번 주 완료", "value": week_completed, "icon": "✅", "color": "#38b2ac"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Manager stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_designer_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""설계자용 통계"""
|
||||
try:
|
||||
# 내가 업로드한 BOM 파일 수
|
||||
my_files_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM files
|
||||
WHERE uploaded_by = :username
|
||||
AND is_active = true
|
||||
""")
|
||||
my_files = db.execute(my_files_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 분류된 자재 수
|
||||
classified_materials_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.uploaded_by = :username
|
||||
AND m.classified_category IS NOT NULL
|
||||
""")
|
||||
classified_materials = db.execute(classified_materials_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 검증 대기 자재 수
|
||||
pending_verification_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.uploaded_by = :username
|
||||
AND m.is_verified = false
|
||||
""")
|
||||
pending_verification = db.execute(pending_verification_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 이번 주 업로드 수
|
||||
week_uploads_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM files
|
||||
WHERE uploaded_by = :username
|
||||
AND upload_date >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||||
""")
|
||||
week_uploads = db.execute(week_uploads_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 분류 완료율 계산
|
||||
total_materials_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE f.uploaded_by = :username
|
||||
""")
|
||||
total_materials = db.execute(total_materials_query, {"username": username}).scalar() or 1
|
||||
|
||||
classification_rate = f"{(classified_materials / total_materials * 100):.0f}%" if total_materials > 0 else "0%"
|
||||
|
||||
return {
|
||||
"title": "설계 담당자",
|
||||
"subtitle": "BOM 파일을 관리하고 자재를 분류합니다",
|
||||
"metrics": [
|
||||
{"label": "내 BOM 파일", "value": my_files, "icon": "📄", "color": "#667eea"},
|
||||
{"label": "분류 완료율", "value": classification_rate, "icon": "🎯", "color": "#48bb78"},
|
||||
{"label": "검증 대기", "value": pending_verification, "icon": "⏳", "color": "#ed8936"},
|
||||
{"label": "이번 주 업로드", "value": week_uploads, "icon": "📤", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Designer stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_purchaser_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""구매자용 통계"""
|
||||
try:
|
||||
# 구매 요청 수
|
||||
purchase_requests_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status IN ('PENDING', 'REQUESTED')
|
||||
""")
|
||||
purchase_requests = db.execute(purchase_requests_query).scalar() or 0
|
||||
|
||||
# 발주 완료 수
|
||||
orders_completed_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status = 'CONFIRMED'
|
||||
AND confirmed_by = :username
|
||||
""")
|
||||
orders_completed = db.execute(orders_completed_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 입고 대기 수
|
||||
receiving_pending_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM material_purchase_tracking
|
||||
WHERE purchase_status = 'ORDERED'
|
||||
""")
|
||||
receiving_pending = db.execute(receiving_pending_query).scalar() or 0
|
||||
|
||||
# 이번 달 구매 금액 (임시 데이터)
|
||||
monthly_amount = "₩2.3M" # 실제로는 계산 필요
|
||||
|
||||
return {
|
||||
"title": "구매 담당자",
|
||||
"subtitle": "구매 요청을 처리하고 발주를 관리합니다",
|
||||
"metrics": [
|
||||
{"label": "구매 요청", "value": purchase_requests, "icon": "🛒", "color": "#667eea"},
|
||||
{"label": "발주 완료", "value": orders_completed, "icon": "✅", "color": "#48bb78"},
|
||||
{"label": "입고 대기", "value": receiving_pending, "icon": "📦", "color": "#ed8936"},
|
||||
{"label": "이번 달 금액", "value": monthly_amount, "icon": "💰", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Purchaser stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_user_stats(db: Session, username: str) -> Dict[str, Any]:
|
||||
"""일반 사용자용 통계"""
|
||||
try:
|
||||
# 내 활동 수 (최근 7일)
|
||||
my_activities_query = text("""
|
||||
SELECT COUNT(*)
|
||||
FROM user_activity_logs
|
||||
WHERE username = :username
|
||||
AND created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'
|
||||
""")
|
||||
my_activities = db.execute(my_activities_query, {"username": username}).scalar() or 0
|
||||
|
||||
# 접근 가능한 프로젝트 수 (임시)
|
||||
accessible_projects = 5
|
||||
|
||||
return {
|
||||
"title": "일반 사용자",
|
||||
"subtitle": "할당된 업무를 수행하고 프로젝트에 참여합니다",
|
||||
"metrics": [
|
||||
{"label": "내 업무", "value": 6, "icon": "📋", "color": "#667eea"},
|
||||
{"label": "완료율", "value": "75%", "icon": "📈", "color": "#48bb78"},
|
||||
{"label": "대기 중", "value": 2, "icon": "⏳", "color": "#ed8936"},
|
||||
{"label": "이번 주 활동", "value": my_activities, "icon": "🎯", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User stats error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/quick-actions")
|
||||
async def get_quick_actions(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
사용자 역할별 빠른 작업 메뉴 조회
|
||||
|
||||
Returns:
|
||||
dict: 역할별 빠른 작업 목록
|
||||
"""
|
||||
try:
|
||||
user_role = current_user.get('role', 'user')
|
||||
|
||||
quick_actions = {
|
||||
"admin": [
|
||||
{"title": "사용자 관리", "icon": "👤", "path": "/admin/users", "color": "#667eea"},
|
||||
{"title": "시스템 설정", "icon": "⚙️", "path": "/admin/settings", "color": "#48bb78"},
|
||||
{"title": "백업 관리", "icon": "💾", "path": "/admin/backup", "color": "#ed8936"},
|
||||
{"title": "활동 로그", "icon": "📊", "path": "/admin/logs", "color": "#9f7aea"}
|
||||
],
|
||||
"manager": [
|
||||
{"title": "프로젝트 생성", "icon": "➕", "path": "/projects/new", "color": "#667eea"},
|
||||
{"title": "팀 관리", "icon": "👥", "path": "/team", "color": "#48bb78"},
|
||||
{"title": "진행 상황", "icon": "📊", "path": "/progress", "color": "#38b2ac"},
|
||||
{"title": "승인 처리", "icon": "✅", "path": "/approvals", "color": "#ed8936"}
|
||||
],
|
||||
"designer": [
|
||||
{"title": "BOM 업로드", "icon": "📤", "path": "/upload", "color": "#667eea"},
|
||||
{"title": "자재 분류", "icon": "🔧", "path": "/materials", "color": "#48bb78"},
|
||||
{"title": "리비전 관리", "icon": "🔄", "path": "/revisions", "color": "#38b2ac"},
|
||||
{"title": "분류 검증", "icon": "✅", "path": "/verify", "color": "#ed8936"}
|
||||
],
|
||||
"purchaser": [
|
||||
{"title": "구매 확정", "icon": "🛒", "path": "/purchase", "color": "#667eea"},
|
||||
{"title": "발주 관리", "icon": "📋", "path": "/orders", "color": "#48bb78"},
|
||||
{"title": "공급업체", "icon": "🏢", "path": "/suppliers", "color": "#38b2ac"},
|
||||
{"title": "입고 처리", "icon": "📦", "path": "/receiving", "color": "#ed8936"}
|
||||
],
|
||||
"user": [
|
||||
{"title": "내 업무", "icon": "📋", "path": "/my-tasks", "color": "#667eea"},
|
||||
{"title": "프로젝트 보기", "icon": "👁️", "path": "/projects", "color": "#48bb78"},
|
||||
{"title": "리포트 다운로드", "icon": "📊", "path": "/reports", "color": "#38b2ac"},
|
||||
{"title": "도움말", "icon": "❓", "path": "/help", "color": "#9f7aea"}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_role": user_role,
|
||||
"quick_actions": quick_actions.get(user_role, quick_actions["user"])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Quick actions error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"빠른 작업 조회 실패: {str(e)}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,399 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import pandas as pd
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from ..database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_DIR = Path("uploads")
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
||||
|
||||
@router.get("/")
|
||||
async def get_files_info():
|
||||
return {
|
||||
"message": "파일 관리 API",
|
||||
"allowed_extensions": list(ALLOWED_EXTENSIONS),
|
||||
"upload_directory": str(UPLOAD_DIR)
|
||||
}
|
||||
|
||||
@router.get("/test")
|
||||
async def test_endpoint():
|
||||
return {"status": "파일 API가 정상 작동합니다!"}
|
||||
|
||||
@router.post("/add-missing-columns")
|
||||
async def add_missing_columns(db: Session = Depends(get_db)):
|
||||
"""누락된 컬럼들 추가"""
|
||||
try:
|
||||
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
|
||||
db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER"))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "누락된 컬럼들이 추가되었습니다",
|
||||
"added_columns": ["files.parsed_count", "materials.row_number"]
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"}
|
||||
|
||||
def validate_file_extension(filename: str) -> bool:
|
||||
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
stem = Path(original_filename).stem
|
||||
suffix = Path(original_filename).suffix
|
||||
return f"{stem}_{timestamp}_{unique_id}{suffix}"
|
||||
|
||||
def parse_dataframe(df):
|
||||
df = df.dropna(how='all')
|
||||
df.columns = df.columns.str.strip().str.lower()
|
||||
|
||||
column_mapping = {
|
||||
'description': ['description', 'item', 'material', '품명', '자재명'],
|
||||
'quantity': ['qty', 'quantity', 'ea', '수량'],
|
||||
'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'],
|
||||
'red_size': ['red_nom', 'reduced_diameter', '축소배관'],
|
||||
'length': ['length', 'len', '길이'],
|
||||
'weight': ['weight', 'wt', '중량'],
|
||||
'dwg_name': ['dwg_name', 'drawing', '도면명'],
|
||||
'line_num': ['line_num', 'line_number', '라인번호']
|
||||
}
|
||||
|
||||
mapped_columns = {}
|
||||
for standard_col, possible_names in column_mapping.items():
|
||||
for possible_name in possible_names:
|
||||
if possible_name in df.columns:
|
||||
mapped_columns[standard_col] = possible_name
|
||||
break
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||
|
||||
try:
|
||||
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
material_grade = ""
|
||||
if "ASTM" in description.upper():
|
||||
astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper())
|
||||
if astm_match:
|
||||
material_grade = astm_match.group(0).strip()
|
||||
|
||||
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
|
||||
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
|
||||
|
||||
if main_size != 'nan' and red_size != 'nan' and red_size != '':
|
||||
size_spec = f"{main_size} x {red_size}"
|
||||
elif main_size != 'nan' and main_size != '':
|
||||
size_spec = main_size
|
||||
else:
|
||||
size_spec = ""
|
||||
|
||||
if description and description not in ['nan', 'None', '']:
|
||||
materials.append({
|
||||
'original_description': description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'material_grade': material_grade,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
|
||||
def parse_file_data(file_path):
|
||||
file_extension = Path(file_path).suffix.lower()
|
||||
|
||||
try:
|
||||
if file_extension == ".csv":
|
||||
df = pd.read_csv(file_path, encoding='utf-8')
|
||||
elif file_extension in [".xlsx", ".xls"]:
|
||||
df = pd.read_excel(file_path, sheet_name=0)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식")
|
||||
|
||||
return parse_dataframe(df)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}")
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
job_no: str = Form(...),
|
||||
revision: str = Form("Rev.0"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not validate_file_extension(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
if file.size and file.size > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
|
||||
|
||||
unique_filename = generate_unique_filename(file.filename)
|
||||
file_path = UPLOAD_DIR / unique_filename
|
||||
|
||||
try:
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||
|
||||
try:
|
||||
materials_data = parse_file_data(str(file_path))
|
||||
parsed_count = len(materials_data)
|
||||
|
||||
# 파일 정보 저장
|
||||
file_insert_query = text("""
|
||||
INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active)
|
||||
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :description, :file_size, :parsed_count, :is_active)
|
||||
RETURNING id
|
||||
""")
|
||||
|
||||
file_result = db.execute(file_insert_query, {
|
||||
"filename": unique_filename,
|
||||
"original_filename": file.filename,
|
||||
"file_path": str(file_path),
|
||||
"job_no": job_no,
|
||||
"revision": revision,
|
||||
"description": f"BOM 파일 - {parsed_count}개 자재",
|
||||
"file_size": file.size,
|
||||
"parsed_count": parsed_count,
|
||||
"is_active": True
|
||||
})
|
||||
|
||||
file_id = file_result.fetchone()[0]
|
||||
|
||||
# 자재 데이터 저장
|
||||
materials_inserted = 0
|
||||
for material_data in materials_data:
|
||||
material_insert_query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, quantity, unit, size_spec,
|
||||
material_grade, line_number, row_number, classified_category,
|
||||
classification_confidence, is_verified, created_at
|
||||
)
|
||||
VALUES (
|
||||
:file_id, :original_description, :quantity, :unit, :size_spec,
|
||||
:material_grade, :line_number, :row_number, :classified_category,
|
||||
:classification_confidence, :is_verified, :created_at
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(material_insert_query, {
|
||||
"file_id": file_id,
|
||||
"original_description": material_data["original_description"],
|
||||
"quantity": material_data["quantity"],
|
||||
"unit": material_data["unit"],
|
||||
"size_spec": material_data["size_spec"],
|
||||
"material_grade": material_data["material_grade"],
|
||||
"line_number": material_data["line_number"],
|
||||
"row_number": material_data["row_number"],
|
||||
"classified_category": None,
|
||||
"classification_confidence": None,
|
||||
"is_verified": False,
|
||||
"created_at": datetime.now()
|
||||
})
|
||||
materials_inserted += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨",
|
||||
"original_filename": file.filename,
|
||||
"file_id": file_id,
|
||||
"parsed_materials_count": parsed_count,
|
||||
"saved_materials_count": materials_inserted,
|
||||
"sample_materials": materials_data[:3] if materials_data else []
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
|
||||
@router.get("/materials")
|
||||
async def get_materials(
|
||||
job_no: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""저장된 자재 목록 조회"""
|
||||
try:
|
||||
query = """
|
||||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
||||
m.size_spec, m.material_grade, m.line_number, m.row_number,
|
||||
m.created_at,
|
||||
f.original_filename, f.job_no,
|
||||
j.job_no, j.job_name
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
LEFT JOIN jobs j ON f.job_no = j.job_no
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " AND f.job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
if file_id:
|
||||
query += " AND m.file_id = :file_id"
|
||||
params["file_id"] = file_id
|
||||
|
||||
query += " ORDER BY m.line_number ASC LIMIT :limit OFFSET :skip"
|
||||
params["limit"] = limit
|
||||
params["skip"] = skip
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
materials = result.fetchall()
|
||||
|
||||
# 전체 개수 조회
|
||||
count_query = """
|
||||
SELECT COUNT(*) as total
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
count_params = {}
|
||||
|
||||
if job_no:
|
||||
count_query += " AND f.job_no = :job_no"
|
||||
count_params["job_no"] = job_no
|
||||
|
||||
if file_id:
|
||||
count_query += " AND m.file_id = :file_id"
|
||||
count_params["file_id"] = file_id
|
||||
|
||||
count_result = db.execute(text(count_query), count_params)
|
||||
total_count = count_result.fetchone()[0]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_count": total_count,
|
||||
"returned_count": len(materials),
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"materials": [
|
||||
{
|
||||
"id": m.id,
|
||||
"file_id": m.file_id,
|
||||
"filename": m.original_filename,
|
||||
"job_no": m.job_no,
|
||||
"project_code": m.official_project_code,
|
||||
"project_name": m.project_name,
|
||||
"original_description": m.original_description,
|
||||
"quantity": float(m.quantity) if m.quantity else 0,
|
||||
"unit": m.unit,
|
||||
"size_spec": m.size_spec,
|
||||
"material_grade": m.material_grade,
|
||||
"line_number": m.line_number,
|
||||
"row_number": m.row_number,
|
||||
"created_at": m.created_at
|
||||
}
|
||||
for m in materials
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/materials/summary")
|
||||
async def get_materials_summary(
|
||||
job_no: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""자재 요약 통계"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_items,
|
||||
COUNT(DISTINCT m.original_description) as unique_descriptions,
|
||||
COUNT(DISTINCT m.size_spec) as unique_sizes,
|
||||
COUNT(DISTINCT m.material_grade) as unique_materials,
|
||||
SUM(m.quantity) as total_quantity,
|
||||
AVG(m.quantity) as avg_quantity,
|
||||
MIN(m.created_at) as earliest_upload,
|
||||
MAX(m.created_at) as latest_upload
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " AND f.job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
if file_id:
|
||||
query += " AND m.file_id = :file_id"
|
||||
params["file_id"] = file_id
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
summary = result.fetchone()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summary": {
|
||||
"total_items": summary.total_items,
|
||||
"unique_descriptions": summary.unique_descriptions,
|
||||
"unique_sizes": summary.unique_sizes,
|
||||
"unique_materials": summary.unique_materials,
|
||||
"total_quantity": float(summary.total_quantity) if summary.total_quantity else 0,
|
||||
"avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0,
|
||||
"earliest_upload": summary.earliest_upload,
|
||||
"latest_upload": summary.latest_upload
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}")
|
||||
# Job 검증 함수 (파일 끝에 추가할 예정)
|
||||
async def validate_job_exists(job_no: str, db: Session):
|
||||
"""Job 존재 여부 및 활성 상태 확인"""
|
||||
try:
|
||||
query = text("SELECT job_no, job_name, status FROM jobs WHERE job_no = :job_no AND is_active = true")
|
||||
job = db.execute(query, {"job_no": job_no}).fetchone()
|
||||
|
||||
if not job:
|
||||
return {"valid": False, "error": f"Job No. '{job_no}'를 찾을 수 없습니다"}
|
||||
|
||||
if job.status == '완료':
|
||||
return {"valid": False, "error": f"완료된 Job '{job.job_name}'에는 파일을 업로드할 수 없습니다"}
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"job": {
|
||||
"job_no": job.job_no,
|
||||
"job_name": job.job_name,
|
||||
"status": job.status
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"valid": False, "error": f"Job 검증 실패: {str(e)}"}
|
||||
@@ -157,6 +157,26 @@ async def confirm_material_purchase(
|
||||
]
|
||||
"""
|
||||
try:
|
||||
# 입력 데이터 검증
|
||||
if not job_no or not revision:
|
||||
raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다")
|
||||
|
||||
if not confirmations:
|
||||
raise HTTPException(status_code=400, detail="확정할 자재가 없습니다")
|
||||
|
||||
# 각 확정 항목 검증
|
||||
for i, confirmation in enumerate(confirmations):
|
||||
if not confirmation.get("material_hash"):
|
||||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다")
|
||||
|
||||
confirmed_qty = confirmation.get("confirmed_quantity")
|
||||
if confirmed_qty is None or confirmed_qty < 0:
|
||||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다")
|
||||
|
||||
unit_price = confirmation.get("unit_price", 0)
|
||||
if unit_price < 0:
|
||||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 단가가 유효하지 않습니다")
|
||||
|
||||
confirmed_items = []
|
||||
|
||||
for confirmation in confirmations:
|
||||
@@ -470,7 +490,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
"""파일의 자재를 해시별로 그룹화하여 조회"""
|
||||
import hashlib
|
||||
|
||||
print(f"🚨🚨🚨 get_materials_by_hash 호출됨! file_id={file_id} 🚨🚨🚨")
|
||||
# 로그 제거
|
||||
|
||||
query = text("""
|
||||
SELECT
|
||||
@@ -492,11 +512,7 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
result = db.execute(query, {"file_id": file_id})
|
||||
materials = result.fetchall()
|
||||
|
||||
print(f"🔍 쿼리 결과 개수: {len(materials)}")
|
||||
if len(materials) > 0:
|
||||
print(f"🔍 첫 번째 자료 샘플: {materials[0]}")
|
||||
else:
|
||||
print(f"❌ 자료가 없음! file_id={file_id}")
|
||||
# 로그 제거
|
||||
|
||||
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
|
||||
materials_dict = {}
|
||||
@@ -505,38 +521,41 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
|
||||
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
|
||||
|
||||
print(f"📝 개별 자재: {mat[1][:50]}... ({mat[2]}) - 수량: {mat[4]}, 길이: {mat[7]}mm")
|
||||
# 개별 자재 로그 제거 (너무 많음)
|
||||
|
||||
if material_hash in materials_dict:
|
||||
# 🔄 기존 항목에 수량 합계
|
||||
existing = materials_dict[material_hash]
|
||||
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
|
||||
# 파이프가 아닌 경우만 quantity 합산 (파이프는 개별 길이가 다르므로 합산하지 않음)
|
||||
if mat[5] != 'PIPE':
|
||||
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
|
||||
existing["line_number"] += f", {mat[8]}" if mat[8] else ""
|
||||
|
||||
# 파이프인 경우 길이 정보 합산
|
||||
if mat[5] == 'PIPE' and mat[7] is not None:
|
||||
if "pipe_details" in existing:
|
||||
# 총길이 합산: 기존 총길이 + (현재 수량 × 현재 길이)
|
||||
# 총길이 합산: 기존 총길이 + 현재 파이프의 실제 길이 (DB에 저장된 개별 길이)
|
||||
current_total = existing["pipe_details"]["total_length_mm"]
|
||||
current_count = existing["pipe_details"]["pipe_count"]
|
||||
|
||||
new_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
|
||||
existing["pipe_details"]["total_length_mm"] = current_total + new_length
|
||||
existing["pipe_details"]["pipe_count"] = current_count + float(mat[4])
|
||||
# ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음
|
||||
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
|
||||
existing["pipe_details"]["total_length_mm"] = current_total + individual_length
|
||||
existing["pipe_details"]["pipe_count"] = current_count + 1 # 파이프 개수는 1개씩 증가
|
||||
|
||||
# 평균 단위 길이 재계산
|
||||
total_length = existing["pipe_details"]["total_length_mm"]
|
||||
total_count = existing["pipe_details"]["pipe_count"]
|
||||
existing["pipe_details"]["length_mm"] = total_length / total_count
|
||||
|
||||
print(f"🔄 파이프 합산: {mat[1]} ({mat[2]}) - 총길이: {total_length}mm, 총개수: {total_count}개, 평균: {total_length/total_count:.1f}mm")
|
||||
# 파이프 합산 로그 제거 (너무 많음)
|
||||
else:
|
||||
# 첫 파이프 정보 설정
|
||||
pipe_length = float(mat[4]) * float(mat[7])
|
||||
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
|
||||
existing["pipe_details"] = {
|
||||
"length_mm": float(mat[7]),
|
||||
"total_length_mm": pipe_length,
|
||||
"pipe_count": float(mat[4])
|
||||
"length_mm": individual_length,
|
||||
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
|
||||
"pipe_count": 1 # 첫 번째 파이프이므로 1개
|
||||
}
|
||||
else:
|
||||
# 🆕 새 항목 생성
|
||||
@@ -553,27 +572,22 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
|
||||
|
||||
# 파이프인 경우 pipe_details 정보 추가
|
||||
if mat[5] == 'PIPE' and mat[7] is not None:
|
||||
pipe_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
|
||||
individual_length = float(mat[7]) # 개별 파이프의 실제 길이
|
||||
material_data["pipe_details"] = {
|
||||
"length_mm": float(mat[7]), # 단위 길이
|
||||
"total_length_mm": pipe_length, # 총 길이
|
||||
"pipe_count": float(mat[4]) # 파이프 개수
|
||||
"length_mm": individual_length, # 개별 파이프 길이
|
||||
"total_length_mm": individual_length, # 첫 번째 파이프이므로 개별 길이와 동일
|
||||
"pipe_count": 1 # 첫 번째 파이프이므로 1개
|
||||
}
|
||||
print(f"🆕 파이프 신규: {mat[1]} ({mat[2]}) - 단위: {mat[7]}mm, 총길이: {pipe_length}mm")
|
||||
# 파이프는 quantity를 1로 설정 (pipe_count와 동일)
|
||||
material_data["quantity"] = 1
|
||||
|
||||
materials_dict[material_hash] = material_data
|
||||
|
||||
# 파이프 데이터가 포함되었는지 확인
|
||||
# 파이프 데이터 요약만 출력
|
||||
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
|
||||
pipe_with_details = sum(1 for data in materials_dict.values()
|
||||
if data.get('category') == 'PIPE' and 'pipe_details' in data)
|
||||
print(f"🔍 반환 결과: 총 {len(materials_dict)}개 자재, 파이프 {pipe_count}개, pipe_details 있는 파이프 {pipe_with_details}개")
|
||||
|
||||
# 첫 번째 파이프 데이터 샘플 출력
|
||||
for hash_key, data in materials_dict.items():
|
||||
if data.get('category') == 'PIPE':
|
||||
print(f"🔍 파이프 샘플: {data}")
|
||||
break
|
||||
print(f"✅ 자재 처리 완료: 총 {len(materials_dict)}개, 파이프 {pipe_count}개 (길이정보: {pipe_with_details}개)")
|
||||
|
||||
return materials_dict
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
- 리비전 비교
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..services.purchase_calculator import (
|
||||
@@ -21,6 +23,28 @@ from ..services.purchase_calculator import (
|
||||
|
||||
router = APIRouter(prefix="/purchase", tags=["purchase"])
|
||||
|
||||
# Pydantic 모델 (최적화된 구조)
|
||||
class PurchaseItemMinimal(BaseModel):
|
||||
"""구매 확정용 최소 필수 데이터"""
|
||||
item_code: str
|
||||
category: str
|
||||
specification: str
|
||||
size: str = ""
|
||||
material: str = ""
|
||||
bom_quantity: float
|
||||
calculated_qty: float
|
||||
unit: str = "EA"
|
||||
safety_factor: float = 1.0
|
||||
|
||||
class PurchaseConfirmRequest(BaseModel):
|
||||
job_no: str
|
||||
file_id: int
|
||||
bom_name: Optional[str] = None # 선택적 필드로 변경
|
||||
revision: str
|
||||
purchase_items: List[PurchaseItemMinimal] # 최적화된 구조 사용
|
||||
confirmed_at: str
|
||||
confirmed_by: str
|
||||
|
||||
@router.get("/items/calculate")
|
||||
async def calculate_purchase_items(
|
||||
job_no: str = Query(..., description="Job 번호"),
|
||||
@@ -39,7 +63,7 @@ async def calculate_purchase_items(
|
||||
file_query = text("""
|
||||
SELECT id FROM files
|
||||
WHERE job_no = :job_no AND revision = :revision AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
file_result = db.execute(file_query, {"job_no": job_no, "revision": revision}).fetchone()
|
||||
@@ -62,6 +86,139 @@ async def calculate_purchase_items(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"구매 품목 계산 실패: {str(e)}")
|
||||
|
||||
@router.post("/confirm")
|
||||
async def confirm_purchase_quantities(
|
||||
request: PurchaseConfirmRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
구매 수량 확정
|
||||
- 계산된 구매 수량을 확정 상태로 저장
|
||||
- 자재별 확정 수량 및 상태 업데이트
|
||||
- 리비전 비교를 위한 기준 데이터 생성
|
||||
"""
|
||||
try:
|
||||
# 1. 기존 확정 데이터 확인 및 업데이트 또는 삽입
|
||||
existing_query = text("""
|
||||
SELECT id FROM purchase_confirmations
|
||||
WHERE file_id = :file_id
|
||||
""")
|
||||
existing_result = db.execute(existing_query, {"file_id": request.file_id}).fetchone()
|
||||
|
||||
if existing_result:
|
||||
# 기존 데이터 업데이트
|
||||
confirmation_id = existing_result[0]
|
||||
update_query = text("""
|
||||
UPDATE purchase_confirmations
|
||||
SET job_no = :job_no,
|
||||
bom_name = :bom_name,
|
||||
revision = :revision,
|
||||
confirmed_at = :confirmed_at,
|
||||
confirmed_by = :confirmed_by,
|
||||
is_active = TRUE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :confirmation_id
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
"confirmation_id": confirmation_id,
|
||||
"job_no": request.job_no,
|
||||
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
|
||||
"revision": request.revision,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
})
|
||||
|
||||
# 기존 확정 품목들 삭제
|
||||
delete_items_query = text("""
|
||||
DELETE FROM confirmed_purchase_items
|
||||
WHERE confirmation_id = :confirmation_id
|
||||
""")
|
||||
db.execute(delete_items_query, {"confirmation_id": confirmation_id})
|
||||
else:
|
||||
# 새로운 확정 데이터 삽입
|
||||
confirm_query = text("""
|
||||
INSERT INTO purchase_confirmations (
|
||||
job_no, file_id, bom_name, revision,
|
||||
confirmed_at, confirmed_by, is_active, created_at
|
||||
) VALUES (
|
||||
:job_no, :file_id, :bom_name, :revision,
|
||||
:confirmed_at, :confirmed_by, TRUE, CURRENT_TIMESTAMP
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
confirm_result = db.execute(confirm_query, {
|
||||
"job_no": request.job_no,
|
||||
"file_id": request.file_id,
|
||||
"bom_name": request.bom_name or f"{request.job_no}_{request.revision}", # 기본값 제공
|
||||
"revision": request.revision,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
})
|
||||
|
||||
confirmation_id = confirm_result.fetchone()[0]
|
||||
|
||||
# 3. 확정된 구매 품목들 저장
|
||||
saved_items = 0
|
||||
for item in request.purchase_items:
|
||||
item_query = text("""
|
||||
INSERT INTO confirmed_purchase_items (
|
||||
confirmation_id, item_code, category, specification,
|
||||
size, material, bom_quantity, calculated_qty,
|
||||
unit, safety_factor, created_at
|
||||
) VALUES (
|
||||
:confirmation_id, :item_code, :category, :specification,
|
||||
:size, :material, :bom_quantity, :calculated_qty,
|
||||
:unit, :safety_factor, CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(item_query, {
|
||||
"confirmation_id": confirmation_id,
|
||||
"item_code": item.item_code or f"{item.category}-{saved_items+1}",
|
||||
"category": item.category,
|
||||
"specification": item.specification,
|
||||
"size": item.size or "",
|
||||
"material": item.material or "",
|
||||
"bom_quantity": item.bom_quantity,
|
||||
"calculated_qty": item.calculated_qty,
|
||||
"unit": item.unit,
|
||||
"safety_factor": item.safety_factor
|
||||
})
|
||||
saved_items += 1
|
||||
|
||||
# 4. 파일 상태를 확정으로 업데이트
|
||||
file_update_query = text("""
|
||||
UPDATE files
|
||||
SET purchase_confirmed = TRUE,
|
||||
confirmed_at = :confirmed_at,
|
||||
confirmed_by = :confirmed_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :file_id
|
||||
""")
|
||||
|
||||
db.execute(file_update_query, {
|
||||
"file_id": request.file_id,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "구매 수량이 성공적으로 확정되었습니다",
|
||||
"confirmation_id": confirmation_id,
|
||||
"confirmed_items": saved_items,
|
||||
"job_no": request.job_no,
|
||||
"revision": request.revision,
|
||||
"confirmed_at": request.confirmed_at,
|
||||
"confirmed_by": request.confirmed_by
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"구매 수량 확정 실패: {str(e)}")
|
||||
|
||||
@router.post("/items/save")
|
||||
async def save_purchase_items(
|
||||
job_no: str,
|
||||
|
||||
362
backend/app/services/activity_logger.py
Normal file
362
backend/app/services/activity_logger.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
사용자 활동 로그 서비스
|
||||
모든 업무 활동을 추적하고 기록하는 서비스
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import Request
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ActivityLogger:
|
||||
"""사용자 활동 로그 관리 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def log_activity(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
사용자 활동 로그 기록
|
||||
|
||||
Args:
|
||||
username: 사용자명 (필수)
|
||||
activity_type: 활동 유형 (FILE_UPLOAD, PROJECT_CREATE 등)
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID (파일, 프로젝트 등)
|
||||
target_type: 대상 유형 (FILE, PROJECT 등)
|
||||
user_id: 사용자 ID
|
||||
ip_address: IP 주소
|
||||
user_agent: 브라우저 정보
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
try:
|
||||
insert_query = text("""
|
||||
INSERT INTO user_activity_logs (
|
||||
user_id, username, activity_type, activity_description,
|
||||
target_id, target_type, ip_address, user_agent, metadata
|
||||
) VALUES (
|
||||
:user_id, :username, :activity_type, :activity_description,
|
||||
:target_id, :target_type, :ip_address, :user_agent, :metadata
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
result = self.db.execute(insert_query, {
|
||||
'user_id': user_id,
|
||||
'username': username,
|
||||
'activity_type': activity_type,
|
||||
'activity_description': activity_description,
|
||||
'target_id': target_id,
|
||||
'target_type': target_type,
|
||||
'ip_address': ip_address,
|
||||
'user_agent': user_agent,
|
||||
'metadata': json.dumps(metadata) if metadata else None
|
||||
})
|
||||
|
||||
log_id = result.fetchone()[0]
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}")
|
||||
return log_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log activity: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def log_file_upload(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
filename: str,
|
||||
file_size: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""파일 업로드 활동 로그"""
|
||||
metadata = {
|
||||
'filename': filename,
|
||||
'file_size': file_size,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'upload_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='FILE_UPLOAD',
|
||||
activity_description=f'BOM 파일 업로드: {filename} (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_project_create(
|
||||
self,
|
||||
username: str,
|
||||
project_id: int,
|
||||
project_name: str,
|
||||
job_no: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""프로젝트 생성 활동 로그"""
|
||||
metadata = {
|
||||
'project_name': project_name,
|
||||
'job_no': job_no,
|
||||
'create_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PROJECT_CREATE',
|
||||
activity_description=f'프로젝트 생성: {project_name} ({job_no})',
|
||||
target_id=project_id,
|
||||
target_type='PROJECT',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_material_classify(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
classified_count: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""자재 분류 활동 로그"""
|
||||
metadata = {
|
||||
'classified_count': classified_count,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'classify_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='MATERIAL_CLASSIFY',
|
||||
activity_description=f'자재 분류 완료: {classified_count}개 자재 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_purchase_confirm(
|
||||
self,
|
||||
username: str,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
confirmed_count: int,
|
||||
total_amount: Optional[float] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""구매 확정 활동 로그"""
|
||||
metadata = {
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'confirmed_count': confirmed_count,
|
||||
'total_amount': total_amount,
|
||||
'confirm_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PURCHASE_CONFIRM',
|
||||
activity_description=f'구매 확정: {confirmed_count}개 품목 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=None, # 구매는 특정 ID가 없음
|
||||
target_type='PURCHASE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def get_user_activities(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> list:
|
||||
"""사용자 활동 이력 조회"""
|
||||
try:
|
||||
where_clause = "WHERE username = :username"
|
||||
params = {'username': username}
|
||||
|
||||
if activity_type:
|
||||
where_clause += " AND activity_type = :activity_type"
|
||||
params['activity_type'] = activity_type
|
||||
|
||||
query = text(f"""
|
||||
SELECT
|
||||
id, activity_type, activity_description,
|
||||
target_id, target_type, metadata, created_at
|
||||
FROM user_activity_logs
|
||||
{where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
params.update({'limit': limit, 'offset': offset})
|
||||
result = self.db.execute(query, params)
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'id': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'metadata': json.loads(row[5]) if row[5] else {},
|
||||
'created_at': row[6].isoformat() if row[6] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user activities: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_recent_activities(
|
||||
self,
|
||||
days: int = 7,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""최근 활동 조회 (전체 사용자)"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
username, activity_type, activity_description,
|
||||
target_id, target_type, created_at
|
||||
FROM user_activity_logs
|
||||
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
""" % days)
|
||||
|
||||
result = self.db.execute(query, {'limit': limit})
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'username': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'created_at': row[5].isoformat() if row[5] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get recent activities: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_client_info(request: Request) -> tuple:
|
||||
"""
|
||||
요청에서 클라이언트 정보 추출
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
tuple: (ip_address, user_agent)
|
||||
"""
|
||||
# IP 주소 추출 (프록시 고려)
|
||||
ip_address = (
|
||||
request.headers.get('x-forwarded-for', '').split(',')[0].strip() or
|
||||
request.headers.get('x-real-ip', '') or
|
||||
request.client.host if request.client else 'unknown'
|
||||
)
|
||||
|
||||
# User-Agent 추출
|
||||
user_agent = request.headers.get('user-agent', 'unknown')
|
||||
|
||||
return ip_address, user_agent
|
||||
|
||||
|
||||
def log_activity_from_request(
|
||||
db: Session,
|
||||
request: Request,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
요청 정보를 포함한 활동 로그 기록 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
request: FastAPI Request 객체
|
||||
username: 사용자명
|
||||
activity_type: 활동 유형
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID
|
||||
target_type: 대상 유형
|
||||
user_id: 사용자 ID
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
ip_address, user_agent = get_client_info(request)
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
return activity_logger.log_activity(
|
||||
username=username,
|
||||
activity_type=activity_type,
|
||||
activity_description=activity_description,
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
@@ -29,13 +29,13 @@ PIPE_MANUFACTURING = {
|
||||
# ========== PIPE 끝 가공별 분류 ==========
|
||||
PIPE_END_PREP = {
|
||||
"BOTH_ENDS_BEVELED": {
|
||||
"codes": ["BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
|
||||
"codes": ["BBE", "BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
|
||||
"cutting_note": "양쪽 개선",
|
||||
"machining_required": True,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"ONE_END_BEVELED": {
|
||||
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END"],
|
||||
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END", "POE"],
|
||||
"cutting_note": "한쪽 개선",
|
||||
"machining_required": True,
|
||||
"confidence": 0.95
|
||||
@@ -45,9 +45,85 @@ PIPE_END_PREP = {
|
||||
"cutting_note": "무 개선",
|
||||
"machining_required": False,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"THREADED": {
|
||||
"codes": ["TOE", "THE", "THREADED", "나사", "스레드"],
|
||||
"cutting_note": "나사 가공",
|
||||
"machining_required": True,
|
||||
"confidence": 0.90
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 구매용 파이프 분류 (끝단 가공 제외) ==========
|
||||
def get_purchase_pipe_description(description: str) -> str:
|
||||
"""구매용 파이프 설명 - 끝단 가공 정보 제거"""
|
||||
|
||||
# 모든 끝단 가공 코드들을 수집
|
||||
end_prep_codes = []
|
||||
for prep_data in PIPE_END_PREP.values():
|
||||
end_prep_codes.extend(prep_data["codes"])
|
||||
|
||||
# 설명에서 끝단 가공 코드 제거
|
||||
clean_description = description.upper()
|
||||
|
||||
# 끝단 가공 코드들을 길이 순으로 정렬 (긴 것부터 처리)
|
||||
end_prep_codes.sort(key=len, reverse=True)
|
||||
|
||||
for code in end_prep_codes:
|
||||
# 단어 경계를 고려하여 제거 (부분 매칭 방지)
|
||||
pattern = r'\b' + re.escape(code) + r'\b'
|
||||
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
|
||||
|
||||
# 끝단 가공 관련 패턴들 추가 제거
|
||||
# BOE-POE, POE-TOE 같은 조합 패턴들
|
||||
end_prep_patterns = [
|
||||
r'\b[A-Z]{2,3}E-[A-Z]{2,3}E\b', # BOE-POE, POE-TOE 등
|
||||
r'\b[A-Z]{2,3}E-[A-Z]{2,3}\b', # BOE-TO, POE-TO 등
|
||||
r'\b[A-Z]{2,3}-[A-Z]{2,3}E\b', # BO-POE, PO-TOE 등
|
||||
r'\b[A-Z]{2,3}-[A-Z]{2,3}\b', # BO-PO, PO-TO 등
|
||||
]
|
||||
|
||||
for pattern in end_prep_patterns:
|
||||
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
|
||||
|
||||
# 남은 하이픈과 공백 정리
|
||||
clean_description = re.sub(r'\s*-\s*', ' ', clean_description) # 하이픈 제거
|
||||
clean_description = re.sub(r'\s+', ' ', clean_description).strip() # 연속 공백 정리
|
||||
|
||||
return clean_description
|
||||
|
||||
def extract_end_preparation_info(description: str) -> Dict:
|
||||
"""파이프 설명에서 끝단 가공 정보 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 끝단 가공 코드 찾기
|
||||
for prep_type, prep_data in PIPE_END_PREP.items():
|
||||
for code in prep_data["codes"]:
|
||||
if code in desc_upper:
|
||||
return {
|
||||
"end_preparation_type": prep_type,
|
||||
"end_preparation_code": code,
|
||||
"machining_required": prep_data["machining_required"],
|
||||
"cutting_note": prep_data["cutting_note"],
|
||||
"confidence": prep_data["confidence"],
|
||||
"matched_pattern": code,
|
||||
"original_description": description,
|
||||
"clean_description": get_purchase_pipe_description(description)
|
||||
}
|
||||
|
||||
# 기본값: PBE (양쪽 무개선)
|
||||
return {
|
||||
"end_preparation_type": "NO_BEVEL", # PBE로 매핑될 예정
|
||||
"end_preparation_code": "PBE",
|
||||
"machining_required": False,
|
||||
"cutting_note": "양쪽 무개선 (기본값)",
|
||||
"confidence": 0.5,
|
||||
"matched_pattern": "DEFAULT",
|
||||
"original_description": description,
|
||||
"clean_description": get_purchase_pipe_description(description)
|
||||
}
|
||||
|
||||
# ========== PIPE 스케줄별 분류 ==========
|
||||
PIPE_SCHEDULE = {
|
||||
"patterns": [
|
||||
@@ -62,6 +138,23 @@ PIPE_SCHEDULE = {
|
||||
]
|
||||
}
|
||||
|
||||
def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
|
||||
|
||||
# 끝단 가공 정보 제거한 설명으로 분류
|
||||
clean_description = get_purchase_pipe_description(description)
|
||||
|
||||
# 기본 파이프 분류 수행
|
||||
result = classify_pipe(dat_file, clean_description, main_nom, length)
|
||||
|
||||
# 구매용임을 표시
|
||||
result["purchase_classification"] = True
|
||||
result["original_description"] = description
|
||||
result["clean_description"] = clean_description
|
||||
|
||||
return result
|
||||
|
||||
def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""
|
||||
|
||||
289
backend/app/services/revision_comparator.py
Normal file
289
backend/app/services/revision_comparator.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
리비전 비교 서비스
|
||||
- 기존 확정 자재와 신규 자재 비교
|
||||
- 변경된 자재만 분류 처리
|
||||
- 리비전 업로드 최적화
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RevisionComparator:
|
||||
"""리비전 비교 및 차이 분석 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]:
|
||||
"""
|
||||
이전 확정된 자재 목록 조회
|
||||
|
||||
Args:
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전 (예: Rev.1)
|
||||
|
||||
Returns:
|
||||
확정된 자재 정보 딕셔너리 또는 None
|
||||
"""
|
||||
try:
|
||||
# 현재 리비전 번호 추출
|
||||
current_rev_num = self._extract_revision_number(current_revision)
|
||||
|
||||
# 이전 리비전들 중 확정된 것 찾기 (역순으로 검색)
|
||||
for prev_rev_num in range(current_rev_num - 1, -1, -1):
|
||||
prev_revision = f"Rev.{prev_rev_num}"
|
||||
|
||||
# 해당 리비전의 확정 데이터 조회
|
||||
query = text("""
|
||||
SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by,
|
||||
COUNT(cpi.id) as confirmed_items_count
|
||||
FROM purchase_confirmations pc
|
||||
LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id
|
||||
WHERE pc.job_no = :job_no
|
||||
AND pc.revision = :revision
|
||||
AND pc.is_active = TRUE
|
||||
GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by
|
||||
ORDER BY pc.confirmed_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
result = self.db.execute(query, {
|
||||
"job_no": job_no,
|
||||
"revision": prev_revision
|
||||
}).fetchone()
|
||||
|
||||
if result and result.confirmed_items_count > 0:
|
||||
logger.info(f"이전 확정 자료 발견: {job_no} {prev_revision} ({result.confirmed_items_count}개 품목)")
|
||||
|
||||
# 확정된 품목들 상세 조회
|
||||
items_query = text("""
|
||||
SELECT cpi.item_code, cpi.category, cpi.specification,
|
||||
cpi.size, cpi.material, cpi.bom_quantity,
|
||||
cpi.calculated_qty, cpi.unit, cpi.safety_factor
|
||||
FROM confirmed_purchase_items cpi
|
||||
WHERE cpi.confirmation_id = :confirmation_id
|
||||
ORDER BY cpi.category, cpi.specification
|
||||
""")
|
||||
|
||||
items_result = self.db.execute(items_query, {
|
||||
"confirmation_id": result.id
|
||||
}).fetchall()
|
||||
|
||||
return {
|
||||
"confirmation_id": result.id,
|
||||
"revision": result.revision,
|
||||
"confirmed_at": result.confirmed_at,
|
||||
"confirmed_by": result.confirmed_by,
|
||||
"items": [dict(item) for item in items_result],
|
||||
"items_count": len(items_result)
|
||||
}
|
||||
|
||||
logger.info(f"이전 확정 자료 없음: {job_no} (현재: {current_revision})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이전 확정 자료 조회 실패: {str(e)}")
|
||||
return None
|
||||
|
||||
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
기존 확정 자재와 신규 자재 비교
|
||||
|
||||
Args:
|
||||
previous_confirmed: 이전 확정 자재 정보
|
||||
new_materials: 신규 업로드된 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
try:
|
||||
# 이전 확정 자재를 해시맵으로 변환 (빠른 검색을 위해)
|
||||
confirmed_materials = {}
|
||||
for item in previous_confirmed["items"]:
|
||||
material_hash = self._generate_material_hash(
|
||||
item["specification"],
|
||||
item["size"],
|
||||
item["material"]
|
||||
)
|
||||
confirmed_materials[material_hash] = item
|
||||
|
||||
# 신규 자재 분석
|
||||
unchanged_materials = [] # 변경 없음 (분류 불필요)
|
||||
changed_materials = [] # 변경됨 (재분류 필요)
|
||||
new_materials_list = [] # 신규 추가 (분류 필요)
|
||||
|
||||
for new_material in new_materials:
|
||||
# 자재 해시 생성 (description 기반)
|
||||
description = new_material.get("description", "")
|
||||
size = self._extract_size_from_description(description)
|
||||
material = self._extract_material_from_description(description)
|
||||
|
||||
material_hash = self._generate_material_hash(description, size, material)
|
||||
|
||||
if material_hash in confirmed_materials:
|
||||
confirmed_item = confirmed_materials[material_hash]
|
||||
|
||||
# 수량 비교
|
||||
new_qty = float(new_material.get("quantity", 0))
|
||||
confirmed_qty = float(confirmed_item["bom_quantity"])
|
||||
|
||||
if abs(new_qty - confirmed_qty) > 0.001: # 수량 변경
|
||||
changed_materials.append({
|
||||
**new_material,
|
||||
"change_type": "QUANTITY_CHANGED",
|
||||
"previous_quantity": confirmed_qty,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 수량 동일 - 기존 분류 결과 재사용
|
||||
unchanged_materials.append({
|
||||
**new_material,
|
||||
"reuse_classification": True,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 신규 자재
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_MATERIAL"
|
||||
})
|
||||
|
||||
# 삭제된 자재 찾기 (이전에는 있었지만 현재는 없는 것)
|
||||
new_material_hashes = set()
|
||||
for material in new_materials:
|
||||
description = material.get("description", "")
|
||||
size = self._extract_size_from_description(description)
|
||||
material_grade = self._extract_material_from_description(description)
|
||||
hash_key = self._generate_material_hash(description, size, material_grade)
|
||||
new_material_hashes.add(hash_key)
|
||||
|
||||
removed_materials = []
|
||||
for hash_key, confirmed_item in confirmed_materials.items():
|
||||
if hash_key not in new_material_hashes:
|
||||
removed_materials.append({
|
||||
"change_type": "REMOVED",
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
|
||||
comparison_result = {
|
||||
"has_previous_confirmation": True,
|
||||
"previous_revision": previous_confirmed["revision"],
|
||||
"previous_confirmed_at": previous_confirmed["confirmed_at"],
|
||||
"unchanged_count": len(unchanged_materials),
|
||||
"changed_count": len(changed_materials),
|
||||
"new_count": len(new_materials_list),
|
||||
"removed_count": len(removed_materials),
|
||||
"total_materials": len(new_materials),
|
||||
"classification_needed": len(changed_materials) + len(new_materials_list),
|
||||
"unchanged_materials": unchanged_materials,
|
||||
"changed_materials": changed_materials,
|
||||
"new_materials": new_materials_list,
|
||||
"removed_materials": removed_materials
|
||||
}
|
||||
|
||||
logger.info(f"리비전 비교 완료: 변경없음 {len(unchanged_materials)}, "
|
||||
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
|
||||
f"삭제됨 {len(removed_materials)}")
|
||||
|
||||
return comparison_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"자재 비교 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
def _extract_revision_number(self, revision: str) -> int:
|
||||
"""리비전 문자열에서 숫자 추출 (Rev.1 → 1)"""
|
||||
try:
|
||||
if revision.startswith("Rev."):
|
||||
return int(revision.replace("Rev.", ""))
|
||||
return 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
|
||||
"""자재 고유성 판단을 위한 해시 생성"""
|
||||
# RULES.md의 코딩 컨벤션 준수
|
||||
hash_input = f"{description}|{size}|{material}".lower().strip()
|
||||
return hashlib.md5(hash_input.encode()).hexdigest()
|
||||
|
||||
def _extract_size_from_description(self, description: str) -> str:
|
||||
"""자재 설명에서 사이즈 정보 추출"""
|
||||
# 간단한 사이즈 패턴 추출 (실제로는 더 정교한 로직 필요)
|
||||
import re
|
||||
size_patterns = [
|
||||
r'(\d+(?:\.\d+)?)\s*(?:mm|MM|인치|inch|")',
|
||||
r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)',
|
||||
r'DN\s*(\d+)',
|
||||
r'(\d+)\s*A'
|
||||
]
|
||||
|
||||
for pattern in size_patterns:
|
||||
match = re.search(pattern, description, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_material_from_description(self, description: str) -> str:
|
||||
"""자재 설명에서 재질 정보 추출"""
|
||||
# 일반적인 재질 패턴
|
||||
materials = ["SS304", "SS316", "SS316L", "A105", "WCB", "CF8M", "CF8", "CS"]
|
||||
|
||||
description_upper = description.upper()
|
||||
for material in materials:
|
||||
if material in description_upper:
|
||||
return material
|
||||
|
||||
return ""
|
||||
|
||||
def get_revision_comparison(db: Session, job_no: str, current_revision: str,
|
||||
new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
리비전 비교 수행 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전
|
||||
new_materials: 신규 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과 또는 전체 분류 필요 정보
|
||||
"""
|
||||
comparator = RevisionComparator(db)
|
||||
|
||||
# 이전 확정 자료 조회
|
||||
previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision)
|
||||
|
||||
if previous_confirmed is None:
|
||||
# 이전 확정 자료가 없으면 전체 분류 필요
|
||||
return {
|
||||
"has_previous_confirmation": False,
|
||||
"classification_needed": len(new_materials),
|
||||
"all_materials_need_classification": True,
|
||||
"materials_to_classify": new_materials,
|
||||
"message": "이전 확정 자료가 없어 전체 자재를 분류합니다."
|
||||
}
|
||||
|
||||
# 이전 확정 자료가 있으면 비교 수행
|
||||
return comparator.compare_materials(previous_confirmed, new_materials)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user