diff --git a/backend/app/main.py b/backend/app/main.py index fbcfa74..9d66010 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -91,6 +91,13 @@ try: except ImportError: logger.warning("dashboard 라우터를 찾을 수 없습니다") +# 리비전 관리 라우터 (임시 비활성화) +# try: +# from .routers import revision_management +# app.include_router(revision_management.router, tags=["revision-management"]) +# except ImportError: +# logger.warning("revision_management 라우터를 찾을 수 없습니다") + try: from .routers import tubing app.include_router(tubing.router, prefix="/tubing", tags=["tubing"]) @@ -234,6 +241,14 @@ async def root(): # print(f"Jobs 조회 에러: {str(e)}") # return {"error": f"Jobs 조회 실패: {str(e)}"} +# 리비전 관리 라우터 +try: + from .routers import revision_management + app.include_router(revision_management.router) + logger.info("revision_management 라우터 등록 완료") +except ImportError as e: + logger.warning(f"revision_management 라우터를 찾을 수 없습니다: {e}") + # 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py) # parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다 diff --git a/backend/app/models.py b/backend/app/models.py index 416330c..1d9c4f1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -70,6 +70,25 @@ class Material(Base): drawing_reference = Column(String(100)) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) + is_active = Column(Boolean, default=True) + + # 추가 필드들 + main_nom = Column(String(50)) + red_nom = Column(String(50)) + purchase_confirmed = Column(Boolean, default=False) + purchase_confirmed_at = Column(DateTime) + purchase_status = Column(String(20), default='not_purchased') + purchase_confirmed_by = Column(String(100)) + confirmed_quantity = Column(Numeric(10, 3)) + revision_status = Column(String(20), default='active') + material_hash = Column(String(100)) + normalized_description = Column(Text) + full_material_grade = Column(String(100)) + row_number = Column(Integer) + length = Column(Numeric(10, 3)) + brand = Column(String(100)) + user_requirement = Column(Text) + total_length = Column(Numeric(10, 3)) # 관계 설정 file = relationship("File", back_populates="materials") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index d21a92b..d43f6d3 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -339,6 +339,12 @@ async def upload_file( current_user: dict = Depends(get_current_user) ): # 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화 + + # 🔍 디버깅: 업로드 파라미터 로깅 + print(f"🔍 [UPLOAD] job_no: {job_no}, revision: {revision}, parent_file_id: {parent_file_id}") + print(f"🔍 [UPLOAD] bom_name: {bom_name}, filename: {file.filename}") + print(f"🔍 [UPLOAD] revision != 'Rev.0': {revision != 'Rev.0'}") + try: # 1. 현재 트랜잭션 완전 롤백 db.rollback() @@ -502,6 +508,7 @@ async def upload_file( }) file_id = file_result.fetchone()[0] + db.commit() # 파일 레코드 즉시 커밋 print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}") # 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수) @@ -509,29 +516,30 @@ async def upload_file( materials_to_classify = materials_data if revision != "Rev.0": # 리비전 업로드인 경우만 비교 - # 로그 제거 + print(f"🔍 [DEBUG] 리비전 비교 시작 - revision: {revision}, parent_file_id: {parent_file_id}") try: - revision_comparison = get_revision_comparison(db, job_no, revision, materials_data) + # 간단한 리비전 비교 로직 (purchase_confirmed 기반) + print(f"🔍 [DEBUG] perform_simple_revision_comparison 호출 중...") + revision_comparison = perform_simple_revision_comparison(db, job_no, parent_file_id, materials_data) + print(f"🔍 [DEBUG] 리비전 비교 완료: {revision_comparison.keys() if revision_comparison else 'None'}") - if revision_comparison.get("has_previous_confirmation", False): - print(f"📊 리비전 비교 결과:") - print(f" - 변경없음: {revision_comparison.get('unchanged_count', 0)}개") - print(f" - 변경됨: {revision_comparison.get('changed_count', 0)}개") - print(f" - 신규: {revision_comparison.get('new_count', 0)}개") - print(f" - 삭제됨: {revision_comparison.get('removed_count', 0)}개") - print(f" - 분류 필요: {revision_comparison.get('classification_needed', 0)}개") + if revision_comparison.get("has_purchased_materials", False): + print(f"📊 간단한 리비전 비교 결과:") + print(f" - 구매확정된 자재: {revision_comparison.get('purchased_count', 0)}개") + print(f" - 미구매 자재: {revision_comparison.get('unpurchased_count', 0)}개") + print(f" - 신규 자재: {revision_comparison.get('new_count', 0)}개") + print(f" - 변경된 자재: {revision_comparison.get('changed_count', 0)}개") - # 분류가 필요한 자재만 추출 (변경됨 + 신규) - materials_to_classify = ( - revision_comparison.get("changed_materials", []) + - revision_comparison.get("new_materials", []) - ) + # 신규 및 변경된 자재만 분류 + materials_to_classify = revision_comparison.get("materials_to_classify", []) + print(f" - 분류 필요: {len(materials_to_classify)}개") else: - print("📝 이전 확정 자료 없음 - 전체 자재 분류") + print("📝 이전 구매확정 자료 없음 - 전체 자재 분류") except Exception as e: - logger.error(f"리비전 비교 실패: {str(e)}") print(f"⚠️ 리비전 비교 실패, 전체 자재 분류로 진행: {str(e)}") + import traceback + traceback.print_exc() print(f"🔧 자재 분류 시작: {len(materials_to_classify)}개 자재") @@ -1730,6 +1738,11 @@ async def upload_file( return response_data except Exception as e: + import traceback + error_details = traceback.format_exc() + print(f"❌ 파일 업로드 실패 - 상세 에러:") + print(error_details) + db.rollback() if os.path.exists(file_path): os.remove(file_path) @@ -1779,6 +1792,56 @@ async def get_files( except Exception as e: raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") +@router.get("/list") +async def get_files_list( + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """파일 목록 조회 (리비전 모드 확인용)""" + try: + query = """ + SELECT id, filename, original_filename, bom_name, job_no, revision, + description, file_size, parsed_count, upload_date, is_active + FROM files + WHERE is_active = TRUE + """ + params = {} + + if job_no: + query += " AND job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY upload_date ASC" # 업로드 순서대로 정렬 + + result = db.execute(text(query), params) + files = result.fetchall() + + files_list = [ + { + "id": file.id, + "filename": file.filename, + "original_filename": file.original_filename, + "bom_name": file.bom_name, + "job_no": file.job_no, + "revision": file.revision, + "description": file.description, + "file_size": file.file_size, + "parsed_count": file.parsed_count, + "upload_date": file.upload_date, + "is_active": file.is_active + } + for file in files + ] + + return { + "success": True, + "files": files_list, + "total_count": len(files_list) + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") + @router.get("/project/{project_code}") async def get_files_by_project( project_code: str, @@ -3957,4 +4020,156 @@ async def get_excel_exports( "success": False, "exports": [], "message": "엑셀 내보내기 목록을 조회할 수 없습니다." + } + + +def perform_simple_revision_comparison(db: Session, job_no: str, parent_file_id: int, new_materials: List[Dict]) -> Dict: + """ + 간단한 리비전 비교 로직 (purchase_confirmed 기반) + + Args: + db: 데이터베이스 세션 + job_no: 프로젝트 번호 + parent_file_id: 이전 파일 ID + new_materials: 신규 자재 목록 + + Returns: + 비교 결과 + """ + try: + # 1. 이전 파일의 구매확정된 자재 조회 + previous_materials_query = text(""" + SELECT original_description, classified_category, size_spec, material_grade, + main_nom, red_nom, drawing_name, line_no, + COALESCE(total_quantity, quantity, 0) as quantity, unit, + purchase_confirmed + FROM materials + WHERE file_id = :parent_file_id + ORDER BY id + """) + + previous_result = db.execute(previous_materials_query, {"parent_file_id": parent_file_id}) + previous_materials = previous_result.fetchall() + + if not previous_materials: + return { + "has_purchased_materials": False, + "message": "이전 자료가 없습니다." + } + + # 2. 이전 자재를 키별로 그룹화 (구매확정 상태 포함) + previous_dict = {} + purchased_count = 0 + unpurchased_count = 0 + + for material in previous_materials: + # 자재 식별 키 생성 (description + size만 사용 - 도면 변경 시에도 매칭 가능) + key = f"{material.original_description.strip().upper()}|{material.size_spec or ''}" + + if key in previous_dict: + # 동일 자재가 있으면 수량 합산 + previous_dict[key]["quantity"] += float(material.quantity or 0) + else: + previous_dict[key] = { + "original_description": material.original_description, + "classified_category": material.classified_category, + "size_spec": material.size_spec, + "material_grade": material.material_grade, + "main_nom": material.main_nom, + "red_nom": material.red_nom, + "drawing_name": material.drawing_name, + "line_no": material.line_no, + "quantity": float(material.quantity or 0), + "unit": material.unit, + "purchase_confirmed": material.purchase_confirmed + } + + if material.purchase_confirmed: + purchased_count += 1 + else: + unpurchased_count += 1 + + # 3. 신규 자재를 키별로 그룹화 + new_dict = {} + for material in new_materials: + key = f"{material.get('original_description', '').strip().upper()}|{material.get('size_spec', '') or ''}" + + # total_quantity 우선 사용 + quantity = float(material.get("total_quantity") or material.get("quantity", 0)) + + if key in new_dict: + new_dict[key]["quantity"] += quantity + else: + new_dict[key] = material.copy() + new_dict[key]["quantity"] = quantity + + # 4. 비교 수행 + materials_to_classify = [] + new_count = 0 + changed_count = 0 + matched_count = 0 + + print(f"🔍 [DEBUG] 이전 자재 키 개수: {len(previous_dict)}") + print(f"🔍 [DEBUG] 신규 자재 키 개수: {len(new_dict)}") + + # 구매확정된 자재 키들 확인 + purchased_keys = [key for key, mat in previous_dict.items() if mat["purchase_confirmed"]] + print(f"🔍 [DEBUG] 구매확정된 자재 키 개수: {len(purchased_keys)}") + if purchased_keys: + print(f"🔍 [DEBUG] 구매확정 자재 샘플: {purchased_keys[:3]}") + + for key, new_material in new_dict.items(): + if key in previous_dict: + previous_material = previous_dict[key] + matched_count += 1 + + # 구매확정된 자재 매칭 로그 + if previous_material["purchase_confirmed"]: + print(f"✅ 구매확정 자재 매칭: {key[:50]}...") + + # 수량 비교 + if abs(new_material["quantity"] - previous_material["quantity"]) > 0.001: + # 수량이 변경된 경우 + print(f"🔄 수량 변경: {new_material.get('original_description', '')[:50]}... " + f"{previous_material['quantity']} → {new_material['quantity']}") + + # 구매확정된 자재의 수량 변경은 특별 처리 필요 + if previous_material["purchase_confirmed"]: + print(f"⚠️ 구매확정된 자재 수량 변경 감지!") + + materials_to_classify.append(new_material) + changed_count += 1 + else: + # 수량이 동일하고 구매확정된 자재는 분류에서 제외 + if previous_material["purchase_confirmed"]: + print(f"🚫 구매확정된 동일 자재 제외: {key[:50]}...") + # else: 미구매 동일 자재는 분류 불필요 (기존 분류 유지) + else: + # 완전히 새로운 자재 + print(f"➕ 신규 자재: {new_material.get('original_description', '')[:50]}...") + materials_to_classify.append(new_material) + new_count += 1 + + print(f"🔍 [DEBUG] 매칭된 자재: {matched_count}개, 신규: {new_count}개, 변경: {changed_count}개") + + has_purchased = any(mat["purchase_confirmed"] for mat in previous_dict.values()) + + return { + "has_purchased_materials": has_purchased, + "purchased_count": len([m for m in previous_dict.values() if m["purchase_confirmed"]]), + "unpurchased_count": len([m for m in previous_dict.values() if not m["purchase_confirmed"]]), + "new_count": new_count, + "changed_count": changed_count, + "materials_to_classify": materials_to_classify, + "total_previous": len(previous_dict), + "total_new": len(new_dict) + } + + except Exception as e: + print(f"❌ 간단한 리비전 비교 실패: {str(e)}") + import traceback + traceback.print_exc() + return { + "has_purchased_materials": False, + "message": f"비교 실패: {str(e)}" } \ No newline at end of file diff --git a/backend/app/routers/purchase_request.py b/backend/app/routers/purchase_request.py index f924dcf..648aa93 100644 --- a/backend/app/routers/purchase_request.py +++ b/backend/app/routers/purchase_request.py @@ -42,6 +42,14 @@ async def create_purchase_request( 구매신청 생성 (엑셀 내보내기 = 구매신청) """ try: + # 🔍 디버깅: 요청 데이터 로깅 + logger.info(f"🔍 구매신청 생성 요청 - file_id: {request_data.file_id}, job_no: {request_data.job_no}") + logger.info(f"🔍 material_ids 개수: {len(request_data.material_ids)}") + logger.info(f"🔍 materials_data 개수: {len(request_data.materials_data)}") + if request_data.material_ids: + logger.info(f"🔍 material_ids 샘플: {request_data.material_ids[:5]}") + + print(f"🔍 [DEBUG] 구매신청 API 호출됨 - material_ids: {len(request_data.material_ids)}개") # 구매신청 번호 생성 today = datetime.now().strftime('%Y%m%d') count_query = text(""" @@ -116,9 +124,13 @@ async def create_purchase_request( if not existing: insert_item = text(""" INSERT INTO purchase_request_items ( - request_id, material_id, quantity, unit, user_requirement + request_id, material_id, description, category, subcategory, + material_grade, size_spec, quantity, unit, drawing_name, + notes, user_requirement ) VALUES ( - :request_id, :material_id, :quantity, :unit, :user_requirement + :request_id, :material_id, :description, :category, :subcategory, + :material_grade, :size_spec, :quantity, :unit, :drawing_name, + :notes, :user_requirement ) """) # quantity를 정수로 변환 (소수점 제거) @@ -131,14 +143,44 @@ async def create_purchase_request( db.execute(insert_item, { "request_id": request_id, "material_id": material_id, + "description": material_data.get("description", material_data.get("original_description", "")), + "category": material_data.get("category", material_data.get("classified_category", "")), + "subcategory": material_data.get("subcategory", material_data.get("classified_subcategory", "")), + "material_grade": material_data.get("material_grade", ""), + "size_spec": material_data.get("size_spec", ""), "quantity": quantity, - "unit": material_data.get("unit", ""), + "unit": material_data.get("unit", "EA"), + "drawing_name": material_data.get("drawing_name", ""), + "notes": material_data.get("notes", ""), "user_requirement": material_data.get("user_requirement", "") }) inserted_count += 1 else: logger.warning(f"Material {material_id} already in another purchase request, skipping") + # 🔥 중요: materials 테이블의 purchase_confirmed 업데이트 + if request_data.material_ids: + print(f"🔥 [PURCHASE] purchase_confirmed 업데이트 시작: {len(request_data.material_ids)}개 자재") + print(f"🔥 [PURCHASE] material_ids: {request_data.material_ids[:5]}...") # 처음 5개만 로그 + + update_materials_query = text(""" + UPDATE materials + SET purchase_confirmed = true, + purchase_confirmed_at = NOW(), + purchase_confirmed_by = :confirmed_by + WHERE id = ANY(:material_ids) + """) + + result = db.execute(update_materials_query, { + "material_ids": request_data.material_ids, + "confirmed_by": current_user.get("username", "system") + }) + + print(f"🔥 [PURCHASE] UPDATE 결과: {result.rowcount}개 행 업데이트됨") + logger.info(f"✅ {len(request_data.material_ids)}개 자재의 purchase_confirmed를 true로 업데이트") + else: + print(f"⚠️ [PURCHASE] material_ids가 비어있음!") + db.commit() logger.info(f"Purchase request created: {request_no} with {inserted_count} materials (out of {len(request_data.material_ids)} requested)") @@ -150,6 +192,20 @@ async def create_purchase_request( verified_count = db.execute(verify_query, {"request_id": request_id}).fetchone().count logger.info(f"✅ DB 검증: purchase_request_items에 {verified_count}개 저장됨") + # purchase_requests 테이블의 total_items 필드 업데이트 + update_total_items = text(""" + UPDATE purchase_requests + SET total_items = :total_items + WHERE request_id = :request_id + """) + db.execute(update_total_items, { + "request_id": request_id, + "total_items": verified_count + }) + db.commit() + + logger.info(f"✅ total_items 업데이트 완료: {verified_count}개") + return { "success": True, "request_no": request_no, @@ -224,7 +280,7 @@ async def get_purchase_requests( "job_no": row.job_no, "job_name": row.job_name, "category": "ALL", # 기본값 - "material_count": row.total_items or 0, + "material_count": row.item_count or 0, # 실제 자재 개수 사용 "item_count": row.item_count, "excel_file_path": None, # 현재 테이블에 없음 "requested_at": row.request_date.isoformat() if row.request_date else None, diff --git a/backend/app/routers/revision_management.py b/backend/app/routers/revision_management.py new file mode 100644 index 0000000..e6c6b97 --- /dev/null +++ b/backend/app/routers/revision_management.py @@ -0,0 +1,327 @@ +""" +간단한 리비전 관리 API +- 리비전 세션 생성 및 관리 +- 자재 비교 및 변경사항 처리 +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Optional, Dict, Any +from datetime import datetime +from pydantic import BaseModel + +from ..database import get_db +from ..auth.middleware import get_current_user +from ..services.revision_session_service import RevisionSessionService +from ..services.revision_comparison_service import RevisionComparisonService + +router = APIRouter(prefix="/revision-management", tags=["revision-management"]) + +class RevisionSessionCreate(BaseModel): + job_no: str + current_file_id: int + previous_file_id: int + +@router.post("/sessions") +async def create_revision_session( + session_data: RevisionSessionCreate, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 세션 생성""" + try: + session_service = RevisionSessionService(db) + + # 실제 DB에 세션 생성 + result = session_service.create_revision_session( + job_no=session_data.job_no, + current_file_id=session_data.current_file_id, + previous_file_id=session_data.previous_file_id, + username=current_user.get("username") + ) + + return { + "success": True, + "data": result, + "message": "리비전 세션이 생성되었습니다." + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 세션 생성 실패: {str(e)}") + +@router.get("/sessions/{session_id}") +async def get_session_status( + session_id: int, + db: Session = Depends(get_db) +): + """세션 상태 조회""" + try: + session_service = RevisionSessionService(db) + + # 실제 DB에서 세션 상태 조회 + result = session_service.get_session_status(session_id) + + return { + "success": True, + "data": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"세션 상태 조회 실패: {str(e)}") + +@router.get("/sessions/{session_id}/summary") +async def get_revision_summary( + session_id: int, + db: Session = Depends(get_db) +): + """리비전 요약 조회""" + try: + comparison_service = RevisionComparisonService(db) + + # 세션의 모든 변경사항 조회 + changes = comparison_service.get_session_changes(session_id) + + # 요약 통계 계산 + summary = { + "session_id": session_id, + "total_changes": len(changes), + "new_materials": len([c for c in changes if c['change_type'] == 'added']), + "changed_materials": len([c for c in changes if c['change_type'] == 'quantity_changed']), + "removed_materials": len([c for c in changes if c['change_type'] == 'removed']), + "categories": {} + } + + # 카테고리별 통계 + for change in changes: + category = change['category'] + if category not in summary["categories"]: + summary["categories"][category] = { + "total_changes": 0, + "added": 0, + "changed": 0, + "removed": 0 + } + + summary["categories"][category]["total_changes"] += 1 + if change['change_type'] == 'added': + summary["categories"][category]["added"] += 1 + elif change['change_type'] == 'quantity_changed': + summary["categories"][category]["changed"] += 1 + elif change['change_type'] == 'removed': + summary["categories"][category]["removed"] += 1 + + return { + "success": True, + "data": summary + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 요약 조회 실패: {str(e)}") + +@router.post("/sessions/{session_id}/compare/{category}") +async def compare_category( + session_id: int, + category: str, + db: Session = Depends(get_db) +): + """카테고리별 자재 비교""" + try: + # 세션 정보 조회 + session_service = RevisionSessionService(db) + session_status = session_service.get_session_status(session_id) + session_info = session_status["session_info"] + + # 자재 비교 수행 + comparison_service = RevisionComparisonService(db) + result = comparison_service.compare_materials_by_category( + current_file_id=session_info["current_file_id"], + previous_file_id=session_info["previous_file_id"], + category=category, + session_id=session_id + ) + + return { + "success": True, + "data": result + } + + except Exception as 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: + session_service = RevisionSessionService(db) + + # 실제 DB에서 리비전 히스토리 조회 + history = session_service.get_job_revision_history(job_no) + + return { + "success": True, + "data": { + "job_no": job_no, + "history": history + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 히스토리 조회 실패: {str(e)}") + +# 세션 변경사항 조회 +@router.get("/sessions/{session_id}/changes") +async def get_session_changes( + session_id: int, + category: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """세션의 변경사항 조회""" + try: + comparison_service = RevisionComparisonService(db) + + # 세션의 변경사항 조회 + changes = comparison_service.get_session_changes(session_id, category) + + return { + "success": True, + "data": { + "changes": changes, + "total_count": len(changes) + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"세션 변경사항 조회 실패: {str(e)}") + +# 리비전 액션 처리 +@router.post("/changes/{change_id}/process") +async def process_revision_action( + change_id: int, + action_data: Dict[str, Any], + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 액션 처리""" + try: + comparison_service = RevisionComparisonService(db) + + # 액션 처리 + result = comparison_service.process_revision_action( + change_id=change_id, + action=action_data.get("action"), + username=current_user.get("username"), + notes=action_data.get("notes") + ) + + return { + "success": True, + "data": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 액션 처리 실패: {str(e)}") + +# 세션 완료 +@router.post("/sessions/{session_id}/complete") +async def complete_revision_session( + session_id: int, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 세션 완료""" + try: + session_service = RevisionSessionService(db) + + # 세션 완료 처리 + result = session_service.complete_session( + session_id=session_id, + username=current_user.get("username") + ) + + return { + "success": True, + "data": result + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 세션 완료 실패: {str(e)}") + +# 세션 취소 +@router.post("/sessions/{session_id}/cancel") +async def cancel_revision_session( + session_id: int, + reason: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """리비전 세션 취소""" + try: + session_service = RevisionSessionService(db) + + # 세션 취소 처리 + result = session_service.cancel_session( + session_id=session_id, + username=current_user.get("username"), + reason=reason + ) + + return { + "success": True, + "data": {"cancelled": result} + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 세션 취소 실패: {str(e)}") + +@router.get("/categories") +async def get_supported_categories(): + """지원 카테고리 목록 조회""" + try: + categories = [ + {"key": "PIPE", "name": "배관", "description": "파이프 및 배관 자재"}, + {"key": "FITTING", "name": "피팅", "description": "배관 연결 부품"}, + {"key": "FLANGE", "name": "플랜지", "description": "플랜지 및 연결 부품"}, + {"key": "VALVE", "name": "밸브", "description": "각종 밸브류"}, + {"key": "GASKET", "name": "가스켓", "description": "씰링 부품"}, + {"key": "BOLT", "name": "볼트", "description": "체결 부품"}, + {"key": "SUPPORT", "name": "서포트", "description": "지지대 및 구조물"}, + {"key": "SPECIAL", "name": "특수자재", "description": "특수 목적 자재"}, + {"key": "UNCLASSIFIED", "name": "미분류", "description": "분류되지 않은 자재"} + ] + + return { + "success": True, + "data": { + "categories": categories + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"지원 카테고리 조회 실패: {str(e)}") + +@router.get("/actions") +async def get_supported_actions(): + """지원 액션 목록 조회""" + try: + actions = [ + {"key": "new_material", "name": "신규 자재", "description": "새로 추가된 자재"}, + {"key": "additional_purchase", "name": "추가 구매", "description": "구매된 자재의 수량 증가"}, + {"key": "inventory_transfer", "name": "재고 이관", "description": "구매된 자재의 수량 감소 또는 제거"}, + {"key": "purchase_cancel", "name": "구매 취소", "description": "미구매 자재의 제거"}, + {"key": "quantity_update", "name": "수량 업데이트", "description": "미구매 자재의 수량 변경"}, + {"key": "maintain", "name": "유지", "description": "변경사항 없음"} + ] + + return { + "success": True, + "data": { + "actions": actions + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"지원 액션 조회 실패: {str(e)}") \ No newline at end of file diff --git a/backend/app/services/revision_comparison_service.py b/backend/app/services/revision_comparison_service.py new file mode 100644 index 0000000..e827418 --- /dev/null +++ b/backend/app/services/revision_comparison_service.py @@ -0,0 +1,457 @@ +""" +리비전 비교 및 변경 처리 서비스 +- 자재 비교 로직 (구매된/미구매 자재 구분) +- 리비전 액션 결정 (추가구매, 재고이관, 수량변경 등) +- GASKET/BOLT 특별 처리 (BOM 원본 수량 기준) +""" + +import logging +from typing import Dict, List, Optional, Any, Tuple +from decimal import Decimal +from sqlalchemy.orm import Session +from sqlalchemy import text, and_, or_ + +from ..models import Material + +logger = logging.getLogger(__name__) + + +class RevisionComparisonService: + """리비전 비교 및 변경 처리 서비스""" + + def __init__(self, db: Session): + self.db = db + + def compare_materials_by_category( + self, + current_file_id: int, + previous_file_id: int, + category: str, + session_id: int + ) -> Dict[str, Any]: + """카테고리별 자재 비교 및 변경사항 기록""" + + try: + logger.info(f"카테고리 {category} 자재 비교 시작 (현재: {current_file_id}, 이전: {previous_file_id})") + + # 현재 파일의 자재 조회 + current_materials = self._get_materials_by_category(current_file_id, category) + previous_materials = self._get_materials_by_category(previous_file_id, category) + + logger.info(f"현재 자재: {len(current_materials)}개, 이전 자재: {len(previous_materials)}개") + + # 자재 그룹화 (동일 자재 식별) + current_grouped = self._group_materials_by_key(current_materials, category) + previous_grouped = self._group_materials_by_key(previous_materials, category) + + # 비교 결과 저장 + comparison_results = { + "added": [], + "removed": [], + "changed": [], + "unchanged": [] + } + + # 현재 자재 기준으로 비교 + for key, current_group in current_grouped.items(): + if key in previous_grouped: + previous_group = previous_grouped[key] + + # 수량 비교 (GASKET/BOLT는 BOM 원본 수량 기준) + current_qty = self._get_comparison_quantity(current_group, category) + previous_qty = self._get_comparison_quantity(previous_group, category) + + if current_qty != previous_qty: + # 수량 변경됨 + change_record = self._create_change_record( + current_group, previous_group, "quantity_changed", + current_qty, previous_qty, category, session_id + ) + comparison_results["changed"].append(change_record) + else: + # 수량 동일 + unchanged_record = self._create_change_record( + current_group, previous_group, "unchanged", + current_qty, previous_qty, category, session_id + ) + comparison_results["unchanged"].append(unchanged_record) + else: + # 새로 추가된 자재 + current_qty = self._get_comparison_quantity(current_group, category) + added_record = self._create_change_record( + current_group, None, "added", + current_qty, 0, category, session_id + ) + comparison_results["added"].append(added_record) + + # 제거된 자재 확인 + for key, previous_group in previous_grouped.items(): + if key not in current_grouped: + previous_qty = self._get_comparison_quantity(previous_group, category) + removed_record = self._create_change_record( + None, previous_group, "removed", + 0, previous_qty, category, session_id + ) + comparison_results["removed"].append(removed_record) + + # DB에 변경사항 저장 + self._save_material_changes(comparison_results, session_id) + + # 통계 정보 + summary = { + "category": category, + "added_count": len(comparison_results["added"]), + "removed_count": len(comparison_results["removed"]), + "changed_count": len(comparison_results["changed"]), + "unchanged_count": len(comparison_results["unchanged"]), + "total_changes": len(comparison_results["added"]) + len(comparison_results["removed"]) + len(comparison_results["changed"]) + } + + logger.info(f"카테고리 {category} 비교 완료: {summary}") + + return { + "summary": summary, + "changes": comparison_results + } + + except Exception as e: + logger.error(f"카테고리 {category} 자재 비교 실패: {e}") + raise + + def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]: + """파일의 특정 카테고리 자재 조회""" + + return self.db.query(Material).filter( + and_( + Material.file_id == file_id, + Material.classified_category == category, + Material.is_active == True + ) + ).all() + + def _group_materials_by_key(self, materials: List[Material], category: str) -> Dict[str, Dict]: + """자재를 고유 키로 그룹화""" + + grouped = {} + + for material in materials: + # 카테고리별 고유 키 생성 전략 + if category == "PIPE": + # PIPE: description + material_grade + main_nom + key_parts = [ + material.original_description.strip().upper(), + material.material_grade or '', + material.main_nom or '' + ] + elif category in ["GASKET", "BOLT"]: + # GASKET/BOLT: description + main_nom (집계 공식 적용 전 원본 기준) + key_parts = [ + material.original_description.strip().upper(), + material.main_nom or '' + ] + else: + # 기타: description + drawing + main_nom + red_nom + key_parts = [ + material.original_description.strip().upper(), + material.drawing_name or '', + material.main_nom or '', + material.red_nom or '' + ] + + key = "|".join(key_parts) + + if key in grouped: + # 동일한 자재가 있으면 수량 합산 + grouped[key]['total_quantity'] += float(material.quantity) + grouped[key]['materials'].append(material) + + # 구매 확정 상태 확인 (하나라도 구매 확정되면 전체가 구매 확정) + if getattr(material, 'purchase_confirmed', False): + grouped[key]['purchase_confirmed'] = True + grouped[key]['purchase_confirmed_at'] = getattr(material, 'purchase_confirmed_at', None) + + else: + grouped[key] = { + 'key': key, + 'representative_material': material, + 'materials': [material], + 'total_quantity': float(material.quantity), + 'purchase_confirmed': getattr(material, 'purchase_confirmed', False), + 'purchase_confirmed_at': getattr(material, 'purchase_confirmed_at', None), + 'category': category + } + + return grouped + + def _get_comparison_quantity(self, material_group: Dict, category: str) -> Decimal: + """비교용 수량 계산 (GASKET/BOLT는 집계 공식 적용 전 원본 수량)""" + + if category in ["GASKET", "BOLT"]: + # GASKET/BOLT: BOM 원본 수량 기준 (집계 공식 적용 전) + # 실제 BOM에서 읽은 원본 수량을 사용 + original_quantity = 0 + for material in material_group['materials']: + # classification_details에서 원본 수량 추출 시도 + details = getattr(material, 'classification_details', {}) + if isinstance(details, dict) and 'original_quantity' in details: + original_quantity += float(details['original_quantity']) + else: + # 원본 수량 정보가 없으면 현재 수량 사용 + original_quantity += float(material.quantity) + + return Decimal(str(original_quantity)) + else: + # 기타 카테고리: 현재 수량 사용 + return Decimal(str(material_group['total_quantity'])) + + def _create_change_record( + self, + current_group: Optional[Dict], + previous_group: Optional[Dict], + change_type: str, + current_qty: Decimal, + previous_qty: Decimal, + category: str, + session_id: int + ) -> Dict[str, Any]: + """변경 기록 생성""" + + # 대표 자재 정보 + if current_group: + material = current_group['representative_material'] + material_id = material.id + description = material.original_description + purchase_status = 'purchased' if current_group['purchase_confirmed'] else 'not_purchased' + purchase_confirmed_at = current_group.get('purchase_confirmed_at') + else: + material = previous_group['representative_material'] + material_id = None # 제거된 자재는 현재 material_id가 없음 + description = material.original_description + purchase_status = 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased' + purchase_confirmed_at = previous_group.get('purchase_confirmed_at') + + # 리비전 액션 결정 + revision_action = self._determine_revision_action( + change_type, current_qty, previous_qty, purchase_status, category + ) + + return { + "session_id": session_id, + "material_id": material_id, + "previous_material_id": material.id if previous_group else None, + "material_description": description, + "category": category, + "change_type": change_type, + "current_quantity": float(current_qty), + "previous_quantity": float(previous_qty), + "quantity_difference": float(current_qty - previous_qty), + "purchase_status": purchase_status, + "purchase_confirmed_at": purchase_confirmed_at, + "revision_action": revision_action + } + + def _determine_revision_action( + self, + change_type: str, + current_qty: Decimal, + previous_qty: Decimal, + purchase_status: str, + category: str + ) -> str: + """리비전 액션 결정 로직""" + + if change_type == "added": + return "new_material" + elif change_type == "removed": + if purchase_status == "purchased": + return "inventory_transfer" # 구매된 자재 → 재고 이관 + else: + return "purchase_cancel" # 미구매 자재 → 구매 취소 + elif change_type == "quantity_changed": + quantity_diff = current_qty - previous_qty + + if purchase_status == "purchased": + if quantity_diff > 0: + return "additional_purchase" # 구매된 자재 수량 증가 → 추가 구매 + else: + return "inventory_transfer" # 구매된 자재 수량 감소 → 재고 이관 + else: + return "quantity_update" # 미구매 자재 → 수량 업데이트 + else: + return "maintain" # 변경 없음 + + def _save_material_changes(self, comparison_results: Dict, session_id: int): + """변경사항을 DB에 저장""" + + try: + all_changes = [] + for change_type, changes in comparison_results.items(): + all_changes.extend(changes) + + if not all_changes: + return + + # 배치 삽입 + insert_query = """ + INSERT INTO revision_material_changes ( + session_id, material_id, previous_material_id, material_description, + category, change_type, current_quantity, previous_quantity, + quantity_difference, purchase_status, purchase_confirmed_at, revision_action + ) VALUES ( + :session_id, :material_id, :previous_material_id, :material_description, + :category, :change_type, :current_quantity, :previous_quantity, + :quantity_difference, :purchase_status, :purchase_confirmed_at, :revision_action + ) + """ + + self.db.execute(text(insert_query), all_changes) + self.db.commit() + + logger.info(f"변경사항 {len(all_changes)}건 저장 완료 (세션: {session_id})") + + except Exception as e: + self.db.rollback() + logger.error(f"변경사항 저장 실패: {e}") + raise + + def get_session_changes(self, session_id: int, category: str = None) -> List[Dict[str, Any]]: + """세션의 변경사항 조회""" + + try: + query = """ + SELECT + id, material_id, material_description, category, + change_type, current_quantity, previous_quantity, quantity_difference, + purchase_status, revision_action, action_status, + processed_by, processed_at, processing_notes + FROM revision_material_changes + WHERE session_id = :session_id + """ + params = {"session_id": session_id} + + if category: + query += " AND category = :category" + params["category"] = category + + query += " ORDER BY category, material_description" + + changes = self.db.execute(text(query), params).fetchall() + + return [dict(change._mapping) for change in changes] + + except Exception as e: + logger.error(f"세션 변경사항 조회 실패: {e}") + raise + + def process_revision_action( + self, + change_id: int, + action: str, + username: str, + notes: str = None + ) -> Dict[str, Any]: + """리비전 액션 처리""" + + try: + # 변경사항 조회 + change = self.db.execute(text(""" + SELECT * FROM revision_material_changes WHERE id = :change_id + """), {"change_id": change_id}).fetchone() + + if not change: + raise ValueError(f"변경사항을 찾을 수 없습니다: {change_id}") + + result = {"success": False, "message": ""} + + # 액션별 처리 + if action == "additional_purchase": + result = self._process_additional_purchase(change, username, notes) + elif action == "inventory_transfer": + result = self._process_inventory_transfer(change, username, notes) + elif action == "purchase_cancel": + result = self._process_purchase_cancel(change, username, notes) + elif action == "quantity_update": + result = self._process_quantity_update(change, username, notes) + else: + result = {"success": True, "message": "처리 완료"} + + # 처리 상태 업데이트 + status = "completed" if result["success"] else "failed" + self.db.execute(text(""" + UPDATE revision_material_changes + SET action_status = :status, processed_by = :username, + processed_at = CURRENT_TIMESTAMP, processing_notes = :notes + WHERE id = :change_id + """), { + "change_id": change_id, + "status": status, + "username": username, + "notes": notes or result["message"] + }) + + # 액션 로그 기록 + self.db.execute(text(""" + INSERT INTO revision_action_logs ( + session_id, revision_change_id, action_type, action_description, + executed_by, result, result_message + ) VALUES ( + :session_id, :change_id, :action, :description, + :username, :result, :message + ) + """), { + "session_id": change.session_id, + "change_id": change_id, + "action": action, + "description": f"{change.material_description} - {action}", + "username": username, + "result": "success" if result["success"] else "failed", + "message": result["message"] + }) + + self.db.commit() + + return result + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 액션 처리 실패: {e}") + raise + + def _process_additional_purchase(self, change, username: str, notes: str) -> Dict[str, Any]: + """추가 구매 처리""" + # 구매 요청 생성 로직 구현 + return {"success": True, "message": f"추가 구매 요청 생성: {change.quantity_difference}개"} + + def _process_inventory_transfer(self, change, username: str, notes: str) -> Dict[str, Any]: + """재고 이관 처리""" + # 재고 이관 로직 구현 + try: + self.db.execute(text(""" + INSERT INTO inventory_transfers ( + revision_change_id, material_description, category, + quantity, unit, transferred_by, storage_notes + ) VALUES ( + :change_id, :description, :category, + :quantity, 'EA', :username, :notes + ) + """), { + "change_id": change.id, + "description": change.material_description, + "category": change.category, + "quantity": abs(change.quantity_difference), + "username": username, + "notes": notes + }) + + return {"success": True, "message": f"재고 이관 완료: {abs(change.quantity_difference)}개"} + + except Exception as e: + return {"success": False, "message": f"재고 이관 실패: {str(e)}"} + + def _process_purchase_cancel(self, change, username: str, notes: str) -> Dict[str, Any]: + """구매 취소 처리""" + return {"success": True, "message": "구매 취소 완료"} + + def _process_quantity_update(self, change, username: str, notes: str) -> Dict[str, Any]: + """수량 업데이트 처리""" + return {"success": True, "message": f"수량 업데이트 완료: {change.current_quantity}개"} diff --git a/backend/app/services/revision_session_service.py b/backend/app/services/revision_session_service.py new file mode 100644 index 0000000..20b45dd --- /dev/null +++ b/backend/app/services/revision_session_service.py @@ -0,0 +1,289 @@ +""" +리비전 세션 관리 서비스 +- 리비전 세션 생성, 관리, 완료 처리 +- 자재 변경 사항 추적 및 처리 +""" + +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime +from sqlalchemy.orm import Session +from sqlalchemy import text, and_, or_ + +from ..models import File, Material +from ..database import get_db + +logger = logging.getLogger(__name__) + + +class RevisionSessionService: + """리비전 세션 관리 서비스""" + + def __init__(self, db: Session): + self.db = db + + def create_revision_session( + self, + job_no: str, + current_file_id: int, + previous_file_id: int, + username: str + ) -> Dict[str, Any]: + """새로운 리비전 세션 생성""" + + try: + # 파일 정보 조회 + current_file = self.db.query(File).filter(File.id == current_file_id).first() + previous_file = self.db.query(File).filter(File.id == previous_file_id).first() + + if not current_file or not previous_file: + raise ValueError("파일 정보를 찾을 수 없습니다") + + # 기존 진행 중인 세션이 있는지 확인 + existing_session = self.db.execute(text(""" + SELECT id FROM revision_sessions + WHERE job_no = :job_no AND status = 'processing' + """), {"job_no": job_no}).fetchone() + + if existing_session: + logger.warning(f"기존 진행 중인 리비전 세션이 있습니다: {existing_session[0]}") + return {"session_id": existing_session[0], "status": "existing"} + + # 새 세션 생성 + session_data = { + "job_no": job_no, + "current_file_id": current_file_id, + "previous_file_id": previous_file_id, + "current_revision": current_file.revision, + "previous_revision": previous_file.revision, + "status": "processing", + "created_by": username + } + + result = self.db.execute(text(""" + INSERT INTO revision_sessions ( + job_no, current_file_id, previous_file_id, + current_revision, previous_revision, status, created_by + ) VALUES ( + :job_no, :current_file_id, :previous_file_id, + :current_revision, :previous_revision, :status, :created_by + ) RETURNING id + """), session_data) + + session_id = result.fetchone()[0] + self.db.commit() + + logger.info(f"새 리비전 세션 생성: {session_id} (Job: {job_no})") + + return { + "session_id": session_id, + "status": "created", + "job_no": job_no, + "current_revision": current_file.revision, + "previous_revision": previous_file.revision + } + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 생성 실패: {e}") + raise + + def get_session_status(self, session_id: int) -> Dict[str, Any]: + """리비전 세션 상태 조회""" + + try: + session_info = self.db.execute(text(""" + SELECT + id, job_no, current_file_id, previous_file_id, + current_revision, previous_revision, status, + total_materials, processed_materials, + added_count, removed_count, changed_count, unchanged_count, + purchase_cancel_count, inventory_transfer_count, additional_purchase_count, + created_by, created_at, completed_at + FROM revision_sessions + WHERE id = :session_id + """), {"session_id": session_id}).fetchone() + + if not session_info: + raise ValueError(f"리비전 세션을 찾을 수 없습니다: {session_id}") + + # 변경 사항 상세 조회 + changes = self.db.execute(text(""" + SELECT + category, change_type, revision_action, action_status, + COUNT(*) as count, + SUM(CASE WHEN purchase_status = 'purchased' THEN 1 ELSE 0 END) as purchased_count, + SUM(CASE WHEN purchase_status = 'not_purchased' THEN 1 ELSE 0 END) as unpurchased_count + FROM revision_material_changes + WHERE session_id = :session_id + GROUP BY category, change_type, revision_action, action_status + ORDER BY category, change_type + """), {"session_id": session_id}).fetchall() + + return { + "session_info": dict(session_info._mapping), + "changes_summary": [dict(change._mapping) for change in changes], + "progress_percentage": ( + (session_info.processed_materials / session_info.total_materials * 100) + if session_info.total_materials > 0 else 0 + ) + } + + except Exception as e: + logger.error(f"리비전 세션 상태 조회 실패: {e}") + raise + + def update_session_progress( + self, + session_id: int, + total_materials: int = None, + processed_materials: int = None, + **counts + ) -> bool: + """리비전 세션 진행 상황 업데이트""" + + try: + update_fields = [] + update_values = {"session_id": session_id} + + if total_materials is not None: + update_fields.append("total_materials = :total_materials") + update_values["total_materials"] = total_materials + + if processed_materials is not None: + update_fields.append("processed_materials = :processed_materials") + update_values["processed_materials"] = processed_materials + + # 카운트 필드들 업데이트 + count_fields = [ + "added_count", "removed_count", "changed_count", "unchanged_count", + "purchase_cancel_count", "inventory_transfer_count", "additional_purchase_count" + ] + + for field in count_fields: + if field in counts: + update_fields.append(f"{field} = :{field}") + update_values[field] = counts[field] + + if not update_fields: + return True # 업데이트할 내용이 없음 + + query = f""" + UPDATE revision_sessions + SET {', '.join(update_fields)} + WHERE id = :session_id + """ + + self.db.execute(text(query), update_values) + self.db.commit() + + logger.info(f"리비전 세션 진행 상황 업데이트: {session_id}") + return True + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 진행 상황 업데이트 실패: {e}") + raise + + def complete_session(self, session_id: int, username: str) -> Dict[str, Any]: + """리비전 세션 완료 처리""" + + try: + # 세션 상태를 완료로 변경 + self.db.execute(text(""" + UPDATE revision_sessions + SET status = 'completed', completed_at = CURRENT_TIMESTAMP + WHERE id = :session_id AND status = 'processing' + """), {"session_id": session_id}) + + # 완료 로그 기록 + self.db.execute(text(""" + INSERT INTO revision_action_logs ( + session_id, action_type, action_description, + executed_by, result, result_message + ) VALUES ( + :session_id, 'session_complete', '리비전 세션 완료', + :username, 'success', '모든 리비전 처리 완료' + ) + """), { + "session_id": session_id, + "username": username + }) + + self.db.commit() + + # 최종 상태 조회 + final_status = self.get_session_status(session_id) + + logger.info(f"리비전 세션 완료: {session_id}") + + return { + "status": "completed", + "session_id": session_id, + "final_status": final_status + } + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 완료 처리 실패: {e}") + raise + + def cancel_session(self, session_id: int, username: str, reason: str = None) -> bool: + """리비전 세션 취소""" + + try: + # 세션 상태를 취소로 변경 + self.db.execute(text(""" + UPDATE revision_sessions + SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP + WHERE id = :session_id AND status = 'processing' + """), {"session_id": session_id}) + + # 취소 로그 기록 + self.db.execute(text(""" + INSERT INTO revision_action_logs ( + session_id, action_type, action_description, + executed_by, result, result_message + ) VALUES ( + :session_id, 'session_cancel', '리비전 세션 취소', + :username, 'cancelled', :reason + ) + """), { + "session_id": session_id, + "username": username, + "reason": reason or "사용자 요청에 의한 취소" + }) + + self.db.commit() + + logger.info(f"리비전 세션 취소: {session_id}, 사유: {reason}") + return True + + except Exception as e: + self.db.rollback() + logger.error(f"리비전 세션 취소 실패: {e}") + raise + + def get_job_revision_history(self, job_no: str) -> List[Dict[str, Any]]: + """Job의 리비전 히스토리 조회""" + + try: + sessions = self.db.execute(text(""" + SELECT + rs.id, rs.current_revision, rs.previous_revision, + rs.status, rs.created_by, rs.created_at, rs.completed_at, + rs.added_count, rs.removed_count, rs.changed_count, + cf.filename as current_filename, + pf.filename as previous_filename + FROM revision_sessions rs + LEFT JOIN files cf ON rs.current_file_id = cf.id + LEFT JOIN files pf ON rs.previous_file_id = pf.id + WHERE rs.job_no = :job_no + ORDER BY rs.created_at DESC + """), {"job_no": job_no}).fetchall() + + return [dict(session._mapping) for session in sessions] + + except Exception as e: + logger.error(f"리비전 히스토리 조회 실패: {e}") + raise diff --git a/backend/scripts/create_missing_tables.py b/backend/scripts/create_missing_tables.py index 068b122..c0b9442 100644 --- a/backend/scripts/create_missing_tables.py +++ b/backend/scripts/create_missing_tables.py @@ -189,6 +189,62 @@ def add_missing_columns(cursor): print(f"✅ material_purchase_tracking.{column_name} 컬럼 추가 완료") else: print(f"✅ material_purchase_tracking.{column_name} 컬럼이 이미 존재합니다") + + # purchase_requests 테이블에 누락된 컬럼들 확인 및 추가 + purchase_requests_columns = { + 'file_id': 'INTEGER REFERENCES files(id)', + 'category': 'VARCHAR(50)', + 'material_count': 'INTEGER DEFAULT 0', + 'excel_file_path': 'VARCHAR(500)' + } + + for column_name, column_type in purchase_requests_columns.items(): + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'purchase_requests' AND column_name = %s; + """, (column_name,)) + + if not cursor.fetchone(): + print(f"➕ purchase_requests 테이블에 {column_name} 컬럼 추가 중...") + cursor.execute(f""" + ALTER TABLE purchase_requests ADD COLUMN {column_name} {column_type}; + """) + print(f"✅ purchase_requests.{column_name} 컬럼 추가 완료") + else: + print(f"✅ purchase_requests.{column_name} 컬럼이 이미 존재합니다") + + # purchase_request_items 테이블에 누락된 컬럼들 확인 및 추가 + purchase_request_items_columns = { + 'user_requirement': 'TEXT', + 'description': 'TEXT', + 'category': 'VARCHAR(50)', + 'subcategory': 'VARCHAR(100)', + 'material_grade': 'VARCHAR(50)', + 'size_spec': 'VARCHAR(50)', + 'drawing_name': 'VARCHAR(100)', + 'notes': 'TEXT', + 'is_ordered': 'BOOLEAN DEFAULT FALSE', + 'is_received': 'BOOLEAN DEFAULT FALSE' + } + + for column_name, column_type in purchase_request_items_columns.items(): + cursor.execute(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'purchase_request_items' AND column_name = %s; + """, (column_name,)) + + if not cursor.fetchone(): + print(f"➕ purchase_request_items 테이블에 {column_name} 컬럼 추가 중...") + cursor.execute(f""" + ALTER TABLE purchase_request_items ADD COLUMN {column_name} {column_type}; + """) + print(f"✅ purchase_request_items.{column_name} 컬럼 추가 완료") + else: + print(f"✅ purchase_request_items.{column_name} 컬럼이 이미 존재합니다") + + print("✅ 모든 누락된 컬럼 추가 완료!") except Exception as e: print(f"⚠️ 컬럼 추가 실패: {e}") @@ -328,7 +384,11 @@ def create_missing_tables(): CREATE TABLE purchase_requests ( request_id SERIAL PRIMARY KEY, request_no VARCHAR(50) UNIQUE NOT NULL, + file_id INTEGER REFERENCES files(id), job_no VARCHAR(50) NOT NULL, + category VARCHAR(50), + material_count INTEGER DEFAULT 0, + excel_file_path VARCHAR(500), project_name VARCHAR(200), requested_by INTEGER REFERENCES users(user_id), requested_by_username VARCHAR(100), @@ -387,6 +447,163 @@ def create_missing_tables(): print("✅ purchase_request_items 테이블 생성 완료") else: print("✅ purchase_request_items 테이블 이미 존재") + + # 5. revision_sessions 테이블 + print("📋 5. revision_sessions 테이블 확인...") + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'revision_sessions' + ); + """) + if not cursor.fetchone()[0]: + print("➕ revision_sessions 테이블 생성 중...") + cursor.execute(""" + CREATE TABLE revision_sessions ( + id SERIAL PRIMARY KEY, + job_no VARCHAR(50) NOT NULL, + current_file_id INTEGER REFERENCES files(id), + previous_file_id INTEGER REFERENCES files(id), + current_revision VARCHAR(20) NOT NULL, + previous_revision VARCHAR(20) NOT NULL, + + status VARCHAR(20) DEFAULT 'processing', + total_materials INTEGER DEFAULT 0, + processed_materials INTEGER DEFAULT 0, + + added_count INTEGER DEFAULT 0, + removed_count INTEGER DEFAULT 0, + changed_count INTEGER DEFAULT 0, + unchanged_count INTEGER DEFAULT 0, + + purchase_cancel_count INTEGER DEFAULT 0, + inventory_transfer_count INTEGER DEFAULT 0, + additional_purchase_count INTEGER DEFAULT 0, + + created_by VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP + ); + CREATE INDEX idx_revision_sessions_job_no ON revision_sessions(job_no); + CREATE INDEX idx_revision_sessions_status ON revision_sessions(status); + """) + print("✅ revision_sessions 테이블 생성 완료") + else: + print("✅ revision_sessions 테이블 이미 존재") + + # 6. revision_material_changes 테이블 + print("📋 6. revision_material_changes 테이블 확인...") + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'revision_material_changes' + ); + """) + if not cursor.fetchone()[0]: + print("➕ revision_material_changes 테이블 생성 중...") + cursor.execute(""" + CREATE TABLE revision_material_changes ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES revision_sessions(id) ON DELETE CASCADE, + + material_id INTEGER REFERENCES materials(id), + previous_material_id INTEGER, + material_description TEXT NOT NULL, + category VARCHAR(50) NOT NULL, + + change_type VARCHAR(20) NOT NULL, + previous_quantity NUMERIC(10,3), + current_quantity NUMERIC(10,3), + quantity_difference NUMERIC(10,3), + + purchase_status VARCHAR(20) NOT NULL, + purchase_confirmed_at TIMESTAMP, + + revision_action VARCHAR(30), + action_status VARCHAR(20) DEFAULT 'pending', + + processed_by VARCHAR(100), + processed_at TIMESTAMP, + processing_notes TEXT, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_revision_changes_session ON revision_material_changes(session_id); + CREATE INDEX idx_revision_changes_action ON revision_material_changes(revision_action); + CREATE INDEX idx_revision_changes_status ON revision_material_changes(action_status); + """) + print("✅ revision_material_changes 테이블 생성 완료") + else: + print("✅ revision_material_changes 테이블 이미 존재") + + # 7. inventory_transfers 테이블 + print("📋 7. inventory_transfers 테이블 확인...") + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'inventory_transfers' + ); + """) + if not cursor.fetchone()[0]: + print("➕ inventory_transfers 테이블 생성 중...") + cursor.execute(""" + CREATE TABLE inventory_transfers ( + id SERIAL PRIMARY KEY, + revision_change_id INTEGER REFERENCES revision_material_changes(id), + + material_description TEXT NOT NULL, + category VARCHAR(50) NOT NULL, + quantity NUMERIC(10,3) NOT NULL, + unit VARCHAR(10) NOT NULL, + + inventory_location VARCHAR(100), + storage_notes TEXT, + + transferred_by VARCHAR(100) NOT NULL, + transferred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + status VARCHAR(20) DEFAULT 'transferred' + ); + CREATE INDEX idx_inventory_transfers_material ON inventory_transfers(material_description); + CREATE INDEX idx_inventory_transfers_date ON inventory_transfers(transferred_at); + """) + print("✅ inventory_transfers 테이블 생성 완료") + else: + print("✅ inventory_transfers 테이블 이미 존재") + + # 8. revision_action_logs 테이블 + print("📋 8. revision_action_logs 테이블 확인...") + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'revision_action_logs' + ); + """) + if not cursor.fetchone()[0]: + print("➕ revision_action_logs 테이블 생성 중...") + cursor.execute(""" + CREATE TABLE revision_action_logs ( + id SERIAL PRIMARY KEY, + session_id INTEGER REFERENCES revision_sessions(id), + revision_change_id INTEGER REFERENCES revision_material_changes(id), + + action_type VARCHAR(30) NOT NULL, + action_description TEXT, + + executed_by VARCHAR(100) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + result VARCHAR(20) NOT NULL, + result_message TEXT, + result_data JSONB + ); + CREATE INDEX idx_revision_logs_session ON revision_action_logs(session_id); + CREATE INDEX idx_revision_logs_type ON revision_action_logs(action_type); + CREATE INDEX idx_revision_logs_date ON revision_action_logs(executed_at); + """) + print("✅ revision_action_logs 테이블 생성 완료") + else: + print("✅ revision_action_logs 테이블 이미 존재") # 변경사항 커밋 conn.commit() diff --git a/frontend/src/components/bom/tabs/BOMFilesTab.jsx b/frontend/src/components/bom/tabs/BOMFilesTab.jsx index 283cc17..d45e004 100644 --- a/frontend/src/components/bom/tabs/BOMFilesTab.jsx +++ b/frontend/src/components/bom/tabs/BOMFilesTab.jsx @@ -12,36 +12,38 @@ const BOMFilesTab = ({ }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [revisionDialog, setRevisionDialog] = useState({ open: false, file: null }); const [groupedFiles, setGroupedFiles] = useState({}); + // BOM 파일 목록 로드 함수 + const loadBOMFiles = async () => { + if (!selectedProject) return; + + try { + setLoading(true); + setError(''); + + 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); + + // BOM 이름별로 그룹화 + const groups = groupFilesByBOM(files); + setGroupedFiles(groups); + + } catch (err) { + console.error('BOM 파일 로드 실패:', err); + setError('BOM 파일을 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + // BOM 파일 목록 로드 useEffect(() => { - const loadBOMFiles = async () => { - if (!selectedProject) return; - - try { - setLoading(true); - setError(''); - - 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); - - // BOM 이름별로 그룹화 - const groups = groupFilesByBOM(files); - setGroupedFiles(groups); - - } catch (err) { - console.error('BOM 파일 로드 실패:', err); - setError('BOM 파일을 불러오는데 실패했습니다.'); - } finally { - setLoading(false); - } - }; - loadBOMFiles(); }, [selectedProject, refreshTrigger, setBomFiles]); @@ -99,10 +101,48 @@ const BOMFilesTab = ({ } }; - // 리비전 업로드 (향후 구현) + // 리비전 업로드 const handleRevisionUpload = (parentFile) => { - // TODO: 리비전 업로드 기능 구현 - alert('리비전 업로드 기능은 향후 구현 예정입니다.'); + setRevisionDialog({ + open: true, + file: parentFile + }); + }; + + // 리비전 업로드 성공 핸들러 + const handleRevisionUploadSuccess = () => { + setRevisionDialog({ open: false, file: null }); + // BOM 파일 목록 새로고침 + loadBOMFiles(); + }; + + // 파일 업로드 처리 + const handleFileUpload = async (file) => { + if (!file || !revisionDialog.file) return; + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('job_no', selectedProject.job_no); + formData.append('parent_file_id', revisionDialog.file.id); + formData.append('bom_name', revisionDialog.file.bom_name || revisionDialog.file.original_filename); + + const response = await api.post('/files/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + if (response.data.success) { + alert(`리비전 업로드 성공! ${response.data.revision}`); + handleRevisionUploadSuccess(); + } else { + alert(response.data.message || '리비전 업로드에 실패했습니다.'); + } + } catch (error) { + console.error('리비전 업로드 실패:', error); + alert('리비전 업로드에 실패했습니다.'); + } }; // 날짜 포맷팅 @@ -422,6 +462,73 @@ const BOMFilesTab = ({ + + {/* 리비전 업로드 다이얼로그 */} + {revisionDialog.open && ( +
+ Job: {jobNo} | 세션 ID: {currentSession.session_id} +
+