리비전 페이지 제거 및 트랜잭션 오류 임시 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- frontend/src/pages/revision/ 폴더 완전 삭제 - EnhancedRevisionPage.css 제거 - support_details 저장 시 트랜잭션 오류로 인해 임시로 상세 정보 저장 비활성화 - 리비전 기능 재설계 예정
This commit is contained in:
@@ -127,6 +127,14 @@ try:
|
||||
except ImportError as e:
|
||||
logger.warning(f"materials 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 간단한 리비전 관리 라우터
|
||||
try:
|
||||
from .routers import simple_revision
|
||||
app.include_router(simple_revision.router)
|
||||
logger.info("simple_revision 라우터 등록 완료")
|
||||
except ImportError as e:
|
||||
logger.warning(f"simple_revision 라우터를 찾을 수 없습니다: {e}")
|
||||
|
||||
# 파일 관리 API 라우터 등록 (비활성화 - files 라우터와 충돌 방지)
|
||||
# try:
|
||||
# from .api import file_management
|
||||
|
||||
@@ -703,7 +703,8 @@ class PurchaseRequests(Base):
|
||||
category = Column(String(50))
|
||||
material_count = Column(Integer)
|
||||
excel_file_path = Column(String(500))
|
||||
requested_by = Column(Integer, ForeignKey("users.user_id"))
|
||||
requested_by = Column(Integer) # ForeignKey 제거
|
||||
requested_by_username = Column(String(100)) # 사용자명 직접 저장
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -711,7 +712,7 @@ class PurchaseRequests(Base):
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File")
|
||||
requested_by_user = relationship("User", foreign_keys=[requested_by])
|
||||
# requested_by_user relationship 제거
|
||||
|
||||
class Jobs(Base):
|
||||
__tablename__ = "jobs"
|
||||
@@ -765,7 +766,7 @@ class ExcelExports(Base):
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File")
|
||||
exported_by_user = relationship("User", foreign_keys=[exported_by])
|
||||
# exported_by_user relationship 제거
|
||||
|
||||
class UserActivityLogs(Base):
|
||||
__tablename__ = "user_activity_logs"
|
||||
@@ -777,7 +778,7 @@ class UserActivityLogs(Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
user = relationship("User")
|
||||
# user relationship 제거
|
||||
|
||||
class ExcelExportHistory(Base):
|
||||
__tablename__ = "excel_export_history"
|
||||
@@ -785,12 +786,13 @@ class ExcelExportHistory(Base):
|
||||
export_id = Column(String(50), primary_key=True, index=True)
|
||||
file_id = Column(Integer, ForeignKey("files.id"))
|
||||
job_no = Column(String(50))
|
||||
exported_by = Column(Integer, ForeignKey("users.user_id"))
|
||||
exported_by = Column(Integer) # ForeignKey 제거
|
||||
exported_by_username = Column(String(100)) # 사용자명 직접 저장
|
||||
export_date = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File")
|
||||
exported_by_user = relationship("User", foreign_keys=[exported_by])
|
||||
# exported_by_user relationship 제거
|
||||
|
||||
class ExportedMaterials(Base):
|
||||
__tablename__ = "exported_materials"
|
||||
@@ -817,4 +819,67 @@ class PurchaseStatusHistory(Base):
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
changed_by_user = relationship("User", foreign_keys=[changed_by])
|
||||
# changed_by_user relationship 제거
|
||||
|
||||
|
||||
# ========== 간단한 리비전 관리 시스템 ==========
|
||||
|
||||
class SimpleRevisionComparison(Base):
|
||||
"""간단한 리비전 비교 결과 저장"""
|
||||
__tablename__ = "simple_revision_comparisons"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
job_no = Column(String(50), nullable=False, index=True)
|
||||
current_file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
previous_file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
category = Column(String(50), nullable=False) # 비교 대상 카테고리
|
||||
|
||||
# 비교 결과 통계
|
||||
added_count = Column(Integer, default=0)
|
||||
removed_count = Column(Integer, default=0)
|
||||
changed_count = Column(Integer, default=0)
|
||||
unchanged_count = Column(Integer, default=0)
|
||||
|
||||
# 구매 상태별 통계
|
||||
purchased_affected = Column(Integer, default=0)
|
||||
unpurchased_affected = Column(Integer, default=0)
|
||||
inventory_count = Column(Integer, default=0)
|
||||
|
||||
# 메타데이터
|
||||
comparison_data = Column(JSON) # 상세 비교 결과
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
created_by_username = Column(String(100))
|
||||
|
||||
# 관계 설정 (간단하게)
|
||||
current_file = relationship("File", foreign_keys=[current_file_id])
|
||||
previous_file = relationship("File", foreign_keys=[previous_file_id])
|
||||
|
||||
|
||||
class SimpleRevisionMaterial(Base):
|
||||
"""간단한 리비전 자재 변경 로그"""
|
||||
__tablename__ = "simple_revision_materials"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
comparison_id = Column(Integer, ForeignKey("simple_revision_comparisons.id"), nullable=False)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"))
|
||||
change_type = Column(String(20), nullable=False) # 'added', 'removed', 'changed', 'quantity_changed'
|
||||
revision_action = Column(String(30)) # 'maintain', 'additional_purchase', 'inventory', 'delete', 'quantity_update'
|
||||
|
||||
# 수량 변경 정보
|
||||
quantity_before = Column(Numeric(10, 3))
|
||||
quantity_after = Column(Numeric(10, 3))
|
||||
quantity_difference = Column(Numeric(10, 3))
|
||||
purchase_status = Column(String(20)) # 'purchased', 'not_purchased'
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정 (간단하게)
|
||||
comparison = relationship("SimpleRevisionComparison")
|
||||
material = relationship("Material")
|
||||
|
||||
|
||||
# ========== 간단한 리비전 시스템 완료 ==========
|
||||
# 나머지 복잡한 테이블들은 제거하고 필요시 추가
|
||||
|
||||
# 기존 복잡한 모델들 제거됨 (PipeLengthCalculation, MaterialPurchaseHistory 등)
|
||||
# 필요시 간단한 구조로 다시 추가 예정
|
||||
|
||||
@@ -367,12 +367,14 @@ async def upload_file(
|
||||
)
|
||||
|
||||
# 4. 자재 데이터 처리
|
||||
is_revision_upload = parent_file_id is not None or revision != 'Rev.0'
|
||||
processing_result = upload_service.process_materials_data(
|
||||
file_path=file_path,
|
||||
file_id=file_record.id,
|
||||
job_no=job_no,
|
||||
revision=revision,
|
||||
parent_file_id=parent_file_id
|
||||
parent_file_id=parent_file_id,
|
||||
is_revision=is_revision_upload
|
||||
)
|
||||
|
||||
# 5. 파일 레코드 업데이트 (파싱된 자재 수)
|
||||
|
||||
278
backend/app/routers/simple_revision.py
Normal file
278
backend/app/routers/simple_revision.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
간단한 리비전 관리 API
|
||||
복잡한 dependency 없이 핵심 기능만 제공
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File as FastAPIFile
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, List, Any, Optional
|
||||
import json
|
||||
|
||||
from ..database import get_db
|
||||
from ..services.simple_revision_service import SimpleRevisionService
|
||||
from ..services.file_upload_service import FileUploadService
|
||||
from ..models import File, Material
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/simple-revision", tags=["Simple Revision"])
|
||||
|
||||
@router.post("/compare")
|
||||
async def compare_revisions(
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category: str,
|
||||
username: str = "system",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
두 리비전 간의 자재 비교
|
||||
|
||||
Args:
|
||||
current_file_id: 현재 파일 ID
|
||||
previous_file_id: 이전 파일 ID
|
||||
category: 비교할 카테고리 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL, UNCLASSIFIED)
|
||||
username: 비교 수행자
|
||||
"""
|
||||
try:
|
||||
service = SimpleRevisionService(db)
|
||||
result = service.compare_revisions(
|
||||
current_file_id, previous_file_id, category, username
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{category} 카테고리 리비전 비교 완료",
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 비교 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}")
|
||||
|
||||
@router.get("/comparison/{comparison_id}")
|
||||
async def get_comparison_result(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""저장된 비교 결과 조회"""
|
||||
try:
|
||||
service = SimpleRevisionService(db)
|
||||
result = service.get_comparison_result(comparison_id)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="비교 결과를 찾을 수 없습니다")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"비교 결과 조회 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"비교 결과 조회 실패: {str(e)}")
|
||||
|
||||
@router.post("/upload-revision")
|
||||
async def upload_revision_file(
|
||||
file: UploadFile = FastAPIFile(...),
|
||||
job_no: str = "default",
|
||||
previous_file_id: int = None,
|
||||
username: str = "system",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전 파일 업로드 및 자동 비교
|
||||
|
||||
Args:
|
||||
file: 업로드할 BOM 파일
|
||||
job_no: 작업 번호
|
||||
previous_file_id: 비교할 이전 파일 ID
|
||||
username: 업로드 사용자
|
||||
"""
|
||||
try:
|
||||
# 1. 파일 업로드
|
||||
upload_service = FileUploadService(db)
|
||||
upload_result = await upload_service.process_upload(
|
||||
file=file,
|
||||
job_no=job_no,
|
||||
username=username,
|
||||
is_revision=True
|
||||
)
|
||||
|
||||
current_file_id = upload_result['file_id']
|
||||
|
||||
# 2. 이전 파일이 지정되지 않은 경우 최신 파일 찾기
|
||||
if not previous_file_id:
|
||||
previous_file = db.query(File).filter(
|
||||
File.job_no == job_no,
|
||||
File.id != current_file_id,
|
||||
File.is_active == True
|
||||
).order_by(File.upload_date.desc()).first()
|
||||
|
||||
if previous_file:
|
||||
previous_file_id = previous_file.id
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "첫 번째 파일이므로 비교할 이전 파일이 없습니다",
|
||||
"data": {
|
||||
"file_id": current_file_id,
|
||||
"comparison_results": []
|
||||
}
|
||||
}
|
||||
|
||||
# 3. 각 카테고리별로 자동 비교 수행
|
||||
categories = [
|
||||
'PIPE', 'FITTING', 'FLANGE', 'VALVE',
|
||||
'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED'
|
||||
]
|
||||
|
||||
revision_service = SimpleRevisionService(db)
|
||||
comparison_results = []
|
||||
|
||||
for category in categories:
|
||||
try:
|
||||
# 해당 카테고리에 자재가 있는지 확인
|
||||
current_materials = db.query(Material).filter(
|
||||
Material.file_id == current_file_id,
|
||||
Material.classified_category == category
|
||||
).count()
|
||||
|
||||
previous_materials = db.query(Material).filter(
|
||||
Material.file_id == previous_file_id,
|
||||
Material.classified_category == category
|
||||
).count()
|
||||
|
||||
if current_materials > 0 or previous_materials > 0:
|
||||
# 비교 수행
|
||||
comparison_result = revision_service.compare_revisions(
|
||||
current_file_id, previous_file_id, category, username
|
||||
)
|
||||
comparison_results.append(comparison_result)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"{category} 카테고리 비교 실패: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"리비전 파일 업로드 및 비교 완료 ({len(comparison_results)}개 카테고리)",
|
||||
"data": {
|
||||
"file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id,
|
||||
"comparison_results": comparison_results
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 업로드 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"리비전 업로드 실패: {str(e)}")
|
||||
|
||||
@router.get("/categories/{file_id}")
|
||||
async def get_available_categories(
|
||||
file_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""파일의 사용 가능한 카테고리 목록 조회"""
|
||||
try:
|
||||
# 파일에 포함된 카테고리별 자재 수 조회
|
||||
categories_query = db.query(
|
||||
Material.classified_category,
|
||||
db.func.count(Material.id).label('count')
|
||||
).filter(
|
||||
Material.file_id == file_id
|
||||
).group_by(Material.classified_category).all()
|
||||
|
||||
categories = []
|
||||
for category, count in categories_query:
|
||||
if category and count > 0:
|
||||
categories.append({
|
||||
'category': category,
|
||||
'count': count
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"file_id": file_id,
|
||||
"categories": categories
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"카테고리 조회 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"카테고리 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/changed-materials/{current_file_id}/{previous_file_id}")
|
||||
async def get_changed_materials_only(
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
categories: str = None, # 쉼표로 구분된 카테고리 목록
|
||||
username: str = "system",
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""변경된 자재만 필터링하여 반환"""
|
||||
try:
|
||||
service = SimpleRevisionService(db)
|
||||
|
||||
# 카테고리 파싱
|
||||
category_list = None
|
||||
if categories:
|
||||
category_list = [cat.strip().upper() for cat in categories.split(',')]
|
||||
|
||||
result = service.get_changed_materials_only(
|
||||
current_file_id, previous_file_id, category_list, username
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"변경된 자재 조회 완료 ({result['total_changed_materials']}개 변경)",
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"변경된 자재 조회 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"변경된 자재 조회 실패: {str(e)}")
|
||||
|
||||
@router.get("/history/{job_no}")
|
||||
async def get_revision_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""작업 번호별 리비전 히스토리 조회"""
|
||||
try:
|
||||
from ..models import SimpleRevisionComparison
|
||||
|
||||
comparisons = db.query(SimpleRevisionComparison).filter(
|
||||
SimpleRevisionComparison.job_no == job_no
|
||||
).order_by(SimpleRevisionComparison.created_at.desc()).all()
|
||||
|
||||
history = []
|
||||
for comp in comparisons:
|
||||
history.append({
|
||||
'comparison_id': comp.id,
|
||||
'category': comp.category,
|
||||
'created_at': comp.created_at.isoformat(),
|
||||
'created_by': comp.created_by_username,
|
||||
'summary': {
|
||||
'added': comp.added_count,
|
||||
'removed': comp.removed_count,
|
||||
'changed': comp.changed_count,
|
||||
'unchanged': comp.unchanged_count
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"job_no": job_no,
|
||||
"history": history
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 히스토리 조회 실패: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}")
|
||||
@@ -6,7 +6,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from ..models import Material, File, User, Project
|
||||
import json
|
||||
from ..models import Material, File, Project
|
||||
from ..auth.models import User
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.error_handlers import ErrorResponse
|
||||
|
||||
@@ -292,7 +294,7 @@ class DatabaseService:
|
||||
"area_code": material.get("area_code"),
|
||||
"line_no": material.get("line_no"),
|
||||
"classification_confidence": material.get("classification_confidence"),
|
||||
"classification_details": material.get("classification_details"),
|
||||
"classification_details": json.dumps(material.get("classification_details", {})) if material.get("classification_details") else None,
|
||||
"revision_status": material.get("revision_status", "new"),
|
||||
"material_hash": material.get("material_hash"),
|
||||
"normalized_description": material.get("normalized_description"),
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from fastapi import UploadFile, HTTPException
|
||||
@@ -14,9 +16,8 @@ from sqlalchemy import text
|
||||
from ..models import File, Material
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.file_processor import parse_file_data
|
||||
from ..utils.file_validator import validate_file_extension, generate_unique_filename
|
||||
from ..utils.file_validator import file_validator
|
||||
from .database_service import DatabaseService
|
||||
from .material_classification_service import MaterialClassificationService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -25,6 +26,16 @@ ALLOWED_EXTENSIONS = {'.xls', '.xlsx', '.csv'}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
class FileUploadService:
|
||||
"""파일 업로드 서비스"""
|
||||
|
||||
@@ -36,7 +47,7 @@ class FileUploadService:
|
||||
def validate_upload_request(self, file: UploadFile, job_no: str) -> None:
|
||||
"""업로드 요청 검증"""
|
||||
|
||||
if not validate_file_extension(file.filename):
|
||||
if not file_validator.validate_file_extension(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
|
||||
@@ -129,7 +140,8 @@ class FileUploadService:
|
||||
file_id: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
parent_file_id: Optional[int] = None
|
||||
parent_file_id: Optional[int] = None,
|
||||
is_revision: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""자재 데이터 처리"""
|
||||
|
||||
@@ -189,8 +201,9 @@ class FileUploadService:
|
||||
# 자재 데이터 DB 저장
|
||||
inserted_count = self.db_service.bulk_insert_materials(processed_materials, file_id)
|
||||
|
||||
# 상세 정보 저장 (분류별)
|
||||
self._save_material_details(processed_materials, file_id)
|
||||
# 상세 정보 저장 (임시로 모든 업로드에서 건너뛰기)
|
||||
# TODO: 상세 정보 저장 로직 수정 필요
|
||||
logger.info(f"Skipped material details saving (temporarily disabled)")
|
||||
|
||||
logger.info(f"Processed {inserted_count} materials for file {file_id}")
|
||||
|
||||
@@ -589,19 +602,102 @@ class FileUploadService:
|
||||
|
||||
|
||||
class MaterialClassificationService:
|
||||
"""자재 분류 서비스 (임시 구현)"""
|
||||
"""자재 분류 서비스"""
|
||||
|
||||
def classify_material(self, material_data: Dict, line_number: int, row_number: int) -> Dict:
|
||||
"""자재 분류 (기존 로직 유지)"""
|
||||
"""자재 분류 (실제 분류 로직 적용)"""
|
||||
|
||||
# 기존 분류 로직을 여기에 통합
|
||||
# 현재는 기본값만 설정
|
||||
material_data.update({
|
||||
'line_number': line_number,
|
||||
'row_number': row_number,
|
||||
'classified_category': material_data.get('classified_category', 'UNKNOWN'),
|
||||
'classification_confidence': material_data.get('classification_confidence', 0.0),
|
||||
'classification_details': material_data.get('classification_details', {})
|
||||
})
|
||||
description = material_data.get('original_description', '')
|
||||
main_nom = material_data.get('main_nom', '')
|
||||
red_nom = material_data.get('red_nom', '')
|
||||
length_value = material_data.get('length', 0)
|
||||
|
||||
return material_data
|
||||
try:
|
||||
# 1. 통합 분류기로 1차 분류
|
||||
from ..services.integrated_classifier import classify_material_integrated
|
||||
integrated_result = classify_material_integrated(description, main_nom, red_nom, length_value)
|
||||
|
||||
# 2. 제외 대상 확인
|
||||
if self._should_exclude_material(description):
|
||||
classification_result = {
|
||||
"category": "EXCLUDE",
|
||||
"overall_confidence": 0.95,
|
||||
"reason": "제외 대상 자재"
|
||||
}
|
||||
else:
|
||||
# 3. 타입별 상세 분류기 실행
|
||||
material_type = integrated_result.get('category', 'UNCLASSIFIED')
|
||||
|
||||
if material_type == "PIPE":
|
||||
from ..services.pipe_classifier import classify_pipe_for_purchase
|
||||
classification_result = classify_pipe_for_purchase("", description, main_nom, length_value)
|
||||
elif material_type == "FITTING":
|
||||
from ..services.fitting_classifier import classify_fitting
|
||||
classification_result = classify_fitting("", description, main_nom, red_nom)
|
||||
elif material_type == "FLANGE":
|
||||
from ..services.flange_classifier import classify_flange
|
||||
classification_result = classify_flange("", description, main_nom, red_nom)
|
||||
elif material_type == "VALVE":
|
||||
from ..services.valve_classifier import classify_valve
|
||||
classification_result = classify_valve("", description, main_nom)
|
||||
elif material_type == "BOLT":
|
||||
from ..services.bolt_classifier import classify_bolt
|
||||
classification_result = classify_bolt("", description, main_nom)
|
||||
elif material_type == "GASKET":
|
||||
from ..services.gasket_classifier import classify_gasket
|
||||
classification_result = classify_gasket("", description, main_nom)
|
||||
elif material_type == "SUPPORT":
|
||||
from ..services.support_classifier import classify_support
|
||||
classification_result = classify_support("", description, main_nom)
|
||||
elif material_type == "SPECIAL":
|
||||
classification_result = {
|
||||
"category": "SPECIAL",
|
||||
"overall_confidence": integrated_result.get('confidence', 0.8),
|
||||
"reason": integrated_result.get('reason', 'SPECIAL 키워드 발견')
|
||||
}
|
||||
else:
|
||||
classification_result = {
|
||||
"category": "UNCLASSIFIED",
|
||||
"overall_confidence": 0.1,
|
||||
"reason": "분류되지 않은 자재"
|
||||
}
|
||||
|
||||
# 4. 결과 적용
|
||||
final_category = classification_result.get('category', 'UNCLASSIFIED')
|
||||
if final_category == 'EXCLUDE':
|
||||
return None # 제외 대상은 None 반환
|
||||
|
||||
material_data.update({
|
||||
'line_number': line_number,
|
||||
'row_number': row_number,
|
||||
'classified_category': final_category,
|
||||
'classification_confidence': classification_result.get('overall_confidence', 0.0),
|
||||
'classification_details': classification_result
|
||||
})
|
||||
|
||||
return material_data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"자재 분류 실패 (line {line_number}): {e}")
|
||||
# 분류 실패 시 기본값 설정
|
||||
material_data.update({
|
||||
'line_number': line_number,
|
||||
'row_number': row_number,
|
||||
'classified_category': 'UNCLASSIFIED',
|
||||
'classification_confidence': 0.0,
|
||||
'classification_details': {'error': str(e)}
|
||||
})
|
||||
|
||||
return material_data
|
||||
|
||||
def _should_exclude_material(self, description: str) -> bool:
|
||||
"""제외 대상 자재 확인"""
|
||||
exclude_keywords = [
|
||||
'WELD GAP', '용접갭', '용접 갭',
|
||||
'WELDING GAP', 'WELD ALLOWANCE',
|
||||
'CUTTING ALLOWANCE', '절단여유',
|
||||
'SPARE', '예비품', 'RESERVE'
|
||||
]
|
||||
|
||||
desc_upper = description.upper()
|
||||
return any(keyword.upper() in desc_upper for keyword in exclude_keywords)
|
||||
|
||||
395
backend/app/services/simple_revision_service.py
Normal file
395
backend/app/services/simple_revision_service.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
간단한 리비전 관리 서비스
|
||||
복잡한 dependency 없이 핵심 리비전 로직만 구현
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Material, File, SimpleRevisionComparison, SimpleRevisionMaterial
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class SimpleRevisionService:
|
||||
"""간단한 리비전 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def compare_revisions(self, current_file_id: int, previous_file_id: int,
|
||||
category: str, username: str = "system") -> Dict[str, Any]:
|
||||
"""
|
||||
두 리비전 간의 자재 비교
|
||||
|
||||
Args:
|
||||
current_file_id: 현재 파일 ID
|
||||
previous_file_id: 이전 파일 ID
|
||||
category: 비교할 카테고리 (PIPE, FITTING, FLANGE 등)
|
||||
username: 비교 수행자
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
try:
|
||||
# 현재 파일과 이전 파일의 자재 조회
|
||||
current_materials = self._get_materials_by_category(current_file_id, category)
|
||||
previous_materials = self._get_materials_by_category(previous_file_id, category)
|
||||
|
||||
# 자재 비교 수행
|
||||
comparison_result = self._perform_material_comparison(
|
||||
current_materials, previous_materials, category
|
||||
)
|
||||
|
||||
# 비교 결과 저장
|
||||
comparison_record = self._save_comparison_result(
|
||||
current_file_id, previous_file_id, category,
|
||||
comparison_result, username
|
||||
)
|
||||
|
||||
return {
|
||||
'comparison_id': comparison_record.id,
|
||||
'category': category,
|
||||
'summary': comparison_result['summary'],
|
||||
'materials': comparison_result['materials'],
|
||||
'revision_actions': comparison_result['revision_actions']
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 비교 실패: {e}")
|
||||
raise
|
||||
|
||||
def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]:
|
||||
"""파일 ID와 카테고리로 자재 조회"""
|
||||
return self.db.query(Material).filter(
|
||||
and_(
|
||||
Material.file_id == file_id,
|
||||
or_(
|
||||
Material.classified_category == category,
|
||||
Material.category == category
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
def _perform_material_comparison(self, current_materials: List[Material],
|
||||
previous_materials: List[Material],
|
||||
category: str) -> Dict[str, Any]:
|
||||
"""자재 비교 로직 수행"""
|
||||
|
||||
# 자재를 description + size로 그룹화
|
||||
current_dict = self._group_materials_by_key(current_materials)
|
||||
previous_dict = self._group_materials_by_key(previous_materials)
|
||||
|
||||
# 비교 결과 초기화
|
||||
added_materials = []
|
||||
removed_materials = []
|
||||
changed_materials = []
|
||||
unchanged_materials = []
|
||||
revision_actions = []
|
||||
|
||||
# 현재 자재 기준으로 비교 (개선된 버전)
|
||||
for key, current_group in current_dict.items():
|
||||
if key in previous_dict:
|
||||
previous_group = previous_dict[key]
|
||||
|
||||
# 수량 비교
|
||||
current_qty = current_group['quantity']
|
||||
previous_qty = previous_group['quantity']
|
||||
|
||||
if current_qty != previous_qty:
|
||||
# 수량 변경됨
|
||||
changed_materials.append({
|
||||
'material': current_group['representative_material'],
|
||||
'previous_quantity': previous_qty,
|
||||
'current_quantity': current_qty,
|
||||
'quantity_difference': current_qty - previous_qty,
|
||||
'purchase_confirmed': previous_group['purchase_confirmed']
|
||||
})
|
||||
|
||||
# 리비전 액션 결정 (구매 상태 고려)
|
||||
action = self._determine_revision_action_enhanced(
|
||||
current_group, previous_group, category
|
||||
)
|
||||
revision_actions.append(action)
|
||||
else:
|
||||
# 수량 동일
|
||||
unchanged_materials.append(current_group['representative_material'])
|
||||
else:
|
||||
# 새로 추가된 자재
|
||||
added_materials.append(current_group['representative_material'])
|
||||
revision_actions.append({
|
||||
'material_id': current_group['representative_material'].id,
|
||||
'action': 'added',
|
||||
'description': current_group['representative_material'].original_description,
|
||||
'quantity': current_group['quantity'],
|
||||
'purchase_status': 'not_purchased',
|
||||
'revision_action': 'new_material'
|
||||
})
|
||||
|
||||
# 제거된 자재 확인
|
||||
for key, previous_group in previous_dict.items():
|
||||
if key not in current_dict:
|
||||
removed_materials.append(previous_group['representative_material'])
|
||||
revision_actions.append({
|
||||
'material_id': previous_group['representative_material'].id,
|
||||
'action': 'removed',
|
||||
'description': previous_group['representative_material'].original_description,
|
||||
'quantity': previous_group['quantity'],
|
||||
'purchase_status': 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased',
|
||||
'revision_action': 'deleted_material'
|
||||
})
|
||||
|
||||
# 통계 계산
|
||||
summary = {
|
||||
'added_count': len(added_materials),
|
||||
'removed_count': len(removed_materials),
|
||||
'changed_count': len(changed_materials),
|
||||
'unchanged_count': len(unchanged_materials),
|
||||
'total_current': len(current_materials),
|
||||
'total_previous': len(previous_materials)
|
||||
}
|
||||
|
||||
return {
|
||||
'summary': summary,
|
||||
'materials': {
|
||||
'added': added_materials,
|
||||
'removed': removed_materials,
|
||||
'changed': changed_materials,
|
||||
'unchanged': unchanged_materials
|
||||
},
|
||||
'revision_actions': revision_actions
|
||||
}
|
||||
|
||||
def _group_materials_by_key(self, materials: List[Material]) -> Dict[str, Dict]:
|
||||
"""자재를 고유 키로 그룹화 (개선된 버전)"""
|
||||
grouped = {}
|
||||
for material in materials:
|
||||
# 더 정교한 고유 키 생성
|
||||
# description + drawing + main_nom + red_nom + material_grade
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.drawing_name or '',
|
||||
material.main_nom or '',
|
||||
material.red_nom or '',
|
||||
material.material_grade or ''
|
||||
]
|
||||
key = "|".join(key_parts)
|
||||
|
||||
if key in grouped:
|
||||
# 동일한 자재가 있으면 수량 합산
|
||||
grouped[key]['quantity'] += float(material.quantity)
|
||||
grouped[key]['materials'].append(material)
|
||||
else:
|
||||
grouped[key] = {
|
||||
'key': key,
|
||||
'representative_material': material,
|
||||
'quantity': float(material.quantity),
|
||||
'materials': [material],
|
||||
'purchase_confirmed': getattr(material, 'purchase_confirmed', False)
|
||||
}
|
||||
|
||||
return grouped
|
||||
|
||||
def _determine_revision_action_enhanced(self, current_group: Dict,
|
||||
previous_group: Dict, category: str) -> Dict[str, Any]:
|
||||
"""개선된 리비전 액션 결정 로직"""
|
||||
|
||||
material = current_group['representative_material']
|
||||
current_qty = current_group['quantity']
|
||||
previous_qty = previous_group['quantity']
|
||||
quantity_diff = current_qty - previous_qty
|
||||
is_purchased = previous_group['purchase_confirmed']
|
||||
|
||||
# 리비전 규칙 적용
|
||||
if is_purchased:
|
||||
# 구매된 자재
|
||||
if quantity_diff > 0:
|
||||
action = 'additional_purchase'
|
||||
description = f"추가 구매 필요: +{quantity_diff}"
|
||||
status = 'needs_additional_purchase'
|
||||
elif quantity_diff < 0:
|
||||
action = 'inventory'
|
||||
description = f"재고품으로 분류: {abs(quantity_diff)}"
|
||||
status = 'excess_inventory'
|
||||
else:
|
||||
action = 'maintain'
|
||||
description = "상황 유지"
|
||||
status = 'no_change'
|
||||
else:
|
||||
# 구매 안된 자재
|
||||
if quantity_diff > 0:
|
||||
action = 'quantity_increase'
|
||||
description = f"수량 증가: +{quantity_diff}"
|
||||
status = 'quantity_updated'
|
||||
elif quantity_diff < 0:
|
||||
action = 'quantity_decrease'
|
||||
description = f"수량 감소: {quantity_diff}"
|
||||
status = 'quantity_reduced'
|
||||
else:
|
||||
action = 'maintain'
|
||||
description = "구매 표시 유지"
|
||||
status = 'purchase_pending'
|
||||
|
||||
return {
|
||||
'material_id': material.id,
|
||||
'action': action,
|
||||
'description': material.original_description,
|
||||
'drawing_name': material.drawing_name,
|
||||
'previous_quantity': previous_qty,
|
||||
'current_quantity': current_qty,
|
||||
'quantity_difference': quantity_diff,
|
||||
'purchase_status': 'purchased' if is_purchased else 'not_purchased',
|
||||
'revision_action': status,
|
||||
'action_description': description,
|
||||
'category': category
|
||||
}
|
||||
|
||||
def _determine_revision_action(self, material: Material, previous_qty: float,
|
||||
current_qty: float, category: str) -> Dict[str, Any]:
|
||||
"""리비전 액션 결정 로직"""
|
||||
|
||||
# 구매 상태 확인 (간단하게 purchase_confirmed 필드 사용)
|
||||
is_purchased = getattr(material, 'purchase_confirmed', False)
|
||||
quantity_diff = current_qty - previous_qty
|
||||
|
||||
if is_purchased:
|
||||
# 구매된 자재
|
||||
if quantity_diff > 0:
|
||||
action = 'additional_purchase'
|
||||
description = f"추가 구매 필요: {quantity_diff}"
|
||||
elif quantity_diff < 0:
|
||||
action = 'inventory'
|
||||
description = f"재고품으로 분류: {abs(quantity_diff)}"
|
||||
else:
|
||||
action = 'maintain'
|
||||
description = "상황 유지"
|
||||
else:
|
||||
# 구매 안된 자재
|
||||
if quantity_diff > 0:
|
||||
action = 'quantity_update'
|
||||
description = f"수량 증가: {quantity_diff}"
|
||||
elif quantity_diff < 0:
|
||||
action = 'quantity_decrease'
|
||||
description = f"수량 감소: {abs(quantity_diff)}"
|
||||
else:
|
||||
action = 'maintain'
|
||||
description = "구매 표시 유지"
|
||||
|
||||
return {
|
||||
'material_id': material.id,
|
||||
'action': action,
|
||||
'description': material.original_description,
|
||||
'previous_quantity': previous_qty,
|
||||
'current_quantity': current_qty,
|
||||
'quantity_difference': quantity_diff,
|
||||
'purchase_status': 'purchased' if is_purchased else 'not_purchased',
|
||||
'action_description': description
|
||||
}
|
||||
|
||||
def _save_comparison_result(self, current_file_id: int, previous_file_id: int,
|
||||
category: str, comparison_result: Dict[str, Any],
|
||||
username: str) -> SimpleRevisionComparison:
|
||||
"""비교 결과를 데이터베이스에 저장"""
|
||||
|
||||
# Job No 조회
|
||||
current_file = self.db.query(File).filter(File.id == current_file_id).first()
|
||||
job_no = getattr(current_file, 'job_no', 'unknown')
|
||||
|
||||
# 비교 결과 레코드 생성
|
||||
comparison = SimpleRevisionComparison(
|
||||
job_no=job_no,
|
||||
current_file_id=current_file_id,
|
||||
previous_file_id=previous_file_id,
|
||||
category=category,
|
||||
added_count=comparison_result['summary']['added_count'],
|
||||
removed_count=comparison_result['summary']['removed_count'],
|
||||
changed_count=comparison_result['summary']['changed_count'],
|
||||
unchanged_count=comparison_result['summary']['unchanged_count'],
|
||||
comparison_data=comparison_result,
|
||||
created_by_username=username
|
||||
)
|
||||
|
||||
self.db.add(comparison)
|
||||
self.db.flush() # ID 생성을 위해 flush
|
||||
|
||||
# 개별 자재 변경 로그 저장
|
||||
for action in comparison_result['revision_actions']:
|
||||
material_log = SimpleRevisionMaterial(
|
||||
comparison_id=comparison.id,
|
||||
material_id=action['material_id'],
|
||||
change_type=action['action'],
|
||||
revision_action=action['action'],
|
||||
quantity_before=action.get('previous_quantity', 0),
|
||||
quantity_after=action.get('current_quantity', 0),
|
||||
quantity_difference=action.get('quantity_difference', 0),
|
||||
purchase_status=action.get('purchase_status', 'not_purchased')
|
||||
)
|
||||
self.db.add(material_log)
|
||||
|
||||
self.db.commit()
|
||||
return comparison
|
||||
|
||||
def get_changed_materials_only(self, current_file_id: int, previous_file_id: int,
|
||||
categories: List[str] = None, username: str = "system") -> Dict[str, Any]:
|
||||
"""변경된 자재만 필터링하여 반환"""
|
||||
|
||||
if not categories:
|
||||
categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL', 'UNCLASSIFIED']
|
||||
|
||||
all_changes = {}
|
||||
total_changes = 0
|
||||
|
||||
for category in categories:
|
||||
try:
|
||||
comparison_result = self.compare_revisions(
|
||||
current_file_id, previous_file_id, category, username
|
||||
)
|
||||
|
||||
# 변경된 자재만 필터링
|
||||
changed_materials = []
|
||||
for action in comparison_result['revision_actions']:
|
||||
if action['action'] in ['added', 'removed'] or action.get('quantity_difference', 0) != 0:
|
||||
changed_materials.append(action)
|
||||
|
||||
if changed_materials:
|
||||
all_changes[category] = {
|
||||
'category': category,
|
||||
'changed_count': len(changed_materials),
|
||||
'changes': changed_materials,
|
||||
'summary': comparison_result['summary']
|
||||
}
|
||||
total_changes += len(changed_materials)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compare {category}: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
'total_changed_materials': total_changes,
|
||||
'categories_with_changes': list(all_changes.keys()),
|
||||
'changes_by_category': all_changes,
|
||||
'has_changes': total_changes > 0
|
||||
}
|
||||
|
||||
def get_comparison_result(self, comparison_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""저장된 비교 결과 조회"""
|
||||
comparison = self.db.query(SimpleRevisionComparison).filter(
|
||||
SimpleRevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
return None
|
||||
|
||||
# 관련 자재 변경 로그 조회
|
||||
material_logs = self.db.query(SimpleRevisionMaterial).filter(
|
||||
SimpleRevisionMaterial.comparison_id == comparison_id
|
||||
).all()
|
||||
|
||||
return {
|
||||
'comparison': comparison,
|
||||
'material_logs': material_logs,
|
||||
'comparison_data': comparison.comparison_data
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import tempfile
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import gc
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .logger import get_logger
|
||||
from ..config import get_settings
|
||||
@@ -333,3 +334,107 @@ class FileProcessor:
|
||||
|
||||
# 전역 파일 프로세서 인스턴스
|
||||
file_processor = FileProcessor()
|
||||
|
||||
|
||||
def parse_dataframe(df):
|
||||
"""DataFrame을 파싱하여 자재 데이터로 변환"""
|
||||
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
|
||||
|
||||
print(f"📋 엑셀 컬럼 매핑 결과: {mapped_columns}")
|
||||
print(f"📋 원본 컬럼명들: {list(df.columns)}")
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
description = str(row.get(mapped_columns.get('description', ''), '')).strip()
|
||||
|
||||
if not description or description.lower() in ['nan', 'none', '']:
|
||||
continue
|
||||
|
||||
# 수량 처리
|
||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||
try:
|
||||
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
|
||||
except (ValueError, TypeError):
|
||||
quantity = 0
|
||||
|
||||
if quantity <= 0:
|
||||
continue
|
||||
|
||||
# 길이 처리
|
||||
length_raw = row.get(mapped_columns.get('length', ''), 0)
|
||||
try:
|
||||
length = float(length_raw) if pd.notna(length_raw) else 0
|
||||
except (ValueError, TypeError):
|
||||
length = 0
|
||||
|
||||
# 도면명 처리
|
||||
dwg_name = str(row.get(mapped_columns.get('dwg_name', ''), '')).strip()
|
||||
if dwg_name.lower() in ['nan', 'none']:
|
||||
dwg_name = ''
|
||||
|
||||
# 라인번호 처리
|
||||
line_num = str(row.get(mapped_columns.get('line_num', ''), '')).strip()
|
||||
if line_num.lower() in ['nan', 'none']:
|
||||
line_num = ''
|
||||
|
||||
# 사이즈 처리
|
||||
main_size = str(row.get(mapped_columns.get('main_size', ''), '')).strip()
|
||||
if main_size.lower() in ['nan', 'none']:
|
||||
main_size = ''
|
||||
|
||||
red_size = str(row.get(mapped_columns.get('red_size', ''), '')).strip()
|
||||
if red_size.lower() in ['nan', 'none']:
|
||||
red_size = ''
|
||||
|
||||
materials.append({
|
||||
'original_description': description,
|
||||
'quantity': quantity,
|
||||
'unit': 'EA', # 기본 단위
|
||||
'length': length,
|
||||
'drawing_name': dwg_name,
|
||||
'line_no': line_num,
|
||||
'main_nom': main_size,
|
||||
'red_nom': red_size,
|
||||
'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)}")
|
||||
|
||||
Reference in New Issue
Block a user