diff --git a/backend/app/main.py b/backend/app/main.py
index fbcfa74..17dcfdb 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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
diff --git a/backend/app/models.py b/backend/app/models.py
index 9198c22..de49ae7 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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 등)
+# 필요시 간단한 구조로 다시 추가 예정
diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py
index 6a5dfa6..3e5be1c 100644
--- a/backend/app/routers/files.py
+++ b/backend/app/routers/files.py
@@ -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. 파일 레코드 업데이트 (파싱된 자재 수)
diff --git a/backend/app/routers/simple_revision.py b/backend/app/routers/simple_revision.py
new file mode 100644
index 0000000..e8c185f
--- /dev/null
+++ b/backend/app/routers/simple_revision.py
@@ -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)}")
diff --git a/backend/app/services/database_service.py b/backend/app/services/database_service.py
index 6c42833..68d2126 100644
--- a/backend/app/services/database_service.py
+++ b/backend/app/services/database_service.py
@@ -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"),
diff --git a/backend/app/services/file_upload_service.py b/backend/app/services/file_upload_service.py
index 4e4ec8a..b8eafaa 100644
--- a/backend/app/services/file_upload_service.py
+++ b/backend/app/services/file_upload_service.py
@@ -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)
diff --git a/backend/app/services/simple_revision_service.py b/backend/app/services/simple_revision_service.py
new file mode 100644
index 0000000..4c0b480
--- /dev/null
+++ b/backend/app/services/simple_revision_service.py
@@ -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
+ }
diff --git a/backend/app/utils/file_processor.py b/backend/app/utils/file_processor.py
index e83aec0..19ca22c 100644
--- a/backend/app/utils/file_processor.py
+++ b/backend/app/utils/file_processor.py
@@ -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)}")
diff --git a/backend/scripts/docker_migrator.py b/backend/scripts/docker_migrator.py
index 6e8fa01..cf8b79a 100644
--- a/backend/scripts/docker_migrator.py
+++ b/backend/scripts/docker_migrator.py
@@ -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 환경 마이그레이션 시작")
diff --git a/backend/start.sh b/backend/start.sh
index 70d876b..8abda73 100644
--- a/backend/start.sh
+++ b/backend/start.sh
@@ -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"
diff --git a/frontend/src/components/RevisionUploadDialog.jsx b/frontend/src/components/RevisionUploadDialog.jsx
index ce5129b..67eec03 100644
--- a/frontend/src/components/RevisionUploadDialog.jsx
+++ b/frontend/src/components/RevisionUploadDialog.jsx
@@ -1,14 +1,110 @@
-import React from 'react';
+import React, { useState, useRef } from 'react';
+import api from '../api';
const RevisionUploadDialog = ({
- revisionDialog,
- setRevisionDialog,
- revisionFile,
- setRevisionFile,
- handleRevisionUpload,
- uploading
+ isOpen,
+ onClose,
+ parentFile,
+ selectedProject,
+ onUploadSuccess
}) => {
- if (!revisionDialog.open) return null;
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [error, setError] = useState('');
+ const fileInputRef = useRef(null);
+
+ const handleFileSelect = (event) => {
+ const file = event.target.files[0];
+ if (file) {
+ // 파일 타입 검증
+ const allowedTypes = [
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'text/csv'
+ ];
+
+ if (!allowedTypes.includes(file.type) && !file.name.match(/\.(xlsx?|csv)$/i)) {
+ setError('Excel 파일(.xlsx, .xls) 또는 CSV 파일만 업로드 가능합니다.');
+ return;
+ }
+
+ setSelectedFile(file);
+ setError('');
+ }
+ };
+
+ const handleUpload = async () => {
+ if (!selectedFile || !parentFile || !selectedProject) {
+ setError('필수 정보가 누락되었습니다.');
+ return;
+ }
+
+ try {
+ setUploading(true);
+ setUploadProgress(0);
+ setError('');
+
+ const formData = new FormData();
+ formData.append('file', selectedFile);
+ formData.append('job_no', selectedProject.official_project_code || selectedProject.job_no);
+ formData.append('parent_file_id', parentFile.id);
+ formData.append('bom_name', parentFile.bom_name || parentFile.original_filename);
+
+ console.log('🔄 리비전 업로드 시작:', {
+ fileName: selectedFile.name,
+ jobNo: selectedProject.official_project_code || selectedProject.job_no,
+ parentFileId: parentFile.id,
+ bomName: parentFile.bom_name || parentFile.original_filename
+ });
+
+ const response = await api.post('/files/upload', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ },
+ onUploadProgress: (progressEvent) => {
+ const progress = Math.round(
+ (progressEvent.loaded * 100) / progressEvent.total
+ );
+ setUploadProgress(progress);
+ }
+ });
+
+ if (response.data.success) {
+ console.log('✅ 리비전 업로드 성공:', response.data);
+
+ // 성공 콜백 호출
+ if (onUploadSuccess) {
+ onUploadSuccess(response.data);
+ }
+
+ // 다이얼로그 닫기
+ handleClose();
+ } else {
+ throw new Error(response.data.message || '리비전 업로드 실패');
+ }
+
+ } catch (err) {
+ console.error('❌ 리비전 업로드 실패:', err);
+ setError(err.response?.data?.detail || err.message || '리비전 업로드에 실패했습니다.');
+ } finally {
+ setUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ const handleClose = () => {
+ setSelectedFile(null);
+ setUploading(false);
+ setUploadProgress(0);
+ setError('');
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ onClose();
+ };
+
+ if (!isOpen) return null;
return (
-
- 리비전 업로드: {revisionDialog.bomName}
-
-
-
setRevisionFile(e.target.files[0])}
- style={{
- width: '100%',
- marginBottom: '16px',
- padding: '8px'
+ {/* 헤더 */}
+
+
+
+ 📝 New Revision Upload
+
+
+
+
+ {/* 부모 파일 정보 */}
+
+
+ Base BOM File:
+
+
+ {parentFile?.bom_name || parentFile?.original_filename}
+
+
+ Current Revision: {parentFile?.revision || 'Rev.0'}
+
+
+
+
+ {/* 파일 선택 */}
+
+
+
+
-
-
+ onClick={() => fileInputRef.current?.click()}
+ >
+
+
+ {selectedFile ? (
+
+
📄
+
+ {selectedFile.name}
+
+
+ {Math.round(selectedFile.size / 1024)} KB
+
+
+ ) : (
+
+
📁
+
+ Click to select file
+
+
+ Excel (.xlsx, .xls) or CSV files only
+
+
+ )}
+
+
+
+ {/* 업로드 진행률 */}
+ {uploading && (
+
+
+
+ Uploading...
+
+
+ {uploadProgress}%
+
+
+
+
+ )}
+
+ {/* 에러 메시지 */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* 버튼들 */}
+
+
@@ -79,26 +325,4 @@ const RevisionUploadDialog = ({
);
};
-export default RevisionUploadDialog;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+export default RevisionUploadDialog;
\ No newline at end of file
diff --git a/frontend/src/components/bom/tabs/BOMFilesTab.jsx b/frontend/src/components/bom/tabs/BOMFilesTab.jsx
index 9ed7742..bf765ae 100644
--- a/frontend/src/components/bom/tabs/BOMFilesTab.jsx
+++ b/frontend/src/components/bom/tabs/BOMFilesTab.jsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import api from '../../../api';
+import RevisionUploadDialog from '../../RevisionUploadDialog';
const BOMFilesTab = ({
selectedProject,
@@ -13,6 +14,7 @@ const BOMFilesTab = ({
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [groupedFiles, setGroupedFiles] = useState({});
+ const [revisionDialog, setRevisionDialog] = useState({ open: false, parentFile: null });
// BOM 파일 목록 로드
useEffect(() => {
@@ -99,10 +101,33 @@ const BOMFilesTab = ({
}
};
- // 리비전 업로드 (향후 구현)
+ // 리비전 업로드
const handleRevisionUpload = (parentFile) => {
- // TODO: 리비전 업로드 기능 구현
- alert('리비전 업로드 기능은 향후 구현 예정입니다.');
+ console.log('🔄 리비전 업로드 시작:', parentFile);
+ setRevisionDialog({ open: true, parentFile });
+ };
+
+ // 리비전 업로드 성공 처리
+ const handleRevisionUploadSuccess = async (uploadResult) => {
+ console.log('✅ 리비전 업로드 성공:', uploadResult);
+
+ // BOM 파일 목록 새로고침
+ try {
+ const projectCode = selectedProject.official_project_code || selectedProject.job_no;
+ const encodedProjectCode = encodeURIComponent(projectCode);
+ const response = await api.get(`/files/project/${encodedProjectCode}`);
+ const files = response.data || [];
+
+ setBomFiles(files);
+ setGroupedFiles(groupFilesByBOM(files));
+
+ // 성공 메시지 표시 (선택사항)
+ console.log(`새 리비전 ${uploadResult.revision} 업로드 완료!`);
+
+ } catch (err) {
+ console.error('파일 목록 새로고침 실패:', err);
+ setError('파일 목록을 새로고침하는데 실패했습니다.');
+ }
};
// 날짜 포맷팅
@@ -422,6 +447,15 @@ const BOMFilesTab = ({
+
+ {/* 리비전 업로드 다이얼로그 */}
+ setRevisionDialog({ open: false, parentFile: null })}
+ parentFile={revisionDialog.parentFile}
+ selectedProject={selectedProject}
+ onUploadSuccess={handleRevisionUploadSuccess}
+ />
);
};
diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js
index 6b42994..c4498f9 100644
--- a/frontend/src/components/common/index.js
+++ b/frontend/src/components/common/index.js
@@ -4,5 +4,5 @@ export { default as ErrorMessage } from './ErrorMessage';
export { default as ConfirmDialog } from './ConfirmDialog';
// 기존 컴포넌트들도 re-export
-export { default as UserMenu } from '../UserMenu';
+export { default as UserMenu } from './UserMenu';
export { default as ErrorBoundary } from '../ErrorBoundary';
\ No newline at end of file
diff --git a/frontend/src/hooks/useRevisionLogic.js b/frontend/src/hooks/useRevisionLogic.js
deleted file mode 100644
index eb3b166..0000000
--- a/frontend/src/hooks/useRevisionLogic.js
+++ /dev/null
@@ -1,314 +0,0 @@
-import { useState, useEffect, useCallback } from 'react';
-import { api } from '../api';
-
-/**
- * 리비전 로직 처리 훅
- * 구매 상태별 자재 처리 로직
- */
-export const useRevisionLogic = (jobNo, currentFileId, previousFileId = null) => {
- const [processingResult, setProcessingResult] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const processRevision = useCallback(async () => {
- if (!jobNo || !currentFileId) return;
-
- try {
- setLoading(true);
- setError(null);
-
- const params = new URLSearchParams({
- job_no: jobNo,
- file_id: currentFileId
- });
-
- if (previousFileId) {
- params.append('previous_file_id', previousFileId);
- }
-
- const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${currentFileId}?${params}`);
-
- if (response.data.success) {
- setProcessingResult(response.data.data);
- }
- } catch (err) {
- console.error('리비전 처리 실패:', err);
- setError(err.message);
- } finally {
- setLoading(false);
- }
- }, [jobNo, currentFileId, previousFileId]);
-
- const applyProcessingResults = useCallback(async (results) => {
- try {
- setLoading(true);
- setError(null);
-
- const response = await api.post('/revision-material/apply-results', {
- processing_results: results
- });
-
- if (response.data.success) {
- return response.data;
- } else {
- throw new Error(response.data.message || '처리 결과 적용 실패');
- }
- } catch (err) {
- console.error('처리 결과 적용 실패:', err);
- setError(err.message);
- throw err;
- } finally {
- setLoading(false);
- }
- }, []);
-
- return {
- processingResult,
- loading,
- error,
- processRevision,
- applyProcessingResults,
- setError
- };
-};
-
-/**
- * 카테고리별 자재 처리 훅
- */
-export const useCategoryMaterialProcessing = (fileId, category) => {
- const [materials, setMaterials] = useState([]);
- const [processingInfo, setProcessingInfo] = useState({});
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const loadCategoryMaterials = useCallback(async () => {
- if (!fileId || !category || category === 'PIPE') return;
-
- try {
- setLoading(true);
- setError(null);
-
- const response = await api.get(`/revision-material/category/${fileId}/${category}`);
-
- if (response.data.success) {
- setMaterials(response.data.data.materials || []);
- setProcessingInfo(response.data.data.processing_info || {});
- }
- } catch (err) {
- console.error('카테고리 자재 조회 실패:', err);
- setError(err.message);
- } finally {
- setLoading(false);
- }
- }, [fileId, category]);
-
- const processMaterial = useCallback(async (materialId, action, additionalData = {}) => {
- try {
- const response = await api.post(`/revision-material/process/${materialId}`, {
- action,
- ...additionalData
- });
-
- if (response.data.success) {
- // 자재 목록 새로고침
- await loadCategoryMaterials();
- return response.data;
- } else {
- throw new Error(response.data.message || '자재 처리 실패');
- }
- } catch (err) {
- console.error('자재 처리 실패:', err);
- setError(err.message);
- throw err;
- }
- }, [loadCategoryMaterials]);
-
- const updateMaterialStatus = useCallback((materialId, newStatus, additionalInfo = {}) => {
- setMaterials(prev =>
- prev.map(material =>
- material.id === materialId
- ? {
- ...material,
- revision_status: newStatus,
- processing_info: {
- ...material.processing_info,
- ...additionalInfo
- }
- }
- : material
- )
- );
- }, []);
-
- useEffect(() => {
- loadCategoryMaterials();
- }, [loadCategoryMaterials]);
-
- return {
- materials,
- processingInfo,
- loading,
- error,
- loadCategoryMaterials,
- processMaterial,
- updateMaterialStatus,
- setError
- };
-};
-
-/**
- * 자재 선택 및 일괄 처리 훅
- */
-export const useMaterialSelection = (materials = []) => {
- const [selectedMaterials, setSelectedMaterials] = useState(new Set());
- const [selectAll, setSelectAll] = useState(false);
-
- const toggleMaterial = useCallback((materialId) => {
- setSelectedMaterials(prev => {
- const newSet = new Set(prev);
- if (newSet.has(materialId)) {
- newSet.delete(materialId);
- } else {
- newSet.add(materialId);
- }
- return newSet;
- });
- }, []);
-
- const toggleSelectAll = useCallback(() => {
- if (selectAll) {
- setSelectedMaterials(new Set());
- } else {
- const selectableMaterials = materials
- .filter(material => material.processing_info?.action !== '완료')
- .map(material => material.id);
- setSelectedMaterials(new Set(selectableMaterials));
- }
- setSelectAll(!selectAll);
- }, [selectAll, materials]);
-
- const clearSelection = useCallback(() => {
- setSelectedMaterials(new Set());
- setSelectAll(false);
- }, []);
-
- const getSelectedMaterials = useCallback(() => {
- return materials.filter(material => selectedMaterials.has(material.id));
- }, [materials, selectedMaterials]);
-
- const getSelectionSummary = useCallback(() => {
- const selected = getSelectedMaterials();
- const byStatus = selected.reduce((acc, material) => {
- const status = material.processing_info?.display_status || 'UNKNOWN';
- acc[status] = (acc[status] || 0) + 1;
- return acc;
- }, {});
-
- return {
- total: selected.length,
- byStatus,
- canProcess: selected.length > 0
- };
- }, [getSelectedMaterials]);
-
- // materials 변경 시 selectAll 상태 업데이트
- useEffect(() => {
- const selectableMaterials = materials
- .filter(material => material.processing_info?.action !== '완료');
-
- if (selectableMaterials.length === 0) {
- setSelectAll(false);
- } else {
- const allSelected = selectableMaterials.every(material =>
- selectedMaterials.has(material.id)
- );
- setSelectAll(allSelected);
- }
- }, [materials, selectedMaterials]);
-
- return {
- selectedMaterials,
- selectAll,
- toggleMaterial,
- toggleSelectAll,
- clearSelection,
- getSelectedMaterials,
- getSelectionSummary
- };
-};
-
-/**
- * 리비전 처리 상태 추적 훅
- */
-export const useRevisionProcessingStatus = (jobNo, fileId) => {
- const [status, setStatus] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const loadStatus = useCallback(async () => {
- if (!jobNo || !fileId) return;
-
- try {
- setLoading(true);
- setError(null);
-
- const response = await api.get(`/revision-status/${jobNo}/${fileId}`);
-
- if (response.data.success) {
- setStatus(response.data.data);
- }
- } catch (err) {
- console.error('리비전 상태 조회 실패:', err);
- setError(err.message);
- } finally {
- setLoading(false);
- }
- }, [jobNo, fileId]);
-
- const updateProcessingProgress = useCallback((category, processed, total) => {
- setStatus(prev => {
- if (!prev) return prev;
-
- const newCategoryStatus = {
- ...prev.processing_status.category_breakdown[category],
- processed,
- pending: total - processed
- };
-
- const newCategoryBreakdown = {
- ...prev.processing_status.category_breakdown,
- [category]: newCategoryStatus
- };
-
- // 전체 통계 재계산
- const totalProcessed = Object.values(newCategoryBreakdown)
- .reduce((sum, cat) => sum + cat.processed, 0);
- const totalMaterials = Object.values(newCategoryBreakdown)
- .reduce((sum, cat) => sum + cat.total, 0);
-
- return {
- ...prev,
- processing_status: {
- ...prev.processing_status,
- total_processed: totalProcessed,
- pending_processing: totalMaterials - totalProcessed,
- completion_percentage: totalMaterials > 0 ? (totalProcessed / totalMaterials * 100) : 0,
- category_breakdown: newCategoryBreakdown
- }
- };
- });
- }, []);
-
- useEffect(() => {
- loadStatus();
- }, [loadStatus]);
-
- return {
- status,
- loading,
- error,
- loadStatus,
- updateProcessingProgress,
- setError
- };
-};
diff --git a/frontend/src/hooks/useRevisionRedirect.js b/frontend/src/hooks/useRevisionRedirect.js
deleted file mode 100644
index 7138aa2..0000000
--- a/frontend/src/hooks/useRevisionRedirect.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useState, useEffect } from 'react';
-import { api } from '../api';
-
-/**
- * 리비전 리다이렉트 훅
- * BOM 페이지 접근 시 리비전 페이지로 리다이렉트 필요성 확인
- */
-export const useRevisionRedirect = (jobNo, fileId, previousFileId = null) => {
- const [redirectInfo, setRedirectInfo] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!jobNo || !fileId) {
- setLoading(false);
- return;
- }
-
- checkRevisionRedirect();
- }, [jobNo, fileId, previousFileId]);
-
- const checkRevisionRedirect = async () => {
- try {
- setLoading(true);
- setError(null);
-
- const params = new URLSearchParams({
- job_no: jobNo,
- file_id: fileId
- });
-
- if (previousFileId) {
- params.append('previous_file_id', previousFileId);
- }
-
- const response = await api.get(`/revision-redirect/check/${jobNo}/${fileId}?${params}`);
-
- if (response.data.success) {
- setRedirectInfo(response.data.data);
- }
- } catch (err) {
- console.error('리비전 리다이렉트 확인 실패:', err);
- setError(err.message);
- // 에러 발생 시 기존 BOM 페이지 사용
- setRedirectInfo({
- should_redirect: false,
- reason: '리비전 상태 확인 실패 - 기존 페이지 사용',
- redirect_url: null,
- processing_summary: null
- });
- } finally {
- setLoading(false);
- }
- };
-
- return {
- redirectInfo,
- loading,
- error,
- refetch: checkRevisionRedirect
- };
-};
-
-/**
- * 리비전 처리 로직 훅
- * 리비전 페이지에서 사용할 상세 처리 결과 조회
- */
-export const useRevisionProcessing = (jobNo, fileId, previousFileId = null) => {
- const [processingResult, setProcessingResult] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const processRevision = async () => {
- if (!jobNo || !fileId) return;
-
- try {
- setLoading(true);
- setError(null);
-
- const params = new URLSearchParams({
- job_no: jobNo,
- file_id: fileId
- });
-
- if (previousFileId) {
- params.append('previous_file_id', previousFileId);
- }
-
- const response = await api.post(`/revision-redirect/process-revision/${jobNo}/${fileId}?${params}`);
-
- if (response.data.success) {
- setProcessingResult(response.data.data);
- }
- } catch (err) {
- console.error('리비전 처리 실패:', err);
- setError(err.message);
- } finally {
- setLoading(false);
- }
- };
-
- return {
- processingResult,
- loading,
- error,
- processRevision
- };
-};
diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx
index fb8de56..fd44adf 100644
--- a/frontend/src/pages/BOMManagementPage.jsx
+++ b/frontend/src/pages/BOMManagementPage.jsx
@@ -34,6 +34,12 @@ const BOMManagementPage = ({
const [userRequirements, setUserRequirements] = useState({});
const [purchasedMaterials, setPurchasedMaterials] = useState(new Set());
const [error, setError] = useState(null);
+
+ // 리비전 관련 상태
+ const [isRevisionMode, setIsRevisionMode] = useState(false);
+ const [revisionData, setRevisionData] = useState(null);
+ const [previousFileId, setPreviousFileId] = useState(null);
+ const [changedMaterials, setChangedMaterials] = useState({});
// 자재 업데이트 함수 (브랜드, 사용자 요구사항 등)
const updateMaterial = (materialId, updates) => {
@@ -155,12 +161,58 @@ const BOMManagementPage = ({
}
};
+ // 리비전 모드 감지 및 변경된 자재 로드
+ const checkAndLoadRevisionData = async () => {
+ try {
+ // 현재 job_no의 모든 파일 목록 확인
+ const filesResponse = await api.get(`/files/list?job_no=${jobNo}`);
+ const files = filesResponse.data.files || [];
+
+ if (files.length > 1) {
+ // 파일이 여러 개 있으면 리비전 모드 활성화
+ setIsRevisionMode(true);
+
+ // 파일들을 업로드 날짜순으로 정렬
+ const sortedFiles = files.sort((a, b) => new Date(a.upload_date) - new Date(b.upload_date));
+
+ // 이전 파일 ID 찾기 (현재 파일 이전 버전)
+ const currentIndex = sortedFiles.findIndex(file => file.id === parseInt(fileId));
+ if (currentIndex > 0) {
+ const previousFile = sortedFiles[currentIndex - 1];
+ setPreviousFileId(previousFile.id);
+
+ // 변경된 자재 로드
+ await loadChangedMaterials(fileId, previousFile.id);
+ }
+ }
+ } catch (error) {
+ console.error('리비전 데이터 로드 실패:', error);
+ // API 오류 시 리비전 모드 비활성화
+ setIsRevisionMode(false);
+ }
+ };
+
+ // 변경된 자재 로드
+ const loadChangedMaterials = async (currentFileId, previousFileId) => {
+ try {
+ const response = await api.get(`/simple-revision/changed-materials/${currentFileId}/${previousFileId}`);
+ if (response.data.success) {
+ setChangedMaterials(response.data.data.changes_by_category || {});
+ setRevisionData(response.data.data);
+ console.log('✅ 변경된 자재 로드 완료:', response.data.data);
+ }
+ } catch (error) {
+ console.error('변경된 자재 로드 실패:', error);
+ }
+ };
+
// 초기 로드
useEffect(() => {
if (fileId) {
loadMaterials(fileId);
loadAvailableRevisions();
loadUserRequirements(fileId);
+ checkAndLoadRevisionData(); // 리비전 데이터 확인
}
}, [fileId]);
@@ -180,12 +232,30 @@ const BOMManagementPage = ({
}
}, [materials, selectedCategory]);
- // 카테고리별 자재 필터링
+ // 카테고리별 자재 필터링 (리비전 모드 지원)
const getCategoryMaterials = (category) => {
- return materials.filter(material =>
- material.classified_category === category ||
- material.category === category
- );
+ if (isRevisionMode && changedMaterials[category]) {
+ // 리비전 모드: 변경된 자재만 표시
+ const changedMaterialIds = changedMaterials[category].changes.map(change => change.material_id);
+ return materials.filter(material =>
+ (material.classified_category === category || material.category === category) &&
+ changedMaterialIds.includes(material.id)
+ );
+ } else {
+ // 일반 모드: 모든 자재 표시
+ return materials.filter(material =>
+ material.classified_category === category ||
+ material.category === category
+ );
+ }
+ };
+
+ // 리비전 액션 정보 가져오기
+ const getRevisionAction = (materialId, category) => {
+ if (!isRevisionMode || !changedMaterials[category]) return null;
+
+ const change = changedMaterials[category].changes.find(c => c.material_id === materialId);
+ return change || null;
};
// 카테고리별 컴포넌트 렌더링
@@ -210,7 +280,11 @@ const BOMManagementPage = ({
fileId,
jobNo,
user,
- onNavigate
+ onNavigate,
+ // 리비전 관련 props 추가
+ isRevisionMode,
+ getRevisionAction: (materialId) => getRevisionAction(materialId, selectedCategory),
+ revisionData
};
switch (selectedCategory) {
@@ -282,15 +356,32 @@ const BOMManagementPage = ({
}}>
-
- BOM Materials Management
-
+
+
+ BOM Materials Management
+
+ {isRevisionMode && (
+
+ 📊 Revision Mode
+
+ )}
+
{bomName} - {currentRevision} | Project: {selectedProject?.job_name || jobNo}
+ {isRevisionMode && revisionData && (
+
+ • {revisionData.total_changed_materials} materials changed
+
+ )}