feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산)
- UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인)
- 자재별 그룹핑 로직 개선:
  * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시
  * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등)
  * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시
  * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계)
  * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시
  * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑
- 전체 카테고리 버튼 제거 (표시 복잡도 감소)
- 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
Hyungi Ahn
2025-09-09 09:24:45 +09:00
parent 4f8e395f87
commit 83b90ef05c
101 changed files with 10841 additions and 4813 deletions

View File

@@ -1,166 +0,0 @@
from flask import Flask, request, jsonify
import psycopg2
from contextlib import contextmanager
app = Flask(__name__)
@contextmanager
def get_db_connection():
conn = psycopg2.connect(
host="localhost",
database="tkmp_db",
user="tkmp_user",
password="tkmp2024!",
port="5432"
)
try:
yield conn
finally:
conn.close()
@app.route('/')
def home():
return {"message": "API 작동 중"}
@app.route('/api/materials')
def get_materials():
job_number = request.args.get('job_number')
if not job_number:
return {"error": "job_number 필요"}, 400
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT id, job_number, item_number, description,
category, quantity, unit, created_at
FROM materials
WHERE job_number = %s
ORDER BY item_number
""", (job_number,))
rows = cur.fetchall()
materials = []
for r in rows:
item = {
'id': r[0],
'job_number': r[1],
'item_number': r[2],
'description': r[3],
'category': r[4],
'quantity': r[5],
'unit': r[6],
'created_at': str(r[7]) if r[7] else None
}
materials.append(item)
return {
'success': True,
'data': materials,
'count': len(materials)
}
except Exception as e:
return {"error": f"DB 오류: {str(e)}"}, 500
if __name__ == '__main__':
print("🚀 서버 시작: http://localhost:5000")
app.run(debug=True, port=5000)
# 수정된 get_materials API (올바른 컬럼명 사용)
@app.route('/api/materials-fixed', methods=['GET'])
def get_materials_fixed():
"""올바른 컬럼명을 사용한 자재 조회 API"""
try:
file_id = request.args.get('file_id')
if not file_id:
return jsonify({
'success': False,
'error': 'file_id parameter is required'
}), 400
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT
id, file_id, line_number, original_description,
classified_category, classified_subcategory,
quantity, unit, created_at
FROM materials
WHERE file_id = %s
ORDER BY line_number
""", (file_id,))
materials = []
for item in cur.fetchall():
material = {
'id': item[0],
'file_id': item[1],
'line_number': item[2],
'original_description': item[3],
'classified_category': item[4],
'classified_subcategory': item[5],
'quantity': float(item[6]) if item[6] else 0,
'unit': item[7],
'created_at': item[8].isoformat() if item[8] else None
}
materials.append(material)
return jsonify({
'success': True,
'data': materials,
'count': len(materials),
'file_id': file_id
})
except Exception as e:
print(f"Error in get_materials_fixed: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.get("/api/materials-test")
def get_materials_test(file_id: int):
"""테스트용 자재 조회 API"""
try:
with get_db_connection() as conn:
cur = conn.cursor()
cur.execute("""
SELECT
id, file_id, line_number, original_description,
classified_category, quantity, unit
FROM materials
WHERE file_id = %s
ORDER BY line_number
LIMIT 5
""", (file_id,))
rows = cur.fetchall()
materials = []
for r in rows:
materials.append({
'id': r[0],
'file_id': r[1],
'line_number': r[2],
'description': r[3],
'category': r[4],
'quantity': float(r[5]) if r[5] else 0,
'unit': r[6]
})
return {
'success': True,
'data': materials,
'count': len(materials)
}
except Exception as e:
return {'error': str(e)}

View File

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

View File

@@ -61,3 +61,19 @@ __all__ = [
'RolePermission',
'UserRepository'
]

View File

@@ -391,3 +391,19 @@ async def delete_user(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 삭제 중 오류가 발생했습니다"
)

View File

@@ -249,3 +249,19 @@ class JWTService:
# JWT 서비스 인스턴스
jwt_service = JWTService()

View File

@@ -303,3 +303,19 @@ async def get_current_user_optional(
except Exception as e:
logger.debug(f"Optional auth failed: {str(e)}")
return None

View File

@@ -352,3 +352,19 @@ class UserRepository:
self.db.rollback()
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
raise

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

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

View File

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

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

View File

@@ -1,41 +0,0 @@
from app.services.integrated_classifier import LEVEL1_TYPE_KEYWORDS
test = "NIPPLE, SMLS, SCH 80, ASTM A106 GR B PBE"
print(f"테스트: {test}")
desc_upper = test.upper()
desc_parts = [part.strip() for part in desc_upper.split(',')]
print(f"대문자 변환: {desc_upper}")
print(f"쉼표 분리: {desc_parts}")
# 단계별 디버깅
detected_types = []
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
type_found = False
for keyword in keywords:
# 전체 문자열에서 찾기
if keyword in desc_upper:
print(f"{material_type}: '{keyword}' 전체 문자열에서 발견")
detected_types.append((material_type, keyword))
type_found = True
break
# 각 부분에서도 정확히 매칭되는지 확인
for part in desc_parts:
if keyword == part or keyword in part:
print(f"{material_type}: '{keyword}' 부분 '{part}'에서 발견")
detected_types.append((material_type, keyword))
type_found = True
break
if type_found:
break
print(f"\n감지된 타입들: {detected_types}")
print(f"감지된 타입 개수: {len(detected_types)}")
if len(detected_types) == 1:
print(f"단일 타입 확정: {detected_types[0][0]}")
elif len(detected_types) > 1:
print(f"복수 타입 감지: {detected_types}")
else:
print("Level 1 키워드 없음 - 재질 기반 분류로 이동")

View File

@@ -1,30 +0,0 @@
"""
수정된 스풀 시스템 사용 예시
"""
# 시나리오: A-1 도면에서 파이프 3개 발견
examples = [
{
"dwg_name": "A-1",
"pipes": [
{"description": "PIPE 1", "user_input_spool": "A"}, # A-1-A
{"description": "PIPE 2", "user_input_spool": "A"}, # A-1-A (같은 스풀)
{"description": "PIPE 3", "user_input_spool": "B"} # A-1-B (다른 스풀)
],
"area_assignment": "#01" # 별도: A-1 도면은 #01 구역에 위치
}
]
# 결과:
spool_identifiers = [
"A-1-A", # 파이프 1, 2가 속함
"A-1-B" # 파이프 3이 속함
]
area_assignment = {
"#01": ["A-1"] # A-1 도면은 #01 구역에 물리적으로 위치
}
print("✅ 수정된 스풀 구조가 적용되었습니다!")
print(f"스풀 식별자: {spool_identifiers}")
print(f"에리어 할당: {area_assignment}")

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env python3
"""
더미 프로젝트 데이터 생성 스크립트
"""
import sys
import os
from datetime import datetime, date
from sqlalchemy import create_engine, text
# 프로젝트 루트를 Python path에 추가
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def create_dummy_jobs():
"""더미 Job 데이터 생성"""
# 간단한 SQLite 연결 (실제 DB 설정에 맞게 수정)
try:
# 실제 프로젝트의 database.py 설정 사용
from app.database import engine
print("✅ 데이터베이스 연결 성공")
except ImportError:
# 직접 연결 (개발용)
DATABASE_URL = "sqlite:///./test.db" # 실제 DB URL로 변경
engine = create_engine(DATABASE_URL)
print("⚠️ 직접 데이터베이스 연결")
# 더미 데이터 정의
dummy_jobs = [
{
'job_no': 'J24-001',
'job_name': '울산 SK에너지 정유시설 증설 배관공사',
'client_name': '삼성엔지니어링',
'end_user': 'SK에너지',
'epc_company': '삼성엔지니어링',
'project_site': '울산광역시 온산공단 SK에너지 정유공장',
'contract_date': '2024-03-15',
'delivery_date': '2024-08-30',
'delivery_terms': 'FOB 울산항',
'status': '진행중',
'description': '정유시설 증설을 위한 배관 자재 공급 프로젝트. 고온고압 배관 및 특수 밸브 포함.',
'created_by': 'admin'
},
{
'job_no': 'J24-002',
'job_name': '포스코 광양 제철소 배관 정비공사',
'client_name': '포스코',
'end_user': '포스코',
'epc_company': None,
'project_site': '전남 광양시 포스코 광양제철소',
'contract_date': '2024-04-02',
'delivery_date': '2024-07-15',
'delivery_terms': 'DDP 광양제철소 현장',
'status': '진행중',
'description': '제철소 정기 정비를 위한 배관 부품 교체. 내열성 특수강 배관 포함.',
'created_by': 'admin'
}
]
try:
with engine.connect() as conn:
# 기존 더미 데이터 삭제 (개발용)
print("🧹 기존 더미 데이터 정리...")
conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')"))
# 새 더미 데이터 삽입
print("📝 더미 데이터 삽입 중...")
for job in dummy_jobs:
insert_query = text("""
INSERT INTO jobs (
job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
status, description, created_by, is_active
) VALUES (
:job_no, :job_name, :client_name, :end_user, :epc_company,
:project_site, :contract_date, :delivery_date, :delivery_terms,
:status, :description, :created_by, :is_active
)
""")
conn.execute(insert_query, {**job, 'is_active': True})
print(f"{job['job_no']}: {job['job_name']}")
# 커밋
conn.commit()
# 결과 확인
result = conn.execute(text("""
SELECT job_no, job_name, client_name, status
FROM jobs
WHERE job_no IN ('J24-001', 'J24-002')
"""))
jobs = result.fetchall()
print(f"\n🎉 총 {len(jobs)}개 더미 Job 생성 완료!")
print("\n📋 생성된 더미 데이터:")
for job in jobs:
print(f"{job[0]}: {job[1]} ({job[2]}) - {job[3]}")
return True
except Exception as e:
print(f"❌ 더미 데이터 생성 실패: {e}")
return False
if __name__ == "__main__":
create_dummy_jobs()

View File

@@ -218,3 +218,19 @@ BEGIN
RAISE NOTICE '👤 기본 계정: admin/admin123, system/admin123';
RAISE NOTICE '🔐 권한 시스템: 5단계 역할 + 모듈별 세분화된 권한';
END $$;

View File

@@ -0,0 +1,142 @@
-- 사용자 추적 및 담당자 기록 필드 추가
-- 생성일: 2025.01
-- 목적: RULES 가이드라인에 따른 사용자 추적 시스템 구축
-- ================================
-- 1. 기존 테이블에 담당자 필드 추가
-- ================================
-- files 테이블 수정 (uploaded_by는 이미 존재)
ALTER TABLE files
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
-- jobs 테이블 수정
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS created_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS assigned_to VARCHAR(100);
-- materials 테이블 수정
ALTER TABLE materials
ADD COLUMN IF NOT EXISTS classified_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100);
-- ================================
-- 2. 사용자 활동 로그 테이블 생성
-- ================================
CREATE TABLE IF NOT EXISTS user_activity_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER, -- users 테이블 참조 (외래키 제약 없음 - 유연성)
username VARCHAR(100) NOT NULL, -- 사용자명 (필수)
-- 활동 정보
activity_type VARCHAR(50) NOT NULL, -- 'FILE_UPLOAD', 'PROJECT_CREATE', 'PURCHASE_CONFIRM' 등
activity_description TEXT, -- 상세 활동 내용
-- 대상 정보
target_id INTEGER, -- 대상 ID (파일, 프로젝트 등)
target_type VARCHAR(50), -- 'FILE', 'PROJECT', 'MATERIAL', 'PURCHASE' 등
-- 세션 정보
ip_address VARCHAR(45), -- IP 주소
user_agent TEXT, -- 브라우저 정보
-- 추가 메타데이터 (JSON)
metadata JSONB, -- 추가 정보 (파일 크기, 처리 시간 등)
-- 시간 정보
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ================================
-- 3. 구매 관련 테이블 수정
-- ================================
-- purchase_items 테이블 수정 (이미 created_by 존재하는지 확인 후 추가)
ALTER TABLE purchase_items
ADD COLUMN IF NOT EXISTS updated_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
-- material_purchase_tracking 테이블 수정 (이미 confirmed_by 존재)
ALTER TABLE material_purchase_tracking
ADD COLUMN IF NOT EXISTS ordered_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS ordered_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(100),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP;
-- ================================
-- 4. 인덱스 생성 (성능 최적화)
-- ================================
-- 사용자 활동 로그 인덱스
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_username ON user_activity_logs(username);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity_logs(activity_type);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id);
-- 담당자 필드 인덱스
CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);
CREATE INDEX IF NOT EXISTS idx_files_updated_by ON files(updated_by);
CREATE INDEX IF NOT EXISTS idx_jobs_created_by ON jobs(created_by);
CREATE INDEX IF NOT EXISTS idx_jobs_assigned_to ON jobs(assigned_to);
CREATE INDEX IF NOT EXISTS idx_materials_classified_by ON materials(classified_by);
-- ================================
-- 5. 트리거 생성 (자동 updated_at 갱신)
-- ================================
-- files 테이블 updated_at 자동 갱신
CREATE OR REPLACE FUNCTION update_files_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_files_updated_at ON files;
CREATE TRIGGER trigger_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW
EXECUTE FUNCTION update_files_updated_at();
-- jobs 테이블 updated_at 자동 갱신
CREATE OR REPLACE FUNCTION update_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_jobs_updated_at ON jobs;
CREATE TRIGGER trigger_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION update_jobs_updated_at();
-- ================================
-- 6. 기본 데이터 설정
-- ================================
-- 기존 데이터에 기본 담당자 설정 (시스템 마이그레이션용)
UPDATE files SET uploaded_by = 'system' WHERE uploaded_by IS NULL;
UPDATE jobs SET created_by = 'system' WHERE created_by IS NULL;
-- ================================
-- 7. 권한 및 보안 설정
-- ================================
-- 활동 로그 테이블은 INSERT만 허용 (수정/삭제 방지)
-- 실제 운영에서는 별도 권한 관리 필요
COMMENT ON TABLE user_activity_logs IS '사용자 활동 로그 - 모든 업무 활동 추적';
COMMENT ON COLUMN user_activity_logs.activity_type IS '활동 유형: FILE_UPLOAD, PROJECT_CREATE, PURCHASE_CONFIRM, MATERIAL_CLASSIFY 등';
COMMENT ON COLUMN user_activity_logs.metadata IS '추가 정보 JSON: 파일크기, 처리시간, 변경내용 등';
-- 완료 메시지
SELECT 'User tracking system tables created successfully!' as result;

View File

@@ -0,0 +1,49 @@
-- 파이프 끝단 가공 정보 테이블 생성
-- 각 파이프별로 끝단 가공 정보를 별도 저장
CREATE TABLE IF NOT EXISTS pipe_end_preparations (
id SERIAL PRIMARY KEY,
material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
-- 끝단 가공 정보
end_preparation_type VARCHAR(50) DEFAULT 'PBE', -- PBE(양쪽무개선), BBE(양쪽개선), POE(한쪽개선), PE(무개선)
end_preparation_code VARCHAR(20), -- 원본 코드 (BBE, POE, PBE 등)
machining_required BOOLEAN DEFAULT FALSE, -- 가공 필요 여부
cutting_note TEXT, -- 가공 메모
-- 원본 정보 보존
original_description TEXT NOT NULL, -- 끝단 가공 포함된 원본 설명
clean_description TEXT NOT NULL, -- 끝단 가공 제외한 구매용 설명
-- 메타데이터
confidence FLOAT DEFAULT 0.0, -- 분류 신뢰도
matched_pattern VARCHAR(100), -- 매칭된 패턴
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_material_id ON pipe_end_preparations(material_id);
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_file_id ON pipe_end_preparations(file_id);
CREATE INDEX IF NOT EXISTS idx_pipe_end_preparations_type ON pipe_end_preparations(end_preparation_type);
-- 기본 끝단 가공 타입 정의
COMMENT ON COLUMN pipe_end_preparations.end_preparation_type IS 'PBE: 양쪽무개선(기본값), BBE: 양쪽개선, POE: 한쪽개선, PE: 무개선';
COMMENT ON COLUMN pipe_end_preparations.machining_required IS '가공이 필요한지 여부 (개선 작업 등)';
COMMENT ON COLUMN pipe_end_preparations.clean_description IS '구매 시 사용할 끝단 가공 정보가 제거된 설명';
-- 트리거: updated_at 자동 업데이트
CREATE OR REPLACE FUNCTION update_pipe_end_preparations_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_pipe_end_preparations_updated_at
BEFORE UPDATE ON pipe_end_preparations
FOR EACH ROW
EXECUTE FUNCTION update_pipe_end_preparations_updated_at();

View File

@@ -1,81 +0,0 @@
#!/usr/bin/env python3
import sys
import os
from datetime import datetime, date
# 프로젝트 루트를 Python path에 추가
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from app.database import engine
from sqlalchemy import text
print("✅ 데이터베이스 연결 성공")
except ImportError as e:
print(f"❌ 임포트 실패: {e}")
sys.exit(1)
def insert_dummy_data():
dummy_jobs = [
{
'job_no': 'J24-001',
'job_name': '울산 SK에너지 정유시설 증설 배관공사',
'client_name': '삼성엔지니어링',
'end_user': 'SK에너지',
'epc_company': '삼성엔지니어링',
'project_site': '울산광역시 온산공단',
'contract_date': '2024-03-15',
'delivery_date': '2024-08-30',
'delivery_terms': 'FOB 울산항',
'description': '정유시설 증설을 위한 배관 자재 공급',
'created_by': 'admin'
},
{
'job_no': 'J24-002',
'job_name': '포스코 광양 제철소 배관 정비공사',
'client_name': '포스코',
'end_user': '포스코',
'epc_company': None,
'project_site': '전남 광양시 포스코 제철소',
'contract_date': '2024-04-02',
'delivery_date': '2024-07-15',
'delivery_terms': 'DDP 광양제철소',
'description': '제철소 정기 정비용 배관 부품',
'created_by': 'admin'
}
]
try:
with engine.connect() as conn:
# 기존 더미 데이터 삭제
conn.execute(text("DELETE FROM jobs WHERE job_no IN ('J24-001', 'J24-002')"))
# 새 데이터 삽입
for job in dummy_jobs:
query = text("""
INSERT INTO jobs (
job_no, job_name, client_name, end_user, epc_company,
project_site, contract_date, delivery_date, delivery_terms,
description, created_by, is_active
) VALUES (
:job_no, :job_name, :client_name, :end_user, :epc_company,
:project_site, :contract_date, :delivery_date, :delivery_terms,
:description, :created_by, :is_active
)
""")
conn.execute(query, {**job, 'is_active': True})
print(f"{job['job_no']}: {job['job_name']}")
conn.commit()
print(f"\n🎉 {len(dummy_jobs)}개 더미 Job 생성 완료!")
# 확인
result = conn.execute(text("SELECT job_no, job_name, client_name FROM jobs"))
for row in result:
print(f"{row[0]}: {row[1]} ({row[2]})")
except Exception as e:
print(f"❌ 오류: {e}")
if __name__ == "__main__":
insert_dummy_data()

View File

@@ -1,5 +0,0 @@
# main.py에 추가할 import
from .api import spools
# app.include_router 추가
app.include_router(spools.router, prefix="/api/spools", tags=["스풀 관리"])

View File

@@ -1,120 +0,0 @@
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...),
revision: str = Form("Rev.0"),
db: Session = Depends(get_db)
):
# 1. Job 검증 (새로 추가!)
job_validation = await validate_job_exists(job_no, db)
if not job_validation["valid"]:
raise HTTPException(
status_code=400,
detail=f"Job 오류: {job_validation['error']}"
)
job_info = job_validation["job"]
print(f"✅ Job 검증 완료: {job_info['job_no']} - {job_info['job_name']}")
# 2. 파일 검증
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를 초과할 수 없습니다")
# 3. 파일 저장
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)}")
# 4. 파일 파싱 및 자재 추출
try:
materials_data = parse_file_data(str(file_path))
parsed_count = len(materials_data)
# 파일 정보 저장 (job_no 사용!)
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, # job_no 사용!
"revision": revision,
"description": f"BOM 파일 - {parsed_count}개 자재 ({job_info['job_name']})",
"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"Job '{job_info['job_name']}'에 BOM 파일 업로드 완료!",
"job": {
"job_no": job_info["job_no"],
"job_name": job_info["job_name"],
"status": job_info["status"]
},
"file": {
"id": file_id,
"original_filename": file.filename,
"parsed_count": parsed_count,
"saved_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)}")

View File

@@ -1,13 +0,0 @@
# upload 함수에 추가할 Job 검증 로직
# Form 파라미터 받은 직후에 추가:
# Job 검증
job_validation = await validate_job_exists(job_no, db)
if not job_validation["valid"]:
raise HTTPException(
status_code=400,
detail=f"Job 오류: {job_validation['error']}"
)
job_info = job_validation["job"]
print(f"✅ Job 검증 완료: {job_info['job_no']} - {job_info['job_name']}")

View File

@@ -1,5 +0,0 @@
Description,Quantity,Unit,Size
"PIPE ASTM A106 GR.B",10,EA,4"
"ELBOW 90° ASTM A234",5,EA,4"
"VALVE GATE ASTM A216",2,EA,4"
"FLANGE WELD NECK",8,EA,4"
Can't render this file because it contains an unexpected character in line 2 and column 30.

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env python3
"""
main_nom, red_nom 기능 테스트 스크립트
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from app.services.fitting_classifier import classify_fitting
from app.services.flange_classifier import classify_flange
def test_main_red_nom():
"""main_nom과 red_nom 분류 테스트"""
print("🔧 main_nom/red_nom 분류 테스트 시작!")
print("=" * 60)
test_cases = [
{
"name": "일반 TEE (동일 사이즈)",
"description": "TEE, SCH 40, ASTM A234 GR WPB",
"main_nom": "4\"",
"red_nom": None,
"expected": "EQUAL TEE"
},
{
"name": "리듀싱 TEE (다른 사이즈)",
"description": "TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",
"main_nom": "4\"",
"red_nom": "2\"",
"expected": "REDUCING TEE"
},
{
"name": "동심 리듀서",
"description": "RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",
"main_nom": "6\"",
"red_nom": "4\"",
"expected": "CONCENTRIC REDUCER"
},
{
"name": "리듀싱 플랜지",
"description": "FLG REDUCING, 300LB, ASTM A105",
"main_nom": "6\"",
"red_nom": "4\"",
"expected": "REDUCING FLANGE"
}
]
for i, test in enumerate(test_cases, 1):
print(f"\n{i}. {test['name']}")
print(f" 설명: {test['description']}")
print(f" MAIN_NOM: {test['main_nom']}")
print(f" RED_NOM: {test['red_nom']}")
# 피팅 분류 테스트
fitting_result = classify_fitting(
"",
test['description'],
test['main_nom'],
test['red_nom']
)
print(f" 🔧 FITTING 분류 결과:")
print(f" 카테고리: {fitting_result.get('category')}")
print(f" 타입: {fitting_result.get('fitting_type', {}).get('type')}")
print(f" 서브타입: {fitting_result.get('fitting_type', {}).get('subtype')}")
print(f" 신뢰도: {fitting_result.get('overall_confidence', 0):.2f}")
# 사이즈 정보 확인
size_info = fitting_result.get('size_info', {})
print(f" 메인 사이즈: {size_info.get('main_size')}")
print(f" 축소 사이즈: {size_info.get('reduced_size')}")
print(f" 사이즈 설명: {size_info.get('size_description')}")
# RED_NOM이 있는 경우 REDUCING 분류 확인
if test['red_nom']:
fitting_type = fitting_result.get('fitting_type', {})
if 'REDUCING' in fitting_type.get('subtype', '').upper():
print(f" ✅ REDUCING 타입 정상 인식!")
else:
print(f" ❌ REDUCING 타입 인식 실패")
print("-" * 50)
print("\n🎯 테스트 완료!")
if __name__ == "__main__":
test_main_red_nom()

View File

@@ -1,6 +0,0 @@
Description,Quantity,Unit,Size
"PIPE ASTM A106 GR.B",10,EA,4"
"GATE VALVE ASTM A216",2,EA,4"
"FLANGE WELD NECK RF",8,EA,4"
"90 DEG ELBOW",5,EA,4"
"GASKET SPIRAL WOUND",4,EA,4"
Can't render this file because it contains an unexpected character in line 2 and column 30.

View File

@@ -1,6 +0,0 @@
description,qty,main_nom,red_nom,length
"TEE EQUAL, SCH 40, ASTM A234 GR WPB",2,4",,"
"TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",1,4",2","
"RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",1,6",4","
"90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS",4,3",,"
"PIPE SMLS, SCH 40, ASTM A106 GR B",1,2",,6000
Can't render this file because it contains an unexpected character in line 2 and column 42.