리비전 페이지 제거 및 트랜잭션 오류 임시 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- frontend/src/pages/revision/ 폴더 완전 삭제
- EnhancedRevisionPage.css 제거
- support_details 저장 시 트랜잭션 오류로 인해 임시로 상세 정보 저장 비활성화
- 리비전 기능 재설계 예정
This commit is contained in:
Hyungi Ahn
2025-10-21 12:11:57 +09:00
parent 8f42a1054e
commit 1dc735f362
29 changed files with 1728 additions and 6987 deletions

View File

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

View File

@@ -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 등)
# 필요시 간단한 구조로 다시 추가 예정

View File

@@ -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. 파일 레코드 업데이트 (파싱된 자재 수)

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

View File

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

View File

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

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

View File

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