from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.orm import Session from sqlalchemy import text from typing import List, Optional import os import shutil from datetime import datetime import uuid import pandas as pd import re import json from pathlib import Path from ..database import get_db from app.services.material_classifier import classify_material from app.services.bolt_classifier import classify_bolt from app.services.flange_classifier import classify_flange from app.services.fitting_classifier import classify_fitting from app.services.gasket_classifier import classify_gasket from app.services.instrument_classifier import classify_instrument from app.services.pipe_classifier import classify_pipe from app.services.valve_classifier import classify_valve router = APIRouter() UPLOAD_DIR = Path("uploads") UPLOAD_DIR.mkdir(exist_ok=True) ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} @router.get("/") async def get_files_info(): return { "message": "파일 관리 API", "allowed_extensions": list(ALLOWED_EXTENSIONS), "upload_directory": str(UPLOAD_DIR) } @router.get("/test") async def test_endpoint(): return {"status": "파일 API가 정상 작동합니다!"} @router.post("/add-missing-columns") async def add_missing_columns(db: Session = Depends(get_db)): """누락된 컬럼들 추가""" try: db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0")) db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER")) db.commit() return { "success": True, "message": "누락된 컬럼들이 추가되었습니다", "added_columns": ["files.parsed_count", "materials.row_number"] } except Exception as e: db.rollback() return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"} def validate_file_extension(filename: str) -> bool: return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS 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}" def parse_dataframe(df): df = df.dropna(how='all') # 원본 컬럼명 유지 (소문자 변환하지 않음) df.columns = df.columns.str.strip() 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: # 대소문자 구분 없이 매핑 for col in df.columns: if possible_name.lower() == col.lower(): mapped_columns[standard_col] = col break if standard_col in mapped_columns: break print(f"찾은 컬럼 매핑: {mapped_columns}") materials = [] for index, row in df.iterrows(): description = str(row.get(mapped_columns.get('description', ''), '')) quantity_raw = row.get(mapped_columns.get('quantity', ''), 0) try: quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0 except: quantity = 0 # 길이 정보 파싱 length_raw = row.get(mapped_columns.get('length', ''), None) length_value = None if pd.notna(length_raw) and length_raw != '': try: length_value = float(length_raw) except: length_value = None material_grade = "" if "ASTM" in description.upper(): astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper()) if astm_match: material_grade = astm_match.group(0).strip() main_size = str(row.get(mapped_columns.get('main_size', ''), '')) red_size = str(row.get(mapped_columns.get('red_size', ''), '')) if main_size != 'nan' and red_size != 'nan' and red_size != '': size_spec = f"{main_size} x {red_size}" elif main_size != 'nan' and main_size != '': size_spec = main_size else: size_spec = "" if description and description not in ['nan', 'None', '']: materials.append({ 'original_description': description, 'quantity': quantity, 'unit': "EA", 'size_spec': size_spec, 'material_grade': material_grade, 'length': length_value, 'line_number': index + 1, '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)}") @router.post("/upload") async def upload_file( file: UploadFile = File(...), project_id: int = Form(...), revision: str = Form("Rev.0"), db: Session = Depends(get_db) ): if not validate_file_extension(str(file.filename)): raise HTTPException( status_code=400, detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}" ) if file.size and file.size > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다") unique_filename = generate_unique_filename(str(file.filename)) file_path = UPLOAD_DIR / unique_filename try: with open(file_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) except Exception as e: raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}") try: materials_data = parse_file_data(str(file_path)) parsed_count = len(materials_data) # 파일 정보 저장 file_insert_query = text(""" INSERT INTO files (filename, original_filename, file_path, project_id, revision, description, file_size, parsed_count, is_active) VALUES (:filename, :original_filename, :file_path, :project_id, :revision, :description, :file_size, :parsed_count, :is_active) RETURNING id """) file_result = db.execute(file_insert_query, { "filename": unique_filename, "original_filename": file.filename, "file_path": str(file_path), "project_id": project_id, "revision": revision, "description": f"BOM 파일 - {parsed_count}개 자재", "file_size": file.size, "parsed_count": parsed_count, "is_active": True }) file_id = file_result.fetchone()[0] # 자재 데이터 저장 (분류 포함) materials_inserted = 0 for material_data in materials_data: # 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등) description = material_data["original_description"] size_spec = material_data["size_spec"] # 각 분류기로 시도 (올바른 매개변수 사용) print(f"분류 시도: {description}") # 분류기 호출 시 타임아웃 및 예외 처리 classification_result = None try: # 파이프 분류기 호출 시 length 매개변수 전달 length_value = None if 'length' in material_data: try: length_value = float(material_data['length']) except: length_value = None # None이면 0.0으로 대체 if length_value is None: length_value = 0.0 # 타임아웃 설정 (10초) import signal def timeout_handler(signum, frame): raise TimeoutError("분류기 실행 시간 초과") signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(10) # 10초 타임아웃 try: classification_result = classify_pipe("", description, size_spec, length_value) print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") finally: signal.alarm(0) # 타임아웃 해제 if classification_result.get("overall_confidence", 0) < 0.5: signal.alarm(10) try: classification_result = classify_fitting("", description, size_spec) print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") finally: signal.alarm(0) if classification_result.get("overall_confidence", 0) < 0.5: signal.alarm(10) try: classification_result = classify_valve("", description, size_spec) print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") finally: signal.alarm(0) if classification_result.get("overall_confidence", 0) < 0.5: signal.alarm(10) try: classification_result = classify_flange("", description, size_spec) print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") finally: signal.alarm(0) if classification_result.get("overall_confidence", 0) < 0.5: signal.alarm(10) try: classification_result = classify_bolt("", description, size_spec) print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") finally: signal.alarm(0) if classification_result.get("overall_confidence", 0) < 0.5: signal.alarm(10) try: classification_result = classify_gasket("", description, size_spec) print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") finally: signal.alarm(0) if classification_result.get("overall_confidence", 0) < 0.5: signal.alarm(10) try: classification_result = classify_instrument("", description, size_spec) print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") finally: signal.alarm(0) except (TimeoutError, Exception) as e: print(f"분류기 실행 중 오류 발생: {e}") # 기본 분류 결과 생성 classification_result = { "category": "UNKNOWN", "overall_confidence": 0.0, "reason": f"분류기 오류: {str(e)}" } print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") # 분류 결과에서 상세 정보 추출 if classification_result.get('category') == 'PIPE': classification_details = classification_result elif classification_result.get('category') == 'FITTING': classification_details = classification_result elif classification_result.get('category') == 'VALVE': classification_details = classification_result else: classification_details = {} # DB에 저장 시 JSON 직렬화 classification_details = json.dumps(classification_details, ensure_ascii=False) # 디버깅: 저장 직전 데이터 확인 print(f"=== 자재[{materials_inserted + 1}] 저장 직전 ===") print(f"자재명: {material_data['original_description']}") print(f"분류결과: {classification_result.get('category')}") print(f"신뢰도: {classification_result.get('overall_confidence', 0)}") print(f"classification_details 길이: {len(classification_details)}") print(f"classification_details 샘플: {classification_details[:200]}...") print("=" * 50) material_insert_query = text(""" INSERT INTO materials ( file_id, original_description, quantity, unit, size_spec, material_grade, line_number, row_number, classified_category, classification_confidence, classification_details, is_verified, created_at ) VALUES ( :file_id, :original_description, :quantity, :unit, :size_spec, :material_grade, :line_number, :row_number, :classified_category, :classification_confidence, :classification_details, :is_verified, :created_at ) """) db.execute(material_insert_query, { "file_id": file_id, "original_description": material_data["original_description"], "quantity": material_data["quantity"], "unit": material_data["unit"], "size_spec": material_data["size_spec"], "material_grade": material_data["material_grade"], "line_number": material_data["line_number"], "row_number": material_data["row_number"], "classified_category": classification_result.get("category", "UNKNOWN"), "classification_confidence": classification_result.get("overall_confidence", 0.0), "classification_details": classification_details, "is_verified": False, "created_at": datetime.now() }) # 각 카테고리별로 상세 테이블에 저장 category = classification_result.get('category') confidence = classification_result.get('overall_confidence', 0) if category == 'PIPE' and confidence >= 0.5: try: # 분류 결과에서 파이프 상세 정보 추출 pipe_info = classification_result # cutting_dimensions에서 length 정보 가져오기 cutting_dims = pipe_info.get('cutting_dimensions', {}) length_mm = cutting_dims.get('length_mm') # length_mm가 없으면 원본 데이터의 length 사용 if not length_mm and material_data.get('length'): length_mm = material_data['length'] pipe_insert_query = text(""" INSERT INTO pipe_details ( material_id, file_id, outer_diameter, schedule, material_spec, manufacturing_method, length_mm ) VALUES ( (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), :file_id, :outer_diameter, :schedule, :material_spec, :manufacturing_method, :length_mm ) """) db.execute(pipe_insert_query, { "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], "outer_diameter": pipe_info.get('nominal_diameter', ''), "schedule": pipe_info.get('schedule', ''), "material_spec": pipe_info.get('material_spec', ''), "manufacturing_method": pipe_info.get('manufacturing_method', ''), "length_mm": length_mm, }) print(f"PIPE 상세정보 저장 완료: {material_data['original_description']}") except Exception as e: print(f"PIPE 상세정보 저장 실패: {e}") # 에러가 발생해도 전체 프로세스는 계속 진행 elif category == 'FITTING' and confidence >= 0.5: try: fitting_info = classification_result fitting_insert_query = text(""" INSERT INTO fitting_details ( material_id, file_id, fitting_type, fitting_subtype, connection_method, connection_code, pressure_rating, max_pressure, manufacturing_method, material_standard, material_grade, material_type, main_size, reduced_size, classification_confidence, additional_info ) VALUES ( (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), :file_id, :fitting_type, :fitting_subtype, :connection_method, :connection_code, :pressure_rating, :max_pressure, :manufacturing_method, :material_standard, :material_grade, :material_type, :main_size, :reduced_size, :classification_confidence, :additional_info ) """) db.execute(fitting_insert_query, { "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], "fitting_type": fitting_info.get('fitting_type', {}).get('type', ''), "fitting_subtype": fitting_info.get('fitting_type', {}).get('subtype', ''), "connection_method": fitting_info.get('connection_method', {}).get('method', ''), "connection_code": fitting_info.get('connection_method', {}).get('matched_code', ''), "pressure_rating": fitting_info.get('pressure_rating', {}).get('rating', ''), "max_pressure": fitting_info.get('pressure_rating', {}).get('max_pressure', ''), "manufacturing_method": fitting_info.get('manufacturing', {}).get('method', ''), "material_standard": fitting_info.get('material', {}).get('standard', ''), "material_grade": fitting_info.get('material', {}).get('grade', ''), "material_type": fitting_info.get('material', {}).get('material_type', ''), "main_size": fitting_info.get('size_info', {}).get('main_size', ''), "reduced_size": fitting_info.get('size_info', {}).get('reduced_size', ''), "classification_confidence": confidence, "additional_info": json.dumps(fitting_info, ensure_ascii=False) }) print(f"FITTING 상세정보 저장 완료: {material_data['original_description']}") except Exception as e: print(f"FITTING 상세정보 저장 실패: {e}") elif category == 'VALVE' and confidence >= 0.5: try: valve_info = classification_result valve_insert_query = text(""" INSERT INTO valve_details ( material_id, file_id, valve_type, valve_subtype, actuator_type, connection_method, pressure_rating, pressure_class, body_material, trim_material, size_inches, fire_safe, low_temp_service, classification_confidence, additional_info ) VALUES ( (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), :file_id, :valve_type, :valve_subtype, :actuator_type, :connection_method, :pressure_rating, :pressure_class, :body_material, :trim_material, :size_inches, :fire_safe, :low_temp_service, :classification_confidence, :additional_info ) """) db.execute(valve_insert_query, { "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], "valve_type": valve_info.get('valve_type', ''), "valve_subtype": valve_info.get('valve_subtype', ''), "actuator_type": valve_info.get('actuator_type', ''), "connection_method": valve_info.get('connection_method', ''), "pressure_rating": valve_info.get('pressure_rating', ''), "pressure_class": valve_info.get('pressure_class', ''), "body_material": valve_info.get('body_material', ''), "trim_material": valve_info.get('trim_material', ''), "size_inches": valve_info.get('size', ''), "fire_safe": valve_info.get('fire_safe', False), "low_temp_service": valve_info.get('low_temp_service', False), "classification_confidence": confidence, "additional_info": json.dumps(valve_info, ensure_ascii=False) }) print(f"VALVE 상세정보 저장 완료: {material_data['original_description']}") except Exception as e: print(f"VALVE 상세정보 저장 실패: {e}") elif category == 'FLANGE' and confidence >= 0.5: try: flange_info = classification_result flange_insert_query = text(""" INSERT INTO flange_details ( material_id, file_id, flange_type, facing_type, pressure_rating, material_standard, material_grade, size_inches, classification_confidence, additional_info ) VALUES ( (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), :file_id, :flange_type, :facing_type, :pressure_rating, :material_standard, :material_grade, :size_inches, :classification_confidence, :additional_info ) """) db.execute(flange_insert_query, { "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], "flange_type": flange_info.get('flange_type', {}).get('type', ''), "facing_type": flange_info.get('face_finish', {}).get('finish', ''), "pressure_rating": flange_info.get('pressure_rating', {}).get('rating', ''), "material_standard": flange_info.get('material', {}).get('standard', ''), "material_grade": flange_info.get('material', {}).get('grade', ''), "size_inches": material_data.get('size_spec', ''), "classification_confidence": confidence, "additional_info": json.dumps(flange_info, ensure_ascii=False) }) print(f"FLANGE 상세정보 저장 완료: {material_data['original_description']}") except Exception as e: print(f"FLANGE 상세정보 저장 실패: {e}") elif category == 'BOLT' and confidence >= 0.5: try: bolt_info = classification_result bolt_insert_query = text(""" INSERT INTO bolt_details ( material_id, file_id, bolt_type, thread_type, diameter, length, material_standard, material_grade, coating_type, includes_nut, includes_washer, classification_confidence, additional_info ) VALUES ( (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), :file_id, :bolt_type, :thread_type, :diameter, :length, :material_standard, :material_grade, :coating_type, :includes_nut, :includes_washer, :classification_confidence, :additional_info ) """) # BOLT 분류기 결과 구조에 맞게 데이터 추출 bolt_details = bolt_info.get('bolt_details', {}) material_info = bolt_info.get('material', {}) db.execute(bolt_insert_query, { "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], "bolt_type": bolt_details.get('type', ''), "thread_type": bolt_details.get('thread_type', ''), "diameter": bolt_details.get('diameter', ''), "length": bolt_details.get('length', ''), "material_standard": material_info.get('standard', ''), "material_grade": material_info.get('grade', ''), "coating_type": material_info.get('coating', ''), "includes_nut": bolt_details.get('includes_nut', False), "includes_washer": bolt_details.get('includes_washer', False), "classification_confidence": confidence, "additional_info": json.dumps(bolt_info, ensure_ascii=False) }) print(f"BOLT 상세정보 저장 완료: {material_data['original_description']}") except Exception as e: print(f"BOLT 상세정보 저장 실패: {e}") elif category == 'GASKET' and confidence >= 0.5: try: gasket_info = classification_result gasket_insert_query = text(""" INSERT INTO gasket_details ( material_id, file_id, gasket_type, gasket_subtype, material_type, size_inches, pressure_rating, thickness, temperature_range, fire_safe, classification_confidence, additional_info ) VALUES ( (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), :file_id, :gasket_type, :gasket_subtype, :material_type, :size_inches, :pressure_rating, :thickness, :temperature_range, :fire_safe, :classification_confidence, :additional_info ) """) # GASKET 분류기 결과 구조에 맞게 데이터 추출 gasket_type_info = gasket_info.get('gasket_type', {}) gasket_material_info = gasket_info.get('gasket_material', {}) pressure_info = gasket_info.get('pressure_rating', {}) size_info = gasket_info.get('size_info', {}) temp_info = gasket_info.get('temperature_info', {}) # SWG 상세 정보 추출 swg_details = gasket_material_info.get('swg_details', {}) additional_info = { "swg_details": swg_details, "face_type": swg_details.get('face_type', ''), "construction": swg_details.get('detailed_construction', ''), "filler": swg_details.get('filler', ''), "outer_ring": swg_details.get('outer_ring', ''), "inner_ring": swg_details.get('inner_ring', '') } db.execute(gasket_insert_query, { "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], "gasket_type": gasket_type_info.get('type', ''), "gasket_subtype": gasket_type_info.get('subtype', ''), "material_type": gasket_material_info.get('material', ''), "size_inches": material_data.get('main_nom', '') or material_data.get('size_spec', ''), "pressure_rating": pressure_info.get('rating', ''), "thickness": swg_details.get('thickness', None), "temperature_range": temp_info.get('range', ''), "fire_safe": gasket_info.get('fire_safe', False), "classification_confidence": confidence, "additional_info": json.dumps(additional_info, ensure_ascii=False) }) print(f"GASKET 상세정보 저장 완료: {material_data['original_description']}") except Exception as e: print(f"GASKET 상세정보 저장 실패: {e}") elif category == 'INSTRUMENT' and confidence >= 0.5: try: inst_info = classification_result inst_insert_query = text(""" INSERT INTO instrument_details ( material_id, file_id, instrument_type, instrument_subtype, measurement_type, measurement_range, accuracy, connection_type, connection_size, body_material, classification_confidence, additional_info ) VALUES ( (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), :file_id, :instrument_type, :instrument_subtype, :measurement_type, :measurement_range, :accuracy, :connection_type, :connection_size, :body_material, :classification_confidence, :additional_info ) """) # INSTRUMENT 분류기 결과 구조에 맞게 데이터 추출 inst_type_info = inst_info.get('instrument_type', {}) measurement_info = inst_info.get('measurement', {}) connection_info = inst_info.get('connection', {}) db.execute(inst_insert_query, { "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], "instrument_type": inst_type_info.get('type', ''), "instrument_subtype": inst_type_info.get('subtype', ''), "measurement_type": measurement_info.get('type', ''), "measurement_range": measurement_info.get('range', ''), "accuracy": measurement_info.get('accuracy', ''), "connection_type": connection_info.get('type', ''), "connection_size": connection_info.get('size', ''), "body_material": inst_info.get('material', ''), "classification_confidence": confidence, "additional_info": json.dumps(inst_info, ensure_ascii=False) }) print(f"INSTRUMENT 상세정보 저장 완료: {material_data['original_description']}") except Exception as e: print(f"INSTRUMENT 상세정보 저장 실패: {e}") materials_inserted += 1 db.commit() return { "success": True, "message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨", "original_filename": file.filename, "file_id": file_id, "parsed_materials_count": parsed_count, "saved_materials_count": materials_inserted, "sample_materials": materials_data[:3] if materials_data else [] } except Exception as e: db.rollback() if os.path.exists(file_path): os.remove(file_path) raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}") @router.get("/materials") async def get_materials( project_id: Optional[int] = None, file_id: Optional[int] = None, job_no: Optional[str] = None, filename: Optional[str] = None, revision: Optional[str] = None, skip: int = 0, limit: int = 100, search: Optional[str] = None, item_type: Optional[str] = None, material_grade: Optional[str] = None, size_spec: Optional[str] = None, file_filter: Optional[str] = None, sort_by: Optional[str] = None, db: Session = Depends(get_db) ): """ 저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능) """ try: query = """ SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.classification_details, m.created_at, f.original_filename, f.project_id, f.job_no, f.revision, p.official_project_code, p.project_name FROM materials m LEFT JOIN files f ON m.file_id = f.id LEFT JOIN projects p ON f.project_id = p.id WHERE 1=1 """ params = {} if project_id: query += " AND f.project_id = :project_id" params["project_id"] = project_id if file_id: query += " AND m.file_id = :file_id" params["file_id"] = file_id if job_no: query += " AND f.job_no = :job_no" params["job_no"] = job_no if filename: query += " AND f.original_filename = :filename" params["filename"] = filename if revision: query += " AND f.revision = :revision" params["revision"] = revision if search: query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" params["search"] = f"%{search}%" if item_type: query += " AND m.classified_category = :item_type" params["item_type"] = item_type if material_grade: query += " AND m.material_grade ILIKE :material_grade" params["material_grade"] = f"%{material_grade}%" if size_spec: query += " AND m.size_spec ILIKE :size_spec" params["size_spec"] = f"%{size_spec}%" if file_filter: query += " AND f.original_filename ILIKE :file_filter" params["file_filter"] = f"%{file_filter}%" # 정렬 처리 if sort_by: if sort_by == "quantity_desc": query += " ORDER BY m.quantity DESC" elif sort_by == "quantity_asc": query += " ORDER BY m.quantity ASC" elif sort_by == "name_asc": query += " ORDER BY m.original_description ASC" elif sort_by == "name_desc": query += " ORDER BY m.original_description DESC" elif sort_by == "created_desc": query += " ORDER BY m.created_at DESC" elif sort_by == "created_asc": query += " ORDER BY m.created_at ASC" else: query += " ORDER BY m.line_number ASC" else: query += " ORDER BY m.line_number ASC" query += " LIMIT :limit OFFSET :skip" params["limit"] = limit params["skip"] = skip result = db.execute(text(query), params) materials = result.fetchall() # 전체 개수 조회 count_query = """ SELECT COUNT(*) as total FROM materials m LEFT JOIN files f ON m.file_id = f.id WHERE 1=1 """ count_params = {} if project_id: count_query += " AND f.project_id = :project_id" count_params["project_id"] = project_id if file_id: count_query += " AND m.file_id = :file_id" count_params["file_id"] = file_id if search: count_query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" count_params["search"] = f"%{search}%" if item_type: count_query += " AND m.classified_category = :item_type" count_params["item_type"] = item_type if material_grade: count_query += " AND m.material_grade ILIKE :material_grade" count_params["material_grade"] = f"%{material_grade}%" if size_spec: count_query += " AND m.size_spec ILIKE :size_spec" count_params["size_spec"] = f"%{size_spec}%" if file_filter: count_query += " AND f.original_filename ILIKE :file_filter" count_params["file_filter"] = f"%{file_filter}%" count_result = db.execute(text(count_query), count_params) total_count = count_result.fetchone()[0] return { "success": True, "total_count": total_count, "returned_count": len(materials), "skip": skip, "limit": limit, "materials": [ { "id": m.id, "file_id": m.file_id, "filename": m.original_filename, "project_id": m.project_id, "project_code": m.official_project_code, "project_name": m.project_name, "original_description": m.original_description, "quantity": float(m.quantity) if m.quantity else 0, "unit": m.unit, "size_spec": m.size_spec, "material_grade": m.material_grade, "line_number": m.line_number, "row_number": m.row_number, "classified_category": m.classified_category, "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0, "classification_details": json.loads(m.classification_details) if m.classification_details else None, "created_at": m.created_at } for m in materials ] } except Exception as e: raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}") @router.get("/materials/summary") async def get_materials_summary( project_id: Optional[int] = None, file_id: Optional[int] = None, db: Session = Depends(get_db) ): """자재 요약 통계""" try: query = """ SELECT COUNT(*) as total_items, COUNT(DISTINCT m.original_description) as unique_descriptions, COUNT(DISTINCT m.size_spec) as unique_sizes, COUNT(DISTINCT m.material_grade) as unique_materials, SUM(m.quantity) as total_quantity, AVG(m.quantity) as avg_quantity, MIN(m.created_at) as earliest_upload, MAX(m.created_at) as latest_upload FROM materials m LEFT JOIN files f ON m.file_id = f.id WHERE 1=1 """ params = {} if project_id: query += " AND f.project_id = :project_id" params["project_id"] = project_id if file_id: query += " AND m.file_id = :file_id" params["file_id"] = file_id result = db.execute(text(query), params) summary = result.fetchone() return { "success": True, "summary": { "total_items": summary.total_items, "unique_descriptions": summary.unique_descriptions, "unique_sizes": summary.unique_sizes, "unique_materials": summary.unique_materials, "total_quantity": float(summary.total_quantity) if summary.total_quantity else 0, "avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0, "earliest_upload": summary.earliest_upload, "latest_upload": summary.latest_upload } } except Exception as e: raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}") @router.get("/materials/compare-revisions") async def compare_revisions( job_no: str, filename: str, old_revision: str, new_revision: str, db: Session = Depends(get_db) ): """ 리비전 간 자재 비교 """ try: # 기존 리비전 자재 조회 old_materials_query = text(""" SELECT m.original_description, m.quantity, m.unit, m.size_spec, m.material_grade, m.classified_category, m.classification_confidence FROM materials m JOIN files f ON m.file_id = f.id WHERE f.job_no = :job_no AND f.original_filename = :filename AND f.revision = :old_revision """) old_result = db.execute(old_materials_query, { "job_no": job_no, "filename": filename, "old_revision": old_revision }) old_materials = old_result.fetchall() # 새 리비전 자재 조회 new_materials_query = text(""" SELECT m.original_description, m.quantity, m.unit, m.size_spec, m.material_grade, m.classified_category, m.classification_confidence FROM materials m JOIN files f ON m.file_id = f.id WHERE f.job_no = :job_no AND f.original_filename = :filename AND f.revision = :new_revision """) new_result = db.execute(new_materials_query, { "job_no": job_no, "filename": filename, "new_revision": new_revision }) new_materials = new_result.fetchall() # 자재 키 생성 함수 def create_material_key(material): return f"{material.original_description}_{material.size_spec}_{material.material_grade}" # 기존 자재를 딕셔너리로 변환 old_materials_dict = {} for material in old_materials: key = create_material_key(material) old_materials_dict[key] = { "original_description": material.original_description, "quantity": float(material.quantity) if material.quantity else 0, "unit": material.unit, "size_spec": material.size_spec, "material_grade": material.material_grade, "classified_category": material.classified_category, "classification_confidence": material.classification_confidence } # 새 자재를 딕셔너리로 변환 new_materials_dict = {} for material in new_materials: key = create_material_key(material) new_materials_dict[key] = { "original_description": material.original_description, "quantity": float(material.quantity) if material.quantity else 0, "unit": material.unit, "size_spec": material.size_spec, "material_grade": material.material_grade, "classified_category": material.classified_category, "classification_confidence": material.classification_confidence } # 변경 사항 분석 all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys()) added_items = [] removed_items = [] changed_items = [] for key in all_keys: old_item = old_materials_dict.get(key) new_item = new_materials_dict.get(key) if old_item and not new_item: # 삭제된 항목 removed_items.append({ "key": key, "item": old_item, "change_type": "removed" }) elif not old_item and new_item: # 추가된 항목 added_items.append({ "key": key, "item": new_item, "change_type": "added" }) elif old_item and new_item: # 수량 변경 확인 if old_item["quantity"] != new_item["quantity"]: changed_items.append({ "key": key, "old_item": old_item, "new_item": new_item, "quantity_change": new_item["quantity"] - old_item["quantity"], "change_type": "quantity_changed" }) # 분류별 통계 def calculate_category_stats(items): stats = {} for item in items: category = item.get("item", {}).get("classified_category", "OTHER") if category not in stats: stats[category] = {"count": 0, "total_quantity": 0} stats[category]["count"] += 1 stats[category]["total_quantity"] += item.get("item", {}).get("quantity", 0) return stats added_stats = calculate_category_stats(added_items) removed_stats = calculate_category_stats(removed_items) changed_stats = calculate_category_stats(changed_items) return { "success": True, "comparison": { "old_revision": old_revision, "new_revision": new_revision, "filename": filename, "job_no": job_no, "summary": { "added_count": len(added_items), "removed_count": len(removed_items), "changed_count": len(changed_items), "total_changes": len(added_items) + len(removed_items) + len(changed_items) }, "changes": { "added": added_items, "removed": removed_items, "changed": changed_items }, "category_stats": { "added": added_stats, "removed": removed_stats, "changed": changed_stats } } } except Exception as e: raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}") @router.post("/materials/update-classification-details") async def update_classification_details( file_id: Optional[int] = None, db: Session = Depends(get_db) ): """기존 자재들의 classification_details 업데이트""" try: # 업데이트할 자재들 조회 query = """ SELECT id, original_description, size_spec, classified_category FROM materials WHERE classification_details IS NULL OR classification_details = '{}' """ params = {} if file_id: query += " AND file_id = :file_id" params["file_id"] = file_id query += " ORDER BY id" result = db.execute(text(query), params) materials = result.fetchall() if not materials: return { "success": True, "message": "업데이트할 자재가 없습니다.", "updated_count": 0 } updated_count = 0 for material in materials: material_id = material.id description = material.original_description size_spec = material.size_spec category = material.classified_category print(f"자재 {material_id} 재분류 중: {description}") # 카테고리별로 적절한 분류기 호출 classification_result = None if category == 'PIPE': classification_result = classify_pipe("", description, size_spec, 0.0) elif category == 'FITTING': classification_result = classify_fitting("", description, size_spec) elif category == 'VALVE': classification_result = classify_valve("", description, size_spec) elif category == 'FLANGE': classification_result = classify_flange("", description, size_spec) elif category == 'BOLT': classification_result = classify_bolt("", description, size_spec) elif category == 'GASKET': classification_result = classify_gasket("", description, size_spec) elif category == 'INSTRUMENT': classification_result = classify_instrument("", description, size_spec) else: # 카테고리가 없으면 모든 분류기 시도 classification_result = classify_pipe("", description, size_spec, 0.0) if classification_result.get("overall_confidence", 0) < 0.5: classification_result = classify_fitting("", description, size_spec) if classification_result.get("overall_confidence", 0) < 0.5: classification_result = classify_valve("", description, size_spec) if classification_result.get("overall_confidence", 0) < 0.5: classification_result = classify_flange("", description, size_spec) if classification_result.get("overall_confidence", 0) < 0.5: classification_result = classify_bolt("", description, size_spec) if classification_result.get("overall_confidence", 0) < 0.5: classification_result = classify_gasket("", description, size_spec) if classification_result.get("overall_confidence", 0) < 0.5: classification_result = classify_instrument("", description, size_spec) if classification_result: # classification_details를 JSON으로 직렬화 classification_details = json.dumps(classification_result, ensure_ascii=False) # DB 업데이트 update_query = text(""" UPDATE materials SET classification_details = :classification_details, updated_at = NOW() WHERE id = :material_id """) db.execute(update_query, { "material_id": material_id, "classification_details": classification_details }) updated_count += 1 print(f"자재 {material_id} 업데이트 완료") db.commit() return { "success": True, "message": f"{updated_count}개 자재의 분류 상세정보가 업데이트되었습니다.", "updated_count": updated_count, "total_materials": len(materials) } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"분류 상세정보 업데이트 실패: {str(e)}")