리비전 페이지 제거 및 트랜잭션 오류 임시 수정
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)}")

View File

@@ -51,7 +51,15 @@ class DockerMigrator:
if self._check_and_create_detail_tables(cursor):
fixes_applied.append("누락된 상세 테이블들 생성")
# 5. 기타 필요한 수정사항들...
# 5. 리비전 관리 테이블들 체크 및 생성
if self._check_and_create_revision_tables(cursor):
fixes_applied.append("리비전 관리 테이블들 생성")
# 6. PIPE 관련 테이블들 체크 및 생성
if self._check_and_create_pipe_tables(cursor):
fixes_applied.append("PIPE 관련 테이블들 생성")
# 7. 기타 필요한 수정사항들...
# 향후 추가될 수 있는 다른 스키마 수정사항들
conn.commit()
@@ -382,6 +390,256 @@ class DockerMigrator:
print(f"❌ 테이블 확인 실패: {e}")
return False
def _check_and_create_revision_tables(self, cursor):
"""간단한 리비전 관리 테이블들 체크 및 생성"""
print("🔍 간단한 리비전 관리 테이블들 확인 중...")
revision_tables = {
'simple_revision_comparisons': '''
CREATE TABLE simple_revision_comparisons (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
current_file_id INTEGER REFERENCES files(id) NOT NULL,
previous_file_id INTEGER REFERENCES files(id) NOT NULL,
category VARCHAR(50) NOT NULL,
added_count INTEGER DEFAULT 0,
removed_count INTEGER DEFAULT 0,
changed_count INTEGER DEFAULT 0,
unchanged_count INTEGER DEFAULT 0,
purchased_affected INTEGER DEFAULT 0,
unpurchased_affected INTEGER DEFAULT 0,
inventory_count INTEGER DEFAULT 0,
comparison_data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by_username VARCHAR(100)
);
CREATE INDEX idx_simple_revision_comparisons_job_no ON simple_revision_comparisons(job_no);
CREATE INDEX idx_simple_revision_comparisons_category ON simple_revision_comparisons(category);
''',
'simple_revision_materials': '''
CREATE TABLE simple_revision_materials (
id SERIAL PRIMARY KEY,
comparison_id INTEGER REFERENCES simple_revision_comparisons(id) NOT NULL,
material_id INTEGER REFERENCES materials(id),
change_type VARCHAR(20) NOT NULL,
revision_action VARCHAR(30),
quantity_before NUMERIC(10,3),
quantity_after NUMERIC(10,3),
quantity_difference NUMERIC(10,3),
purchase_status VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_simple_revision_materials_comparison ON simple_revision_materials(comparison_id);
''',
}
created_tables = []
for table_name, create_sql in revision_tables.items():
# 테이블 존재 확인
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = %s
)
""", (table_name,))
result = cursor.fetchone()
if isinstance(result, dict):
table_exists = result.get('exists', False)
else:
table_exists = result[0] if result else False
if not table_exists:
# 테이블 생성
cursor.execute(create_sql)
created_tables.append(table_name)
print(f" 🏗️ {table_name} 테이블 생성됨")
if created_tables:
print(f"{len(created_tables)}개 리비전 관리 테이블 생성 완료")
return True
else:
print("✅ 모든 리비전 관리 테이블이 이미 존재합니다")
return False
def _check_and_create_pipe_tables(self, cursor):
"""PIPE 관련 테이블들 체크 및 생성"""
print("🔍 PIPE 관련 테이블들 확인 중...")
pipe_tables = {
'pipe_cutting_plans': '''
CREATE TABLE pipe_cutting_plans (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
file_id INTEGER REFERENCES files(id) NOT NULL,
area VARCHAR(20) NOT NULL,
drawing_name VARCHAR(100) NOT NULL,
line_number VARCHAR(50),
pipe_info TEXT,
length NUMERIC(10,3),
end_preparation VARCHAR(20),
status VARCHAR(20) DEFAULT 'draft',
cutting_status VARCHAR(20) DEFAULT 'not_cut',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100)
);
CREATE INDEX idx_pipe_cutting_plans_job_no ON pipe_cutting_plans(job_no);
CREATE INDEX idx_pipe_cutting_plans_drawing ON pipe_cutting_plans(drawing_name);
''',
'pipe_revision_comparisons': '''
CREATE TABLE pipe_revision_comparisons (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
current_cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id),
previous_cutting_plan_id INTEGER REFERENCES pipe_cutting_plans(id),
drawing_name VARCHAR(100) NOT NULL,
has_changes BOOLEAN DEFAULT FALSE,
total_pipes_current INTEGER DEFAULT 0,
total_pipes_previous INTEGER DEFAULT 0,
added_pipes INTEGER DEFAULT 0,
removed_pipes INTEGER DEFAULT 0,
changed_pipes INTEGER DEFAULT 0,
unchanged_pipes INTEGER DEFAULT 0,
comparison_details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_pipe_revision_comparisons_job_no ON pipe_revision_comparisons(job_no);
''',
'pipe_revision_changes': '''
CREATE TABLE pipe_revision_changes (
id SERIAL PRIMARY KEY,
comparison_id INTEGER REFERENCES pipe_revision_comparisons(id) NOT NULL,
change_type VARCHAR(20) NOT NULL,
drawing_name VARCHAR(100) NOT NULL,
line_number VARCHAR(50),
pipe_info TEXT,
length NUMERIC(10,3),
end_preparation VARCHAR(20),
old_data JSONB,
new_data JSONB,
cutting_status VARCHAR(20) DEFAULT 'not_cut',
impact_on_material_requirement JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
''',
'pipe_drawing_issues': '''
CREATE TABLE pipe_drawing_issues (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
snapshot_id INTEGER REFERENCES pipe_issue_snapshots(id),
area VARCHAR(20) NOT NULL,
drawing_name VARCHAR(100) NOT NULL,
issue_description TEXT NOT NULL,
severity VARCHAR(20) DEFAULT 'normal',
status VARCHAR(20) DEFAULT 'open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
resolved_by VARCHAR(100),
resolved_at TIMESTAMP
);
CREATE INDEX idx_pipe_drawing_issues_job_no ON pipe_drawing_issues(job_no);
''',
'pipe_segment_issues': '''
CREATE TABLE pipe_segment_issues (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
snapshot_id INTEGER REFERENCES pipe_issue_snapshots(id),
area VARCHAR(20) NOT NULL,
drawing_name VARCHAR(100) NOT NULL,
line_no VARCHAR(50) NOT NULL,
pipe_info TEXT NOT NULL,
length NUMERIC(10,3) NOT NULL,
issue_description TEXT NOT NULL,
issue_type VARCHAR(30),
severity VARCHAR(20) DEFAULT 'normal',
status VARCHAR(20) DEFAULT 'open',
resolution_action TEXT,
length_adjustment NUMERIC(10,3),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
resolved_by VARCHAR(100),
resolved_at TIMESTAMP
);
CREATE INDEX idx_pipe_segment_issues_job_no ON pipe_segment_issues(job_no);
''',
'pipe_issue_snapshots': '''
CREATE TABLE pipe_issue_snapshots (
id SERIAL PRIMARY KEY,
job_no VARCHAR(50) NOT NULL,
snapshot_name VARCHAR(200),
cutting_plan_finalized_at TIMESTAMP NOT NULL,
is_locked BOOLEAN DEFAULT TRUE,
snapshot_data JSONB,
total_segments INTEGER DEFAULT 0,
total_drawings INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
description TEXT
);
CREATE INDEX idx_pipe_issue_snapshots_job_no ON pipe_issue_snapshots(job_no);
'''
}
created_tables = []
# pipe_issue_snapshots를 먼저 생성 (다른 테이블들이 참조하므로)
priority_tables = ['pipe_issue_snapshots', 'pipe_cutting_plans']
# 우선순위 테이블들 먼저 생성
for table_name in priority_tables:
if table_name in pipe_tables:
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = %s
)
""", (table_name,))
result = cursor.fetchone()
if isinstance(result, dict):
table_exists = result.get('exists', False)
else:
table_exists = result[0] if result else False
if not table_exists:
cursor.execute(pipe_tables[table_name])
created_tables.append(table_name)
print(f" 🏗️ {table_name} 테이블 생성됨")
# 나머지 테이블들 생성
for table_name, create_sql in pipe_tables.items():
if table_name in priority_tables:
continue # 이미 처리됨
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = %s
)
""", (table_name,))
result = cursor.fetchone()
if isinstance(result, dict):
table_exists = result.get('exists', False)
else:
table_exists = result[0] if result else False
if not table_exists:
cursor.execute(create_sql)
created_tables.append(table_name)
print(f" 🏗️ {table_name} 테이블 생성됨")
if created_tables:
print(f"{len(created_tables)}개 PIPE 관련 테이블 생성 완료")
return True
else:
print("✅ 모든 PIPE 관련 테이블이 이미 존재합니다")
return False
def main():
print("🚀 Docker 환경 마이그레이션 시작")

View File

@@ -13,29 +13,40 @@ echo " - DB_PORT: ${DB_PORT:-5432}"
echo " - DB_NAME: ${DB_NAME:-tk_mp_bom}"
echo " - DB_USER: ${DB_USER:-tkmp_user}"
# 1. Run complete DB migration
echo "Running complete DB migration..."
python scripts/complete_migrate.py
# 1. Run Docker-optimized migration (includes all new revision/PIPE tables)
echo "Running Docker-optimized migration..."
python scripts/docker_migrator.py
migration_result=$?
if [ $migration_result -ne 0 ]; then
echo "WARNING: DB migration had some errors. Trying to fix missing tables..."
python scripts/fix_missing_tables.py
fix_result=$?
if [ $fix_result -eq 0 ]; then
echo "SUCCESS: Missing tables fixed"
else
echo "WARNING: Some tables may still be missing but starting server anyway"
fi
if [ $migration_result -eq 0 ]; then
echo "SUCCESS: Docker migration completed successfully"
else
echo "SUCCESS: Complete DB migration finished"
echo "WARNING: Docker migration had some issues. Trying fallback methods..."
# Fallback to legacy migration methods
echo "Running legacy complete migration..."
python scripts/complete_migrate.py
legacy_result=$?
if [ $legacy_result -ne 0 ]; then
echo "WARNING: Legacy migration had errors. Trying to fix missing tables..."
python scripts/fix_missing_tables.py
fix_result=$?
if [ $fix_result -eq 0 ]; then
echo "SUCCESS: Missing tables fixed"
else
echo "WARNING: Some tables may still be missing but starting server anyway"
fi
else
echo "SUCCESS: Legacy migration finished"
fi
fi
# Additional safety check for critical tables
echo "Verifying critical tables..."
python scripts/fix_missing_tables.py
# Complete schema analysis and fix
# Complete schema analysis and fix (for any remaining issues)
echo "Running complete schema analysis and fix..."
python scripts/analyze_and_fix_schema.py
echo "Complete schema analysis completed"