from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Body from sqlalchemy.orm import Session from sqlalchemy import text from typing import List, Optional, Dict import os import shutil from datetime import datetime import uuid import pandas as pd import re from pathlib import Path import json from ..database import get_db from ..auth.middleware import get_current_user from ..services.activity_logger import ActivityLogger, log_activity_from_request from ..utils.logger import get_logger from app.services.material_classifier import classify_material # 로거 설정 logger = get_logger(__name__) from app.services.integrated_classifier import classify_material_integrated, should_exclude_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 from app.services.revision_comparator import get_revision_comparison router = APIRouter() UPLOAD_DIR = Path("uploads") UPLOAD_DIR.mkdir(exist_ok=True) ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} # API 정보는 /info 엔드포인트로 이동됨 @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().str.lower() # 로그 제거 column_mapping = { 'description': ['description', 'item', 'material', '품명', '자재명'], 'quantity': ['qty', 'quantity', 'ea', '수량'], 'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'], 'red_size': ['red_nom', 'reduced_diameter', '축소배관'], 'length': ['length', 'len', '길이'], 'weight': ['weight', 'wt', '중량'], 'dwg_name': ['dwg_name', 'drawing', '도면명'], 'line_num': ['line_num', 'line_number', '라인번호'] } mapped_columns = {} for standard_col, possible_names in column_mapping.items(): for possible_name in possible_names: if possible_name in df.columns: mapped_columns[standard_col] = possible_name break # 로그 제거 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 material_grade = "" if "ASTM" in description.upper(): # ASTM 표준과 등급만 추출, end_preparation(BOE, POE, BBE 등)은 제외 astm_match = re.search(r'ASTM\s+([A-Z0-9]+(?:\s+GR\s+[A-Z0-9]+)?)', 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', ''), '')) # main_nom과 red_nom 별도 저장 (원본 값 유지) main_nom = main_size if main_size != 'nan' and main_size != '' else None red_nom = red_size if red_size != 'nan' and red_size != '' else None # 기존 size_spec도 유지 (호환성을 위해) 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 = "" # LENGTH 정보 추출 length_raw = row.get(mapped_columns.get('length', ''), '') length_value = None if pd.notna(length_raw) and str(length_raw).strip() != '': try: length_value = float(str(length_raw).strip()) except (ValueError, TypeError): length_value = None if description and description not in ['nan', 'None', '']: materials.append({ 'original_description': description, 'quantity': quantity, 'unit': "EA", 'size_spec': size_spec, 'main_nom': main_nom, # 추가 'red_nom': red_nom, # 추가 '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( request: Request, file: UploadFile = File(...), job_no: str = Form(...), revision: str = Form("Rev.0"), # 기본값은 Rev.0 (새 BOM) parent_file_id: Optional[int] = Form(None), # 리비전 업로드 시 부모 파일 ID bom_name: Optional[str] = Form(None), # BOM 이름 (사용자 입력) db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): # 로그 제거 if not validate_file_extension(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(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) # 로그 제거 # 리비전 업로드인 경우만 자동 리비전 생성 if parent_file_id is not None: # 로그 제거 # 부모 파일의 정보 조회 parent_query = text(""" SELECT original_filename, revision, bom_name FROM files WHERE id = :parent_file_id AND job_no = :job_no """) parent_result = db.execute(parent_query, { "parent_file_id": parent_file_id, "job_no": job_no }) parent_file = parent_result.fetchone() if not parent_file: raise HTTPException(status_code=404, detail="부모 파일을 찾을 수 없습니다.") # 해당 BOM의 최신 리비전 확인 (bom_name 기준) bom_name_to_use = parent_file[2] or parent_file[0] # bom_name 우선, 없으면 original_filename latest_revision_query = text(""" SELECT revision FROM files WHERE job_no = :job_no AND (bom_name = :bom_name OR (bom_name IS NULL AND original_filename = :bom_name)) ORDER BY revision DESC LIMIT 1 """) latest_result = db.execute(latest_revision_query, { "job_no": job_no, "bom_name": bom_name_to_use }) latest_revision = latest_result.fetchone() if latest_revision: latest_rev = latest_revision[0] if latest_rev.startswith("Rev."): try: rev_num = int(latest_rev.replace("Rev.", "")) revision = f"Rev.{rev_num + 1}" except ValueError: revision = "Rev.1" else: revision = "Rev.1" print(f"리비전 업로드: {latest_rev} → {revision}") else: revision = "Rev.1" print(f"첫 번째 리비전: {revision}") # 파일명을 부모와 동일하게 유지 file.filename = parent_file[0] else: # 일반 업로드 (새 BOM) print(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)") # 파일 정보 저장 (사용자 정보 포함) print("DB 저장 시작") username = current_user.get('username', 'unknown') user_id = current_user.get('user_id') file_insert_query = text(""" INSERT INTO files (filename, original_filename, file_path, job_no, revision, bom_name, description, file_size, parsed_count, is_active, uploaded_by) VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :bom_name, :description, :file_size, :parsed_count, :is_active, :uploaded_by) RETURNING id """) file_result = db.execute(file_insert_query, { "filename": unique_filename, "original_filename": file.filename, "file_path": str(file_path), "job_no": job_no, "revision": revision, "bom_name": bom_name or file.filename, # bom_name 우선, 없으면 파일명 "description": f"BOM 파일 - {parsed_count}개 자재", "file_size": file.size, "parsed_count": parsed_count, "is_active": True, "uploaded_by": username }) file_id = file_result.fetchone()[0] print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}") # 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수) revision_comparison = None materials_to_classify = materials_data if revision != "Rev.0": # 리비전 업로드인 경우만 비교 # 로그 제거 try: revision_comparison = get_revision_comparison(db, job_no, revision, materials_data) 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)}개") # 분류가 필요한 자재만 추출 (변경됨 + 신규) materials_to_classify = ( revision_comparison.get("changed_materials", []) + revision_comparison.get("new_materials", []) ) else: print("📝 이전 확정 자료 없음 - 전체 자재 분류") except Exception as e: logger.error(f"리비전 비교 실패: {str(e)}") print(f"⚠️ 리비전 비교 실패, 전체 자재 분류로 진행: {str(e)}") print(f"🔧 자재 분류 시작: {len(materials_to_classify)}개 자재") # 자재 데이터 저장 (분류 포함) - 배치 처리로 성능 개선 materials_to_insert = [] pipe_details_to_insert = [] fitting_details_to_insert = [] bolt_details_to_insert = [] gasket_details_to_insert = [] flange_details_to_insert = [] materials_inserted = 0 # 변경없는 자재 먼저 처리 (기존 분류 결과 재사용) if revision_comparison and revision_comparison.get("has_previous_confirmation", False): unchanged_materials = revision_comparison.get("unchanged_materials", []) for material_data in unchanged_materials: previous_item = material_data.get("previous_item", {}) # 기존 분류 결과 재사용 materials_to_insert.append({ "file_id": file_id, "original_description": material_data["original_description"], "classified_category": previous_item.get("category", "UNKNOWN"), "confidence": 1.0, # 확정된 자료이므로 신뢰도 100% "quantity": material_data["quantity"], "unit": material_data.get("unit", "EA"), "size_spec": material_data.get("size_spec", ""), "material_grade": previous_item.get("material", ""), "specification": previous_item.get("specification", ""), "reused_from_confirmation": True }) materials_inserted += 1 # 분류가 필요한 자재 처리 for material_data in materials_to_classify: # 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등) description = material_data["original_description"] size_spec = material_data["size_spec"] # 각 분류기로 시도 (올바른 매개변수 사용) print(f"분류 시도: {description}") # LENGTH 정보 추출 length_value = None if "length" in material_data: try: length_value = float(material_data["length"]) except (ValueError, TypeError): length_value = None # main_nom과 red_nom 추출 main_nom = material_data.get("main_nom") red_nom = material_data.get("red_nom") # 1. 통합 분류기로 자재 타입 결정 integrated_result = classify_material_integrated(description, main_nom or "", red_nom or "", length_value) print(f"[분류] {description}") print(f"통합 분류 결과: {integrated_result.get('category', 'UNKNOWN')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})") # 2. 제외 대상 확인 if should_exclude_material(description): classification_result = { "category": "EXCLUDE", "overall_confidence": 0.95, "reason": "제외 대상 자재" } else: # 3. 타입별 상세 분류기 실행 material_type = integrated_result.get('category', 'UNKNOWN') if material_type == "PIPE": from ..services.pipe_classifier import classify_pipe_for_purchase classification_result = classify_pipe_for_purchase("", description, main_nom or "", length_value) elif material_type == "FITTING": classification_result = classify_fitting("", description, main_nom or "", red_nom) elif material_type == "FLANGE": classification_result = classify_flange("", description, main_nom or "", red_nom) elif material_type == "VALVE": classification_result = classify_valve("", description, main_nom or "") elif material_type == "BOLT": classification_result = classify_bolt("", description, main_nom or "") elif material_type == "GASKET": classification_result = classify_gasket("", description, main_nom or "") elif material_type == "INSTRUMENT": classification_result = classify_instrument("", description, main_nom or "") else: # UNKNOWN 처리 classification_result = { "category": "UNKNOWN", "overall_confidence": integrated_result.get('confidence', 0.0), "reason": f"분류 불가: {integrated_result.get('evidence', [])}" } # 통합 분류기의 신뢰도가 더 낮으면 조정 if integrated_result.get('confidence', 0) < 0.5: classification_result['overall_confidence'] = min( classification_result.get('overall_confidence', 1.0), integrated_result.get('confidence', 0.0) + 0.2 ) print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") # 기본 자재 정보 저장 material_insert_query = text(""" INSERT INTO materials ( file_id, original_description, quantity, unit, size_spec, main_nom, red_nom, material_grade, line_number, row_number, classified_category, classification_confidence, is_verified, created_at ) VALUES ( :file_id, :original_description, :quantity, :unit, :size_spec, :main_nom, :red_nom, :material_grade, :line_number, :row_number, :classified_category, :classification_confidence, :is_verified, :created_at ) RETURNING id """) # 첫 번째 자재에 대해서만 디버그 출력 if materials_inserted == 0: print(f"첫 번째 자재 저장:") print(f" size_spec: '{material_data['size_spec']}'") print(f" original_description: {material_data['original_description']}") print(f" category: {classification_result.get('category', 'UNKNOWN')}") material_result = 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"], "main_nom": material_data.get("main_nom"), # 추가 "red_nom": material_data.get("red_nom"), # 추가 "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), "is_verified": False, "created_at": datetime.now() }) material_id = material_result.fetchone()[0] materials_inserted += 1 # PIPE 분류 결과인 경우 상세 정보 저장 if classification_result.get("category") == "PIPE": print("PIPE 상세 정보 저장 시작") # 끝단 가공 정보 추출 및 저장 from ..services.pipe_classifier import extract_end_preparation_info end_prep_info = extract_end_preparation_info(description) # 끝단 가공 정보 테이블에 저장 end_prep_insert_query = text(""" INSERT INTO pipe_end_preparations ( material_id, file_id, end_preparation_type, end_preparation_code, machining_required, cutting_note, original_description, clean_description, confidence, matched_pattern ) VALUES ( :material_id, :file_id, :end_preparation_type, :end_preparation_code, :machining_required, :cutting_note, :original_description, :clean_description, :confidence, :matched_pattern ) """) db.execute(end_prep_insert_query, { "material_id": material_id, "file_id": file_id, "end_preparation_type": end_prep_info["end_preparation_type"], "end_preparation_code": end_prep_info["end_preparation_code"], "machining_required": end_prep_info["machining_required"], "cutting_note": end_prep_info["cutting_note"], "original_description": end_prep_info["original_description"], "clean_description": end_prep_info["clean_description"], "confidence": end_prep_info["confidence"], "matched_pattern": end_prep_info["matched_pattern"] }) # 길이 정보 추출 - 분류 결과의 length_info 우선 사용 length_info = classification_result.get("length_info", {}) length_mm = length_info.get("length_mm") or material_data.get("length", 0.0) if material_data.get("length") else None # material_id도 함께 저장하도록 수정 pipe_detail_insert_query = text(""" INSERT INTO pipe_details ( material_id, file_id, outer_diameter, schedule, material_spec, manufacturing_method, end_preparation, length_mm ) VALUES ( :material_id, :file_id, :outer_diameter, :schedule, :material_spec, :manufacturing_method, :end_preparation, :length_mm ) """) # 재질 정보 material_info = classification_result.get("material", {}) manufacturing_info = classification_result.get("manufacturing", {}) end_prep_info = classification_result.get("end_preparation", {}) schedule_info = classification_result.get("schedule", {}) size_info = classification_result.get("size_info", {}) # main_nom을 outer_diameter로 활용 outer_diameter = material_data.get("main_nom") or material_data.get("size_spec", "") # end_preparation 정보 추출 (분류 결과에서) end_prep = "" if isinstance(end_prep_info, dict): end_prep = end_prep_info.get("type", "") else: end_prep = str(end_prep_info) if end_prep_info else "" # 재질 정보 - 분류 결과에서 상세 정보 추출 material_grade_from_classifier = "" if isinstance(material_info, dict): material_grade_from_classifier = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN": material_spec = material_grade_from_classifier # materials 테이블의 material_grade도 업데이트 db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade WHERE id = :material_id """), { "new_material_grade": material_grade_from_classifier, "material_id": material_id }) print(f"PIPE material_grade 업데이트: {material_grade_from_classifier}") else: # 기존 파싱 결과 사용 material_spec = material_data.get("material_grade", "") # 제조방법 추출 manufacturing_method = "" if isinstance(manufacturing_info, dict): manufacturing_method = manufacturing_info.get("method", "UNKNOWN") else: manufacturing_method = str(manufacturing_info) if manufacturing_info else "UNKNOWN" # 스케줄 정보 추출 schedule = "" if isinstance(schedule_info, dict): schedule = schedule_info.get("schedule", "UNKNOWN") else: schedule = str(schedule_info) if schedule_info else "UNKNOWN" db.execute(pipe_detail_insert_query, { "material_id": material_id, "file_id": file_id, "outer_diameter": outer_diameter, "schedule": schedule, "material_spec": material_spec, "manufacturing_method": manufacturing_method, "end_preparation": end_prep, "length_mm": material_data.get("length", 0.0) if material_data.get("length") else 0.0 }) print("PIPE 상세 정보 저장 완료") # FITTING 분류 결과인 경우 상세 정보 저장 elif classification_result.get("category") == "FITTING": print("FITTING 상세 정보 저장 시작") # 피팅 정보 추출 fitting_type_info = classification_result.get("fitting_type", {}) connection_info = classification_result.get("connection_method", {}) pressure_info = classification_result.get("pressure_rating", {}) material_info = classification_result.get("material", {}) # 피팅 타입 및 서브타입 fitting_type = fitting_type_info.get("type", "UNKNOWN") fitting_subtype = fitting_type_info.get("subtype", "UNKNOWN") # 연결 방식 connection_method = connection_info.get("method", "UNKNOWN") # 압력 등급 pressure_rating = pressure_info.get("rating", "UNKNOWN") # 재질 정보 material_standard = material_info.get("standard", "") material_grade = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 if material_grade and material_grade != "UNKNOWN": db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade WHERE id = :material_id """), { "new_material_grade": material_grade, "material_id": material_id }) print(f"FITTING material_grade 업데이트: {material_grade}") # main_size와 reduced_size main_size = material_data.get("main_nom") or material_data.get("size_spec", "") reduced_size = material_data.get("red_nom", "") # NIPPLE인 경우 길이와 스케줄 정보 추가 length_mm = None schedule = "UNKNOWN" if fitting_type == "NIPPLE": # 길이 정보 추출 length_mm = material_data.get("length", 0.0) if material_data.get("length") else None # 스케줄 정보 추출 (분류 결과에서) schedule_info = classification_result.get("schedule_info", {}) schedule = schedule_info.get("schedule", "UNKNOWN") schedule_info = classification_result.get("schedule", {}) if isinstance(schedule_info, dict): schedule = schedule_info.get("schedule", "UNKNOWN") else: schedule = str(schedule_info) if schedule_info else "UNKNOWN" db.execute(text(""" INSERT INTO fitting_details ( material_id, file_id, fitting_type, fitting_subtype, connection_method, pressure_rating, material_standard, material_grade, main_size, reduced_size, length_mm, schedule ) VALUES ( :material_id, :file_id, :fitting_type, :fitting_subtype, :connection_method, :pressure_rating, :material_standard, :material_grade, :main_size, :reduced_size, :length_mm, :schedule ) """), { "material_id": material_id, "file_id": file_id, "fitting_type": fitting_type, "fitting_subtype": fitting_subtype, "connection_method": connection_method, "pressure_rating": pressure_rating, "material_standard": material_standard, "material_grade": material_grade, "main_size": main_size, "reduced_size": reduced_size, "length_mm": length_mm, "schedule": schedule }) print(f"FITTING 상세 정보 저장 완료: {fitting_type} - {fitting_subtype}") # FLANGE 분류 결과인 경우 상세 정보 저장 if classification_result.get("category") == "FLANGE": print("FLANGE 상세 정보 저장 시작") # 플랜지 타입 정보 flange_type_info = classification_result.get("flange_type", {}) pressure_info = classification_result.get("pressure_rating", {}) face_finish_info = classification_result.get("face_finish", {}) material_info = classification_result.get("material", {}) # 플랜지 타입 (WN, BL, SO 등) flange_type = "" if isinstance(flange_type_info, dict): flange_type = flange_type_info.get("type", "UNKNOWN") else: flange_type = str(flange_type_info) if flange_type_info else "UNKNOWN" # 압력 등급 (150LB, 300LB 등) pressure_rating = "" if isinstance(pressure_info, dict): pressure_rating = pressure_info.get("rating", "UNKNOWN") else: pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN" # 면 가공 (RF, FF, RTJ 등) facing_type = "" if isinstance(face_finish_info, dict): facing_type = face_finish_info.get("finish", "UNKNOWN") else: facing_type = str(face_finish_info) if face_finish_info else "UNKNOWN" # 재질 정보 material_standard = "" material_grade = "" if isinstance(material_info, dict): material_standard = material_info.get("standard", "") material_grade = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 if material_grade and material_grade != "UNKNOWN": db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade WHERE id = :material_id """), { "new_material_grade": material_grade, "material_id": material_id }) print(f"FLANGE material_grade 업데이트: {material_grade}") # 사이즈 정보 size_inches = material_data.get("main_nom") or material_data.get("size_spec", "") db.execute(text(""" INSERT INTO flange_details ( material_id, file_id, flange_type, pressure_rating, facing_type, material_standard, material_grade, size_inches ) VALUES ( :material_id, :file_id, :flange_type, :pressure_rating, :facing_type, :material_standard, :material_grade, :size_inches ) """), { "material_id": material_id, "file_id": file_id, "flange_type": flange_type, "pressure_rating": pressure_rating, "facing_type": facing_type, "material_standard": material_standard, "material_grade": material_grade, "size_inches": size_inches }) print(f"FLANGE 상세 정보 저장 완료: {flange_type} - {pressure_rating}") # GASKET 분류 결과인 경우 상세 정보 저장 if classification_result.get("category") == "GASKET": print("GASKET 상세 정보 저장 시작") # 가스켓 타입 정보 gasket_type_info = classification_result.get("gasket_type", {}) gasket_material_info = classification_result.get("gasket_material", {}) pressure_info = classification_result.get("pressure_rating", {}) # 가스켓 타입 (SPIRAL_WOUND, O_RING 등) gasket_type = "" if isinstance(gasket_type_info, dict): gasket_type = gasket_type_info.get("type", "UNKNOWN") else: gasket_type = str(gasket_type_info) if gasket_type_info else "UNKNOWN" # 가스켓 소재 - SWG의 경우 메탈 부분을 우선으로 material_type = "" if isinstance(gasket_material_info, dict): # SWG 상세 정보가 있으면 메탈 부분을 material_type으로 사용 swg_details = gasket_material_info.get("swg_details", {}) if swg_details and swg_details.get("outer_ring"): material_type = swg_details.get("outer_ring", "UNKNOWN") # SS304 else: material_type = gasket_material_info.get("material", "UNKNOWN") else: material_type = str(gasket_material_info) if gasket_material_info else "UNKNOWN" # 압력 등급 pressure_rating = "" if isinstance(pressure_info, dict): pressure_rating = pressure_info.get("rating", "UNKNOWN") else: pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN" # 사이즈 정보 size_inches = material_data.get("main_nom") or material_data.get("size_spec", "") # SWG 상세 정보 추출 swg_details = gasket_material_info.get("swg_details", {}) if isinstance(gasket_material_info, dict) else {} thickness = swg_details.get("thickness", None) if swg_details else None filler_material = swg_details.get("filler", "") if swg_details else "" # additional_info에 SWG 상세 정보 저장 additional_info = "" if swg_details: face_type = swg_details.get("face_type", "") outer_ring = swg_details.get("outer_ring", "") inner_ring = swg_details.get("inner_ring", "") construction = swg_details.get("detailed_construction", "") # JSON 형태로 additional_info 생성 additional_info = { "face_type": face_type, "construction": construction, "outer_ring": outer_ring, "inner_ring": inner_ring, "filler": swg_details.get("filler", ""), "thickness": swg_details.get("thickness", None) } additional_info_json = json.dumps(additional_info, ensure_ascii=False) db.execute(text(""" INSERT INTO gasket_details ( material_id, file_id, gasket_type, material_type, pressure_rating, size_inches, thickness, filler_material, additional_info ) VALUES ( :material_id, :file_id, :gasket_type, :material_type, :pressure_rating, :size_inches, :thickness, :filler_material, :additional_info ) """), { "material_id": material_id, "file_id": file_id, "gasket_type": gasket_type, "material_type": material_type, "pressure_rating": pressure_rating, "size_inches": size_inches, "thickness": thickness, "filler_material": filler_material, "additional_info": additional_info_json }) print(f"GASKET 상세 정보 저장 완료: {gasket_type} - {material_type}") # BOLT 분류 결과인 경우 상세 정보 저장 if classification_result.get("category") == "BOLT": print("BOLT 상세 정보 저장 시작") # 볼트 타입 정보 fastener_type_info = classification_result.get("fastener_type", {}) thread_spec_info = classification_result.get("thread_specification", {}) dimensions_info = classification_result.get("dimensions", {}) material_info = classification_result.get("material", {}) # 볼트 타입 (STUD_BOLT, HEX_BOLT 등) bolt_type = "" if isinstance(fastener_type_info, dict): bolt_type = fastener_type_info.get("type", "UNKNOWN") else: bolt_type = str(fastener_type_info) if fastener_type_info else "UNKNOWN" # 나사 타입 (METRIC, INCH 등) thread_type = "" if isinstance(thread_spec_info, dict): thread_type = thread_spec_info.get("standard", "UNKNOWN") else: thread_type = str(thread_spec_info) if thread_spec_info else "UNKNOWN" # 치수 정보 (실제 볼트 사이즈 사용) diameter = "" length = "" nominal_size_fraction = "" if isinstance(dimensions_info, dict): # 볼트 분류기에서 추출한 실제 볼트 사이즈 사용 diameter = dimensions_info.get("nominal_size", material_data.get("main_nom", "")) nominal_size_fraction = dimensions_info.get("nominal_size_fraction", diameter) length = dimensions_info.get("length", "") if not length and "70.0000 LG" in description: # 원본 설명에서 길이 추출 import re length_match = re.search(r'(\d+(?:\.\d+)?)\s*LG', description.upper()) if length_match: length = f"{length_match.group(1)}mm" # 재질 정보 material_standard = "" material_grade = "" if isinstance(material_info, dict): material_standard = material_info.get("standard", "") material_grade = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 if material_grade and material_grade != "UNKNOWN": db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade WHERE id = :material_id """), { "new_material_grade": material_grade, "material_id": material_id }) print(f"BOLT material_grade 업데이트: {material_grade}") # 압력 등급 (150LB 등) pressure_rating = "" if "150LB" in description.upper(): pressure_rating = "150LB" elif "300LB" in description.upper(): pressure_rating = "300LB" elif "600LB" in description.upper(): pressure_rating = "600LB" # 코팅 타입 (ELEC.GALV 등) coating_type = "" if "ELEC.GALV" in description.upper() or "ELEC GALV" in description.upper(): coating_type = "ELECTRO_GALVANIZED" elif "HOT.GALV" in description.upper() or "HOT GALV" in description.upper(): coating_type = "HOT_DIP_GALVANIZED" elif "GALV" in description.upper(): coating_type = "GALVANIZED" elif "ZINC" in description.upper(): coating_type = "ZINC_PLATED" elif "DACROMET" in description.upper(): coating_type = "DACROMET" elif "SS" in description.upper() or "STAINLESS" in description.upper(): coating_type = "STAINLESS" elif "PLAIN" in description.upper() or "BLACK" in description.upper(): coating_type = "PLAIN" db.execute(text(""" INSERT INTO bolt_details ( material_id, file_id, bolt_type, thread_type, diameter, length, material_standard, material_grade, coating_type, pressure_rating, classification_confidence ) VALUES ( :material_id, :file_id, :bolt_type, :thread_type, :diameter, :length, :material_standard, :material_grade, :coating_type, :pressure_rating, :classification_confidence ) """), { "material_id": material_id, "file_id": file_id, "bolt_type": bolt_type, "thread_type": thread_type, "diameter": diameter, "length": length, "material_standard": material_standard, "material_grade": material_grade, "coating_type": coating_type, "pressure_rating": pressure_rating, "classification_confidence": classification_result.get("overall_confidence", 0.0) }) print(f"BOLT 상세 정보 저장 완료: {bolt_type} - {material_standard} {material_grade}") # VALVE 분류 결과인 경우 상세 정보 저장 if classification_result.get("category") == "VALVE": print("VALVE 상세 정보 저장 시작") # 밸브 타입 정보 valve_type_info = classification_result.get("valve_type", {}) connection_info = classification_result.get("connection_method", {}) pressure_info = classification_result.get("pressure_rating", {}) material_info = classification_result.get("material", {}) # 밸브 타입 (GATE_VALVE, BALL_VALVE 등) valve_type = "" if isinstance(valve_type_info, dict): valve_type = valve_type_info.get("type", "UNKNOWN") else: valve_type = str(valve_type_info) if valve_type_info else "UNKNOWN" # 밸브 서브타입 (특수 기능) valve_subtype = "" special_features = classification_result.get("special_features", []) if special_features: valve_subtype = ", ".join(special_features) # 작동 방식 actuator_type = "MANUAL" # 기본값 actuation_info = classification_result.get("actuation", {}) if isinstance(actuation_info, dict): actuator_type = actuation_info.get("method", "MANUAL") # 연결 방식 (FLANGED, THREADED 등) connection_method = "" if isinstance(connection_info, dict): connection_method = connection_info.get("method", "UNKNOWN") else: connection_method = str(connection_info) if connection_info else "UNKNOWN" # 압력 등급 (150LB, 300LB 등) pressure_rating = "" if isinstance(pressure_info, dict): pressure_rating = pressure_info.get("rating", "UNKNOWN") else: pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN" # 재질 정보 body_material = "" trim_material = "" if isinstance(material_info, dict): body_material = material_info.get("grade", "") # 트림 재질은 일반적으로 바디와 동일하거나 별도 명시 trim_material = body_material # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 if body_material and body_material != "UNKNOWN": db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade WHERE id = :material_id """), { "new_material_grade": body_material, "material_id": material_id }) print(f"VALVE material_grade 업데이트: {body_material}") # 사이즈 정보 size_inches = material_data.get("main_nom") or material_data.get("size_spec", "") # 특수 기능 (Fire Safe, Anti-Static 등) fire_safe = any("FIRE" in feature.upper() for feature in special_features) low_temp_service = any("CRYO" in feature.upper() or "LOW" in feature.upper() for feature in special_features) # 추가 정보 JSON 생성 additional_info = { "characteristics": valve_type_info.get("characteristics", "") if isinstance(valve_type_info, dict) else "", "typical_connections": valve_type_info.get("typical_connections", []) if isinstance(valve_type_info, dict) else [], "special_features": special_features, "manufacturing": classification_result.get("manufacturing", {}), "evidence": valve_type_info.get("evidence", []) if isinstance(valve_type_info, dict) else [] } additional_info_json = json.dumps(additional_info, ensure_ascii=False) db.execute(text(""" INSERT INTO valve_details ( material_id, file_id, valve_type, valve_subtype, actuator_type, connection_method, pressure_rating, body_material, trim_material, size_inches, fire_safe, low_temp_service, classification_confidence, additional_info ) VALUES ( :material_id, :file_id, :valve_type, :valve_subtype, :actuator_type, :connection_method, :pressure_rating, :body_material, :trim_material, :size_inches, :fire_safe, :low_temp_service, :classification_confidence, :additional_info ) """), { "material_id": material_id, "file_id": file_id, "valve_type": valve_type, "valve_subtype": valve_subtype, "actuator_type": actuator_type, "connection_method": connection_method, "pressure_rating": pressure_rating, "body_material": body_material, "trim_material": trim_material, "size_inches": size_inches, "fire_safe": fire_safe, "low_temp_service": low_temp_service, "classification_confidence": classification_result.get("overall_confidence", 0.0), "additional_info": additional_info_json }) print(f"VALVE 상세 정보 저장 완료: {valve_type} - {connection_method} - {pressure_rating}") db.commit() print(f"자재 저장 완료: {materials_inserted}개") # 활동 로그 기록 try: activity_logger = ActivityLogger(db) activity_logger.log_file_upload( username=username, file_id=file_id, filename=file.filename, file_size=file.size or 0, job_no=job_no, revision=revision, user_id=user_id, ip_address=request.client.host if request.client else None, user_agent=request.headers.get('user-agent') ) print(f"활동 로그 기록 완료: {username} - 파일 업로드") except Exception as e: print(f"활동 로그 기록 실패: {str(e)}") # 로그 실패는 업로드 성공에 영향을 주지 않음 return { "success": True, "message": f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다.", "original_filename": file.filename, "file_id": file_id, "materials_count": materials_inserted, "saved_materials_count": materials_inserted, "revision": revision, # 생성된 리비전 정보 추가 "uploaded_by": username, # 업로드한 사용자 정보 추가 "parsed_count": parsed_count } 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("/") async def get_files( 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 DESC" result = db.execute(text(query), params) files = result.fetchall() return [ { "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, "created_at": file.upload_date, "is_active": file.is_active } for file in files ] except Exception as e: raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") @router.get("/stats") async def get_files_stats(db: Session = Depends(get_db)): """파일 및 자재 통계 조회""" try: # 총 파일 수 files_query = text("SELECT COUNT(*) FROM files WHERE is_active = true") total_files = db.execute(files_query).fetchone()[0] # 총 자재 수 materials_query = text("SELECT COUNT(*) FROM materials") total_materials = db.execute(materials_query).fetchone()[0] # 최근 업로드 (최근 5개) recent_query = text(""" SELECT f.original_filename, f.upload_date, f.parsed_count, j.job_name FROM files f LEFT JOIN jobs j ON f.job_no = j.job_no WHERE f.is_active = true ORDER BY f.upload_date DESC LIMIT 5 """) recent_uploads = db.execute(recent_query).fetchall() return { "success": True, "totalFiles": total_files, "totalMaterials": total_materials, "recentUploads": [ { "filename": upload.original_filename, "created_at": upload.upload_date, "parsed_count": upload.parsed_count or 0, "project_name": upload.job_name or "Unknown" } for upload in recent_uploads ] } except Exception as e: raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}") @router.delete("/delete/{file_id}") async def delete_file(file_id: int, db: Session = Depends(get_db)): """파일 삭제""" try: # 자재 먼저 삭제 db.execute(text("DELETE FROM materials WHERE file_id = :file_id"), {"file_id": file_id}) # 파일 삭제 result = db.execute(text("DELETE FROM files WHERE id = :file_id"), {"file_id": file_id}) if result.rowcount == 0: raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") db.commit() return { "success": True, "message": "파일이 삭제되었습니다" } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}") @router.get("/materials-v2") # 완전히 새로운 엔드포인트 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.created_at, m.classified_category, m.classification_confidence, m.classification_details, m.is_verified, m.verified_by, m.verified_at, f.original_filename, f.project_id, f.job_no, f.revision, p.official_project_code, p.project_name, pd.outer_diameter, pd.schedule, pd.material_spec, pd.manufacturing_method, pd.end_preparation, pd.length_mm, pep.end_preparation_type, pep.end_preparation_code, pep.machining_required, pep.cutting_note, pep.clean_description as pipe_clean_description, fd.fitting_type, fd.fitting_subtype, fd.connection_method, fd.pressure_rating, fd.material_standard, fd.material_grade as fitting_material_grade, fd.main_size, fd.reduced_size, fd.length_mm as fitting_length_mm, fd.schedule as fitting_schedule, mpt.confirmed_quantity, mpt.purchase_status, mpt.confirmed_by, mpt.confirmed_at, -- 구매수량 계산에서 분류된 정보를 우선 사용 CASE WHEN mpt.id IS NOT NULL THEN CASE WHEN mpt.description LIKE '%PIPE%' OR mpt.description LIKE '%파이프%' THEN 'PIPE' WHEN mpt.description LIKE '%FITTING%' OR mpt.description LIKE '%피팅%' OR mpt.description LIKE '%NIPPLE%' OR mpt.description LIKE '%ELBOW%' OR mpt.description LIKE '%TEE%' OR mpt.description LIKE '%REDUCER%' THEN 'FITTING' WHEN mpt.description LIKE '%VALVE%' OR mpt.description LIKE '%밸브%' THEN 'VALVE' WHEN mpt.description LIKE '%FLANGE%' OR mpt.description LIKE '%플랜지%' THEN 'FLANGE' WHEN mpt.description LIKE '%BOLT%' OR mpt.description LIKE '%볼트%' OR mpt.description LIKE '%STUD%' THEN 'BOLT' WHEN mpt.description LIKE '%GASKET%' OR mpt.description LIKE '%가스켓%' THEN 'GASKET' WHEN mpt.description LIKE '%INSTRUMENT%' OR mpt.description LIKE '%계기%' THEN 'INSTRUMENT' ELSE m.classified_category END ELSE m.classified_category END as final_classified_category, -- 구매수량 계산 완료 여부 CASE WHEN mpt.id IS NOT NULL THEN true ELSE m.is_verified END as final_is_verified, CASE WHEN mpt.id IS NOT NULL THEN 'purchase_calculation' ELSE m.verified_by END as final_verified_by FROM materials m LEFT JOIN files f ON m.file_id = f.id LEFT JOIN projects p ON f.project_id = p.id LEFT JOIN pipe_details pd ON m.id = pd.material_id LEFT JOIN pipe_end_preparations pep ON m.id = pep.material_id LEFT JOIN fitting_details fd ON m.id = fd.material_id LEFT JOIN valve_details vd ON m.id = vd.material_id LEFT JOIN material_purchase_tracking mpt ON ( m.material_hash = mpt.material_hash AND f.job_no = mpt.job_no AND f.revision = mpt.revision ) 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] # 파이프 그룹핑을 위한 딕셔너리 pipe_groups = {} # 니플 그룹핑을 위한 딕셔너리 (길이 기반) nipple_groups = {} # 일반 피팅 그룹핑을 위한 딕셔너리 (수량 기반) fitting_groups = {} # 플랜지 그룹핑을 위한 딕셔너리 flange_groups = {} # 밸브 그룹핑을 위한 딕셔너리 valve_groups = {} # 볼트 그룹핑을 위한 딕셔너리 bolt_groups = {} # 가스켓 그룹핑을 위한 딕셔너리 gasket_groups = {} # UNKNOWN 그룹핑을 위한 딕셔너리 unknown_groups = {} # 각 자재의 상세 정보도 가져오기 material_list = [] valve_count = 0 for m in materials: if m.classified_category == 'VALVE': valve_count += 1 # 디버깅: 첫 번째 자재의 모든 속성 출력 if len(material_list) == 0: # 로그 제거 pass material_dict = { "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, "main_nom": m.main_nom, # 추가 "red_nom": m.red_nom, # 추가 "material_grade": m.material_grade, "line_number": m.line_number, "row_number": m.row_number, # 구매수량 계산에서 분류된 정보를 우선 사용 "classified_category": m.final_classified_category or m.classified_category, "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0, "classification_details": m.classification_details, "is_verified": m.final_is_verified if m.final_is_verified is not None else m.is_verified, "verified_by": m.final_verified_by or m.verified_by, "verified_at": m.verified_at, "purchase_confirmed": bool(m.confirmed_quantity), "confirmed_quantity": float(m.confirmed_quantity) if m.confirmed_quantity else None, "purchase_status": m.purchase_status, "purchase_confirmed_by": m.confirmed_by, "purchase_confirmed_at": m.confirmed_at, "created_at": m.created_at } # 카테고리별 상세 정보 추가 (JOIN 결과 사용) if m.classified_category == 'PIPE': # JOIN된 결과에서 pipe_details 정보 가져오기 if hasattr(m, 'outer_diameter') and m.outer_diameter is not None: pipe_details = { "outer_diameter": m.outer_diameter, "schedule": m.schedule, "material_spec": m.material_spec, "manufacturing_method": m.manufacturing_method, "end_preparation": m.end_preparation, "length_mm": float(m.length_mm) if m.length_mm else None } # 파이프 그룹핑 키 생성 (끝단 가공 정보 제외하고 그룹핑) # pep 테이블에서 clean_description을 가져오거나, 없으면 직접 계산 if hasattr(m, 'pipe_clean_description') and m.pipe_clean_description: clean_description = m.pipe_clean_description else: from ..services.pipe_classifier import get_purchase_pipe_description clean_description = get_purchase_pipe_description(m.original_description) pipe_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" # 로그 제거 - 과도한 출력 방지 if pipe_key not in pipe_groups: pipe_groups[pipe_key] = { "total_length_mm": 0, "total_quantity": 0, "materials": [] } # 개별 파이프 길이 합산 (DB에 저장된 실제 길이 사용) if pipe_details["length_mm"]: # ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음 individual_length = float(pipe_details["length_mm"]) pipe_groups[pipe_key]["total_length_mm"] += individual_length pipe_groups[pipe_key]["total_quantity"] += 1 # 파이프 개수는 1개씩 증가 pipe_groups[pipe_key]["materials"].append(material_dict) # 개별 길이 정보를 pipe_details에 추가 pipe_details["individual_total_length"] = individual_length # 구매용 깨끗한 설명도 추가 material_dict['clean_description'] = clean_description material_dict['pipe_details'] = pipe_details elif m.classified_category == 'FITTING': # CAP과 PLUG 먼저 처리 (fitting_type이 없을 수 있음) if 'CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper(): # CAP과 PLUG 그룹핑 from ..services.pipe_classifier import get_purchase_pipe_description clean_description = get_purchase_pipe_description(m.original_description) fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" if fitting_key not in fitting_groups: fitting_groups[fitting_key] = { "total_quantity": 0, "materials": [] } fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"] fitting_groups[fitting_key]["materials"].append(material_dict) material_dict['clean_description'] = clean_description # JOIN된 fitting_details 데이터 직접 사용 elif hasattr(m, 'fitting_type') and m.fitting_type is not None: # 로그 제거 - 과도한 출력 방지 fitting_details = { "fitting_type": m.fitting_type, "fitting_subtype": m.fitting_subtype, "connection_method": m.connection_method, "pressure_rating": m.pressure_rating, "material_standard": m.material_standard, "material_grade": m.fitting_material_grade, "main_size": m.main_size, "reduced_size": m.reduced_size, "length_mm": float(m.fitting_length_mm) if m.fitting_length_mm else None, "schedule": m.fitting_schedule } material_dict['fitting_details'] = fitting_details # 니플인 경우 길이 기반 그룹핑 if 'NIPPLE' in m.original_description.upper() and m.fitting_length_mm: # 끝단 가공 정보 제거 from ..services.pipe_classifier import get_purchase_pipe_description clean_description = get_purchase_pipe_description(m.original_description) nipple_key = f"{clean_description}|{m.size_spec}|{m.material_grade}|{m.fitting_length_mm}mm" # 로그 제거 - 과도한 출력 방지 if nipple_key not in nipple_groups: nipple_groups[nipple_key] = { "total_length_mm": 0, "total_quantity": 0, "materials": [] } # 개별 니플 길이 합산 (수량 × 단위길이) - 타입 변환 individual_total_length = float(material_dict["quantity"]) * float(m.fitting_length_mm) nipple_groups[nipple_key]["total_length_mm"] += individual_total_length nipple_groups[nipple_key]["total_quantity"] += material_dict["quantity"] nipple_groups[nipple_key]["materials"].append(material_dict) # 총길이 정보를 fitting_details에 추가 fitting_details["individual_total_length"] = individual_total_length fitting_details["is_nipple"] = True # 구매용 깨끗한 설명도 추가 material_dict['clean_description'] = clean_description else: # 일반 피팅 (니플이 아닌 경우) - 수량 기반 그룹핑 from ..services.pipe_classifier import get_purchase_pipe_description clean_description = get_purchase_pipe_description(m.original_description) fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" if fitting_key not in fitting_groups: fitting_groups[fitting_key] = { "total_quantity": 0, "materials": [] } fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"] fitting_groups[fitting_key]["materials"].append(material_dict) # 구매용 깨끗한 설명도 추가 material_dict['clean_description'] = clean_description elif m.classified_category == 'FLANGE': flange_query = text("SELECT * FROM flange_details WHERE material_id = :material_id") flange_result = db.execute(flange_query, {"material_id": m.id}) flange_detail = flange_result.fetchone() if flange_detail: material_dict['flange_details'] = { "flange_type": flange_detail.flange_type, "facing_type": flange_detail.facing_type, "pressure_rating": flange_detail.pressure_rating, "material_standard": flange_detail.material_standard, "material_grade": flange_detail.material_grade, "size_inches": flange_detail.size_inches } # 플랜지 그룹핑 추가 from ..services.pipe_classifier import get_purchase_pipe_description clean_description = get_purchase_pipe_description(m.original_description) flange_key = f"{m.size_spec}|{m.material_grade}|{flange_detail.pressure_rating if flange_detail else ''}|{flange_detail.flange_type if flange_detail else ''}" if flange_key not in flange_groups: flange_groups[flange_key] = { "total_quantity": 0, "materials": [] } flange_groups[flange_key]["total_quantity"] += material_dict["quantity"] flange_groups[flange_key]["materials"].append(material_dict) material_dict['clean_description'] = clean_description elif m.classified_category == 'GASKET': gasket_query = text("SELECT * FROM gasket_details WHERE material_id = :material_id") gasket_result = db.execute(gasket_query, {"material_id": m.id}) gasket_detail = gasket_result.fetchone() if gasket_detail: material_dict['gasket_details'] = { "gasket_type": gasket_detail.gasket_type, "material_type": gasket_detail.material_type, "pressure_rating": gasket_detail.pressure_rating, "size_inches": gasket_detail.size_inches, "thickness": gasket_detail.thickness, "temperature_range": gasket_detail.temperature_range } # 가스켓 그룹핑 - 크기, 압력, 재질로 그룹핑 # original_description에서 주요 정보 추출 description = m.original_description or '' gasket_key = f"{m.size_spec}|{description}" if gasket_key not in gasket_groups: gasket_groups[gasket_key] = { "total_quantity": 0, "materials": [] } gasket_groups[gasket_key]["total_quantity"] += material_dict["quantity"] gasket_groups[gasket_key]["materials"].append(material_dict) elif m.classified_category == 'VALVE': valve_query = text("SELECT * FROM valve_details WHERE material_id = :material_id") valve_result = db.execute(valve_query, {"material_id": m.id}) valve_detail = valve_result.fetchone() if valve_detail: material_dict['valve_details'] = { "valve_type": valve_detail.valve_type, "valve_subtype": valve_detail.valve_subtype, "actuator_type": valve_detail.actuator_type, "connection_method": valve_detail.connection_method, "pressure_rating": valve_detail.pressure_rating, "body_material": valve_detail.body_material, "size_inches": valve_detail.size_inches } # 밸브 그룹핑 추가 from ..services.pipe_classifier import get_purchase_pipe_description clean_description = get_purchase_pipe_description(m.original_description) valve_key = f"{clean_description}|{m.size_spec}|{m.material_grade}" if valve_key not in valve_groups: valve_groups[valve_key] = { "total_quantity": 0, "materials": [] } valve_groups[valve_key]["total_quantity"] += material_dict["quantity"] valve_groups[valve_key]["materials"].append(material_dict) material_dict['clean_description'] = clean_description elif m.classified_category == 'BOLT': bolt_query = text("SELECT * FROM bolt_details WHERE material_id = :material_id") bolt_result = db.execute(bolt_query, {"material_id": m.id}) bolt_detail = bolt_result.fetchone() if bolt_detail: material_dict['bolt_details'] = { "bolt_type": bolt_detail.bolt_type, "thread_type": bolt_detail.thread_type, "diameter": bolt_detail.diameter, "length": bolt_detail.length, "material_standard": bolt_detail.material_standard, "material_grade": bolt_detail.material_grade, "coating_type": bolt_detail.coating_type, "pressure_rating": bolt_detail.pressure_rating } # 볼트 그룹핑 추가 - 크기, 재질, 길이로 그룹핑 # 원본 설명에서 길이 추출 import re length_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:LG|MM)', m.original_description.upper()) bolt_length = length_match.group(1) if length_match else 'UNKNOWN' bolt_key = f"{m.size_spec}|{m.material_grade}|{bolt_length}" if bolt_key not in bolt_groups: bolt_groups[bolt_key] = { "total_quantity": 0, "materials": [] } bolt_groups[bolt_key]["total_quantity"] += material_dict["quantity"] bolt_groups[bolt_key]["materials"].append(material_dict) # 파이프, 니플, 일반 피팅, 플랜지가 아닌 경우만 바로 추가 (이들은 그룹핑 후 추가) is_nipple = (m.classified_category == 'FITTING' and ('NIPPLE' in m.original_description.upper() or (hasattr(m, 'fitting_type') and m.fitting_type == 'NIPPLE'))) # CAP과 PLUG도 일반 피팅으로 처리 is_cap_or_plug = (m.classified_category == 'FITTING' and ('CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper())) is_general_fitting = (m.classified_category == 'FITTING' and not is_nipple and ((hasattr(m, 'fitting_type') and m.fitting_type is not None) or is_cap_or_plug)) is_flange = (m.classified_category == 'FLANGE') is_valve = (m.classified_category == 'VALVE') is_bolt = (m.classified_category == 'BOLT') is_gasket = (m.classified_category == 'GASKET') # UNKNOWN 카테고리 그룹핑 처리 if m.classified_category == 'UNKNOWN': unknown_key = m.original_description or 'UNKNOWN' if unknown_key not in unknown_groups: unknown_groups[unknown_key] = { "total_quantity": 0, "materials": [] } unknown_groups[unknown_key]["total_quantity"] += material_dict["quantity"] unknown_groups[unknown_key]["materials"].append(material_dict) elif m.classified_category != 'PIPE' and not is_nipple and not is_general_fitting and not is_flange and not is_valve and not is_bolt and not is_gasket: material_list.append(material_dict) # 파이프 그룹별로 대표 파이프 하나만 추가 (그룹핑된 정보로) for pipe_key, group_info in pipe_groups.items(): if group_info["materials"]: # 그룹의 첫 번째 파이프를 대표로 사용 representative_pipe = group_info["materials"][0].copy() # 그룹핑된 정보로 업데이트 representative_pipe['quantity'] = group_info["total_quantity"] representative_pipe['original_description'] = representative_pipe['clean_description'] # 깨끗한 설명 사용 if 'pipe_details' in representative_pipe: representative_pipe['pipe_details']['total_length_mm'] = group_info["total_length_mm"] representative_pipe['pipe_details']['pipe_count'] = group_info["total_quantity"] # ✅ pipe_count 추가 representative_pipe['pipe_details']['group_total_quantity'] = group_info["total_quantity"] # 평균 단위 길이 계산 if group_info["total_quantity"] > 0: representative_pipe['pipe_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"] material_list.append(representative_pipe) # 니플 그룹별로 대표 니플 하나만 추가 (그룹핑된 정보로) try: for nipple_key, group_info in nipple_groups.items(): if group_info["materials"]: # 그룹의 첫 번째 니플을 대표로 사용 representative_nipple = group_info["materials"][0].copy() # 그룹핑된 정보로 업데이트 representative_nipple['quantity'] = group_info["total_quantity"] representative_nipple['original_description'] = representative_nipple.get('clean_description', representative_nipple['original_description']) # 깨끗한 설명 사용 if 'fitting_details' in representative_nipple: representative_nipple['fitting_details']['total_length_mm'] = group_info["total_length_mm"] representative_nipple['fitting_details']['group_total_quantity'] = group_info["total_quantity"] # 평균 단위 길이 계산 if group_info["total_quantity"] > 0: representative_nipple['fitting_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"] material_list.append(representative_nipple) except Exception as nipple_error: # 로그 제거 # 니플 그룹핑 실패시에도 계속 진행 pass # 일반 피팅 그룹별로 대표 피팅 하나만 추가 (그룹핑된 정보로) try: for fitting_key, group_info in fitting_groups.items(): if group_info["materials"]: representative_fitting = group_info["materials"][0].copy() representative_fitting['quantity'] = group_info["total_quantity"] representative_fitting['original_description'] = representative_fitting.get('clean_description', representative_fitting['original_description']) if 'fitting_details' in representative_fitting: representative_fitting['fitting_details']['group_total_quantity'] = group_info["total_quantity"] material_list.append(representative_fitting) except Exception as fitting_error: # 로그 제거 # 피팅 그룹핑 실패시에도 계속 진행 pass # 플랜지 그룹별로 대표 플랜지 하나만 추가 (그룹핑된 정보로) try: for flange_key, group_info in flange_groups.items(): if group_info["materials"]: representative_flange = group_info["materials"][0].copy() representative_flange['quantity'] = group_info["total_quantity"] # original_description은 그대로 유지 (SCH 정보 보존) # representative_flange['original_description'] = representative_flange.get('clean_description', representative_flange['original_description']) if 'flange_details' in representative_flange: representative_flange['flange_details']['group_total_quantity'] = group_info["total_quantity"] material_list.append(representative_flange) except Exception as flange_error: # 플랜지 그룹핑 실패시에도 계속 진행 pass # 밸브 그룹별로 대표 밸브 하나만 추가 (그룹핑된 정보로) print(f"DEBUG: 전체 밸브 수: {valve_count}, valve_groups 수: {len(valve_groups)}") try: for valve_key, group_info in valve_groups.items(): if group_info["materials"]: representative_valve = group_info["materials"][0].copy() representative_valve['quantity'] = group_info["total_quantity"] if 'valve_details' in representative_valve: representative_valve['valve_details']['group_total_quantity'] = group_info["total_quantity"] material_list.append(representative_valve) print(f"DEBUG: 밸브 추가됨 - {valve_key}, 수량: {group_info['total_quantity']}") except Exception as valve_error: print(f"ERROR: 밸브 그룹핑 실패 - {valve_error}") # 밸브 그룹핑 실패시에도 계속 진행 pass # 볼트 그룹별로 대표 볼트 하나만 추가 (그룹핑된 정보로) print(f"DEBUG: bolt_groups 수: {len(bolt_groups)}") try: for bolt_key, group_info in bolt_groups.items(): if group_info["materials"]: representative_bolt = group_info["materials"][0].copy() representative_bolt['quantity'] = group_info["total_quantity"] if 'bolt_details' in representative_bolt: representative_bolt['bolt_details']['group_total_quantity'] = group_info["total_quantity"] material_list.append(representative_bolt) print(f"DEBUG: 볼트 추가됨 - {bolt_key}, 수량: {group_info['total_quantity']}") except Exception as bolt_error: print(f"ERROR: 볼트 그룹핑 실패 - {bolt_error}") # 볼트 그룹핑 실패시에도 계속 진행 pass # 가스켓 그룹별로 대표 가스켓 하나만 추가 (그룹핑된 정보로) print(f"DEBUG: gasket_groups 수: {len(gasket_groups)}") try: for gasket_key, group_info in gasket_groups.items(): if group_info["materials"]: representative_gasket = group_info["materials"][0].copy() representative_gasket['quantity'] = group_info["total_quantity"] if 'gasket_details' in representative_gasket: representative_gasket['gasket_details']['group_total_quantity'] = group_info["total_quantity"] material_list.append(representative_gasket) print(f"DEBUG: 가스켓 추가됨 - {gasket_key}, 수량: {group_info['total_quantity']}") except Exception as gasket_error: print(f"ERROR: 가스켓 그룹핑 실패 - {gasket_error}") # 가스켓 그룹핑 실패시에도 계속 진행 pass # UNKNOWN 그룹별로 대표 항목 하나만 추가 (그룹핑된 정보로) print(f"DEBUG: unknown_groups 수: {len(unknown_groups)}") try: for unknown_key, group_info in unknown_groups.items(): if group_info["materials"]: representative_unknown = group_info["materials"][0].copy() representative_unknown['quantity'] = group_info["total_quantity"] material_list.append(representative_unknown) print(f"DEBUG: UNKNOWN 추가됨 - {unknown_key[:50]}, 수량: {group_info['total_quantity']}") except Exception as unknown_error: print(f"ERROR: UNKNOWN 그룹핑 실패 - {unknown_error}") # UNKNOWN 그룹핑 실패시에도 계속 진행 pass return { "success": True, "total_count": total_count, "returned_count": len(materials), "skip": skip, "limit": limit, "materials": material_list } 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.get("/pipe-details") async def get_pipe_details( file_id: Optional[int] = None, job_no: Optional[str] = None, db: Session = Depends(get_db) ): """ PIPE 상세 정보 조회 """ try: query = """ SELECT pd.*, f.original_filename, f.job_no, f.revision, m.original_description, m.quantity, m.unit FROM pipe_details pd LEFT JOIN files f ON pd.file_id = f.id LEFT JOIN materials m ON pd.file_id = m.file_id AND m.classified_category = 'PIPE' WHERE 1=1 """ params = {} if file_id: query += " AND pd.file_id = :file_id" params["file_id"] = file_id if job_no: query += " AND f.job_no = :job_no" params["job_no"] = job_no query += " ORDER BY pd.created_at DESC" result = db.execute(text(query), params) pipe_details = result.fetchall() return [ { "id": pd.id, "file_id": pd.file_id, "original_filename": pd.original_filename, "job_no": pd.job_no, "revision": pd.revision, "original_description": pd.original_description, "quantity": pd.quantity, "unit": pd.unit, "material_spec": pd.material_spec, "manufacturing_method": pd.manufacturing_method, "end_preparation": pd.end_preparation, "schedule": pd.schedule, "outer_diameter": pd.outer_diameter, "length_mm": pd.length_mm, "created_at": pd.created_at, "updated_at": pd.updated_at } for pd in pipe_details ] except Exception as e: raise HTTPException(status_code=500, detail=f"PIPE 상세 정보 조회 실패: {str(e)}") @router.get("/fitting-details") async def get_fitting_details( file_id: Optional[int] = None, job_no: Optional[str] = None, db: Session = Depends(get_db) ): """ FITTING 상세 정보 조회 """ try: query = """ SELECT fd.*, f.original_filename, f.job_no, f.revision, m.original_description, m.quantity, m.unit FROM fitting_details fd LEFT JOIN files f ON fd.file_id = f.id LEFT JOIN materials m ON fd.material_id = m.id WHERE 1=1 """ params = {} if file_id: query += " AND fd.file_id = :file_id" params["file_id"] = file_id if job_no: query += " AND f.job_no = :job_no" params["job_no"] = job_no query += " ORDER BY fd.created_at DESC" result = db.execute(text(query), params) fitting_details = result.fetchall() return [ { "id": fd.id, "file_id": fd.file_id, "fitting_type": fd.fitting_type, "fitting_subtype": fd.fitting_subtype, "connection_method": fd.connection_method, "pressure_rating": fd.pressure_rating, "material_standard": fd.material_standard, "material_grade": fd.material_grade, "main_size": fd.main_size, "reduced_size": fd.reduced_size, "classification_confidence": fd.classification_confidence, "original_description": fd.original_description, "quantity": fd.quantity } for fd in fitting_details ] except Exception as e: raise HTTPException(status_code=500, detail=f"FITTING 상세 정보 조회 실패: {str(e)}") @router.get("/valve-details") async def get_valve_details( file_id: Optional[int] = None, job_no: Optional[str] = None, db: Session = Depends(get_db) ): """ VALVE 상세 정보 조회 """ try: query = """ SELECT vd.*, f.original_filename, f.job_no, f.revision, m.original_description, m.quantity, m.unit FROM valve_details vd LEFT JOIN files f ON vd.file_id = f.id LEFT JOIN materials m ON vd.material_id = m.id WHERE 1=1 """ params = {} if file_id: query += " AND vd.file_id = :file_id" params["file_id"] = file_id if job_no: query += " AND f.job_no = :job_no" params["job_no"] = job_no query += " ORDER BY vd.created_at DESC" result = db.execute(text(query), params) valve_details = result.fetchall() return [ { "id": vd.id, "file_id": vd.file_id, "valve_type": vd.valve_type, "valve_subtype": vd.valve_subtype, "actuator_type": vd.actuator_type, "connection_method": vd.connection_method, "pressure_rating": vd.pressure_rating, "body_material": vd.body_material, "size_inches": vd.size_inches, "fire_safe": vd.fire_safe, "classification_confidence": vd.classification_confidence, "original_description": vd.original_description, "quantity": vd.quantity } for vd in valve_details ] except Exception as e: raise HTTPException(status_code=500, detail=f"VALVE 상세 정보 조회 실패: {str(e)}") @router.get("/user-requirements") async def get_user_requirements( file_id: Optional[int] = None, job_no: Optional[str] = None, status: Optional[str] = None, db: Session = Depends(get_db) ): """ 사용자 요구사항 조회 """ try: query = """ SELECT ur.*, f.original_filename, f.job_no, f.revision, rt.type_name, rt.category FROM user_requirements ur LEFT JOIN files f ON ur.file_id = f.id LEFT JOIN requirement_types rt ON ur.requirement_type = rt.type_code WHERE 1=1 """ params = {} if file_id: query += " AND ur.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 status: query += " AND ur.status = :status" params["status"] = status query += " ORDER BY ur.created_at DESC" result = db.execute(text(query), params) requirements = result.fetchall() return [ { "id": req.id, "file_id": req.file_id, "original_filename": req.original_filename, "job_no": req.job_no, "revision": req.revision, "requirement_type": req.requirement_type, "type_name": req.type_name, "category": req.category, "requirement_title": req.requirement_title, "requirement_description": req.requirement_description, "requirement_spec": req.requirement_spec, "status": req.status, "priority": req.priority, "assigned_to": req.assigned_to, "due_date": req.due_date, "created_at": req.created_at, "updated_at": req.updated_at } for req in requirements ] except Exception as e: raise HTTPException(status_code=500, detail=f"사용자 요구사항 조회 실패: {str(e)}") @router.post("/user-requirements") async def create_user_requirement( file_id: int, requirement_type: str, requirement_title: str, requirement_description: Optional[str] = None, requirement_spec: Optional[str] = None, priority: str = "NORMAL", assigned_to: Optional[str] = None, due_date: Optional[str] = None, db: Session = Depends(get_db) ): """ 사용자 요구사항 생성 """ try: insert_query = text(""" INSERT INTO user_requirements ( file_id, requirement_type, requirement_title, requirement_description, requirement_spec, priority, assigned_to, due_date ) VALUES ( :file_id, :requirement_type, :requirement_title, :requirement_description, :requirement_spec, :priority, :assigned_to, :due_date ) RETURNING id """) result = db.execute(insert_query, { "file_id": file_id, "requirement_type": requirement_type, "requirement_title": requirement_title, "requirement_description": requirement_description, "requirement_spec": requirement_spec, "priority": priority, "assigned_to": assigned_to, "due_date": due_date }) requirement_id = result.fetchone()[0] db.commit() return { "success": True, "message": "요구사항이 생성되었습니다", "requirement_id": requirement_id } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"요구사항 생성 실패: {str(e)}") @router.post("/materials/{material_id}/verify") async def verify_material_classification( material_id: int, request: Request, verified_category: Optional[str] = None, db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """ 자재 분류 결과 검증 """ try: username = current_user.get('username', 'unknown') # 자재 존재 확인 material_query = text("SELECT * FROM materials WHERE id = :material_id") material_result = db.execute(material_query, {"material_id": material_id}) material = material_result.fetchone() if not material: raise HTTPException(status_code=404, detail="자재를 찾을 수 없습니다") # 검증 정보 업데이트 update_query = text(""" UPDATE materials SET is_verified = TRUE, verified_by = :username, verified_at = CURRENT_TIMESTAMP, classified_category = COALESCE(:verified_category, classified_category) WHERE id = :material_id """) db.execute(update_query, { "material_id": material_id, "username": username, "verified_category": verified_category }) # 활동 로그 기록 try: from ..services.activity_logger import log_activity_from_request log_activity_from_request( db, request, username, "MATERIAL_VERIFY", f"자재 분류 검증: {material.original_description}" ) except Exception as e: print(f"활동 로그 기록 실패: {str(e)}") db.commit() return { "success": True, "message": "자재 분류가 검증되었습니다", "material_id": material_id, "verified_by": username } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"자재 검증 실패: {str(e)}") @router.put("/materials/{material_id}/update-classification") async def update_material_classification( material_id: int, request: Request, classified_category: str = Form(...), classified_subcategory: str = Form(None), material_grade: str = Form(None), schedule: str = Form(None), size_spec: str = Form(None), db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """BOM 관리 페이지에서 사용자가 분류를 수정하는 API""" try: username = current_user.get("username", "unknown") # 자재 존재 확인 check_query = text("SELECT id, original_description FROM materials WHERE id = :material_id") result = db.execute(check_query, {"material_id": material_id}) material = result.fetchone() if not material: raise HTTPException(status_code=404, detail="자재를 찾을 수 없습니다") # 분류 정보 업데이트 update_query = text(""" UPDATE materials SET classified_category = :classified_category, classified_subcategory = :classified_subcategory, material_grade = :material_grade, schedule = :schedule, size_spec = :size_spec, is_verified = true, verified_by = :verified_by, verified_at = NOW(), updated_at = NOW() WHERE id = :material_id """) db.execute(update_query, { "material_id": material_id, "classified_category": classified_category, "classified_subcategory": classified_subcategory or "", "material_grade": material_grade or "", "schedule": schedule or "", "size_spec": size_spec or "", "verified_by": username }) db.commit() # 활동 로그 기록 await log_activity_from_request( request, db, "material_classification_update", f"자재 분류 수정: {material.original_description} -> {classified_category}", {"material_id": material_id, "category": classified_category} ) return { "success": True, "message": "자재 분류가 성공적으로 업데이트되었습니다", "material_id": material_id, "classified_category": classified_category } except Exception as e: db.rollback() print(f"자재 분류 업데이트 실패: {str(e)}") raise HTTPException(status_code=500, detail=f"자재 분류 업데이트 실패: {str(e)}") @router.post("/materials/confirm-purchase") async def confirm_material_purchase_api( request: Request, job_no: str = Query(...), revision: str = Query(...), confirmed_by: str = Query("user"), confirmations_data: List[Dict] = Body(...), db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): """자재 구매수량 확정 API (프론트엔드 호환)""" try: # 입력 데이터 검증 if not job_no or not revision: raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다") if not confirmations_data: raise HTTPException(status_code=400, detail="확정할 자재가 없습니다") # 각 확정 항목 검증 for i, confirmation in enumerate(confirmations_data): if not confirmation.get("material_hash"): raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다") confirmed_qty = confirmation.get("confirmed_quantity") if confirmed_qty is None or confirmed_qty < 0: raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다") confirmed_items = [] for confirmation in confirmations_data: # 발주 추적 테이블에 저장/업데이트 upsert_query = text(""" INSERT INTO material_purchase_tracking ( job_no, material_hash, revision, description, size_spec, unit, bom_quantity, calculated_quantity, confirmed_quantity, purchase_status, supplier_name, unit_price, total_price, confirmed_by, confirmed_at ) SELECT :job_no, m.material_hash, :revision, m.original_description, m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty, 'CONFIRMED', :supplier_name, :unit_price, :total_price, :confirmed_by, CURRENT_TIMESTAMP FROM materials m WHERE m.material_hash = :material_hash AND m.file_id = ( SELECT id FROM files WHERE job_no = :job_no AND revision = :revision ORDER BY upload_date DESC LIMIT 1 ) LIMIT 1 ON CONFLICT (job_no, material_hash, revision) DO UPDATE SET confirmed_quantity = :confirmed_qty, purchase_status = 'CONFIRMED', supplier_name = :supplier_name, unit_price = :unit_price, total_price = :total_price, confirmed_by = :confirmed_by, confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP RETURNING id, description, confirmed_quantity """) calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"]) total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0) result = db.execute(upsert_query, { "job_no": job_no, "revision": revision, "material_hash": confirmation["material_hash"], "calculated_qty": calculated_qty, "confirmed_qty": confirmation["confirmed_quantity"], "supplier_name": confirmation.get("supplier_name", ""), "unit_price": confirmation.get("unit_price", 0), "total_price": total_price, "confirmed_by": confirmed_by }) confirmed_item = result.fetchone() if confirmed_item: confirmed_items.append({ "id": confirmed_item[0], "description": confirmed_item[1], "confirmed_quantity": confirmed_item[2] }) db.commit() # 활동 로그 기록 await log_activity_from_request( request, db, "material_purchase_confirm", f"구매수량 확정: {job_no} {revision} - {len(confirmed_items)}개 품목", {"job_no": job_no, "revision": revision, "items_count": len(confirmed_items)} ) return { "success": True, "message": f"{len(confirmed_items)}개 품목의 구매수량이 확정되었습니다", "job_no": job_no, "revision": revision, "confirmed_items": confirmed_items } except Exception as e: db.rollback() print(f"구매수량 확정 실패: {str(e)}") raise HTTPException(status_code=500, detail=f"구매수량 확정 실패: {str(e)}")