diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 7bbc61d..f2ef8aa 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -374,15 +374,13 @@ async def upload_file( pipe_insert_query = text(""" INSERT INTO pipe_details ( - material_id, file_id, size_inches, schedule_type, material_spec, - manufacturing_method, length_mm, outer_diameter_mm, wall_thickness_mm, - weight_per_meter_kg, classification_confidence, additional_info + 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, :size_inches, :schedule_type, :material_spec, - :manufacturing_method, :length_mm, :outer_diameter_mm, :wall_thickness_mm, - :weight_per_meter_kg, :classification_confidence, :additional_info + :file_id, :outer_diameter, :schedule, + :material_spec, :manufacturing_method, :length_mm ) """) @@ -390,16 +388,12 @@ async def upload_file( "file_id": file_id, "description": material_data["original_description"], "row_number": material_data["row_number"], - "size_inches": pipe_info.get('nominal_diameter', ''), - "schedule_type": pipe_info.get('schedule', ''), + "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, - "outer_diameter_mm": pipe_info.get('outer_diameter_mm'), - "wall_thickness_mm": pipe_info.get('wall_thickness_mm'), - "weight_per_meter_kg": pipe_info.get('weight_per_meter_kg'), - "classification_confidence": classification_result.get('overall_confidence', 0.0), - "additional_info": json.dumps(pipe_info, ensure_ascii=False) + }) print(f"PIPE 상세정보 저장 완료: {material_data['original_description']}") @@ -713,7 +707,7 @@ async def get_materials( try: query = """ SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, - m.size_spec, m.material_grade, m.line_number, m.row_number, + 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, diff --git a/backend/app/main.py b/backend/app/main.py index fa68e31..661f54f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -236,656 +236,9 @@ async def root(): # print(f"Jobs 조회 에러: {str(e)}") # return {"error": f"Jobs 조회 실패: {str(e)}"} -# 파일 업로드 API -@app.post("/upload") -async def upload_file( - file: UploadFile = File(...), - job_no: str = Form(...), # project_id 대신 job_no 사용 - bom_name: str = Form(""), # BOM 이름 추가 - bom_type: str = Form(""), - revision: str = Form("Rev.0"), - parent_bom_id: Optional[int] = Form(None), - description: str = Form(""), - db: Session = Depends(get_db) -): - """파일 업로드 및 자재 분류 (자동 리비전 관리)""" - try: - print("=== main.py 업로드 API 호출됨 ===") - print(f"파일명: {file.filename}") - print(f"job_no: {job_no}") - print(f"bom_name: {bom_name}") - print(f"bom_type: {bom_type}") - - # job_no로 job 확인 - job_query = text("SELECT job_no FROM jobs WHERE job_no = :job_no AND is_active = true") - job_result = db.execute(job_query, {"job_no": job_no}) - job = job_result.fetchone() - - if not job: - return {"error": f"Job No. '{job_no}'에 해당하는 작업을 찾을 수 없습니다."} - - # 업로드 디렉토리 생성 - upload_dir = "uploads" - os.makedirs(upload_dir, exist_ok=True) - - # 파일 저장 - if file.filename: - file_path = os.path.join(upload_dir, file.filename) - print(f"파일 저장 경로: {file_path}") - print(f"원본 파일명: {file.filename}") - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - print(f"파일 저장 완료: {file_path}") - else: - return {"error": "파일명이 없습니다."} - - # 파일 크기 계산 - file_size = os.path.getsize(file_path) - - # 파일 타입 결정 - file_type = "excel" if file.filename.endswith(('.xls', '.xlsx')) else "csv" if file.filename.endswith('.csv') else "unknown" - - # BOM 종류별 자동 리비전 관리 - if bom_name and not parent_bom_id: - # 같은 job_no의 같은 BOM 이름에 대한 최신 리비전 조회 - latest_revision_query = text(""" - SELECT revision FROM files - WHERE job_no = :job_no AND bom_name = :bom_name - ORDER BY revision DESC LIMIT 1 - """) - - result = db.execute(latest_revision_query, { - "job_no": job_no, - "bom_name": bom_name - }) - - latest_file = result.fetchone() - if latest_file: - # 기존 리비전이 있으면 다음 리비전 번호 생성 - current_rev = latest_file.revision - if current_rev.startswith("Rev."): - try: - rev_num = int(current_rev.replace("Rev.", "")) - revision = f"Rev.{rev_num + 1}" - except ValueError: - revision = "Rev.1" - else: - revision = "Rev.1" - else: - # 첫 번째 업로드인 경우 Rev.0 - revision = "Rev.0" - - # 데이터베이스에 파일 정보 저장 - insert_query = text(""" - INSERT INTO files ( - job_no, filename, original_filename, file_path, - file_size, upload_date, revision, file_type, uploaded_by, bom_name - ) VALUES ( - :job_no, :filename, :original_filename, :file_path, - :file_size, NOW(), :revision, :file_type, :uploaded_by, :bom_name - ) RETURNING id - """) - - result = db.execute(insert_query, { - "job_no": job_no, - "filename": file.filename, - "original_filename": file.filename, - "file_path": file_path, - "file_size": file_size, - "revision": revision, - "file_type": file_type, - "uploaded_by": "system", - "bom_name": bom_name - }) - - file_id = result.fetchone()[0] - - # 1차: 파일 파싱 (CSV/Excel 파일 읽기) - materials_data = parse_file(file_path) - - # 2차: 각 자재를 분류기로 분류 - classified_materials = [] - - # 리비전 업로드인 경우 기존 분류 정보 가져오기 - existing_classifications = {} - if parent_bom_id: - parent_materials = db.execute( - text("SELECT original_description, classified_category, classified_subcategory, material_grade, schedule, size_spec FROM materials WHERE file_id = :file_id"), - {"file_id": parent_bom_id} - ).fetchall() - - for material in parent_materials: - existing_classifications[material.original_description] = { - "classified_category": material.classified_category, - "classified_subcategory": material.classified_subcategory, - "material_grade": material.material_grade, - "schedule": material.schedule, - "size_spec": material.size_spec - } - - for material in materials_data: - # 리비전 업로드인 경우 기존 분류 사용, 아니면 새로 분류 - if parent_bom_id and material.get("original_description") in existing_classifications: - existing_class = existing_classifications[material.get("original_description")] - classified_material = { - **material, - **existing_class, - "classification_confidence": 1.0 # 기존 분류이므로 높은 신뢰도 - } - else: - classified_material = classify_material_item(material) - classified_materials.append(classified_material) - - # 3차: 분류된 자재를 데이터베이스에 저장 - for material in classified_materials: - insert_material_query = text(""" - INSERT INTO materials ( - file_id, line_number, original_description, - classified_category, classified_subcategory, - material_grade, schedule, size_spec, - quantity, unit, drawing_name, area_code, line_no, - classification_confidence, is_verified, created_at - ) VALUES ( - :file_id, :line_number, :original_description, - :classified_category, :classified_subcategory, - :material_grade, :schedule, :size_spec, - :quantity, :unit, :drawing_name, :area_code, :line_no, - :classification_confidence, :is_verified, NOW() - ) - RETURNING id - """) - - result = db.execute(insert_material_query, { - "file_id": file_id, - "line_number": material.get("line_number", 0), - "original_description": material.get("original_description", ""), - "classified_category": material.get("classified_category", ""), - "classified_subcategory": material.get("classified_subcategory", ""), - "material_grade": material.get("material_grade", ""), - "schedule": material.get("schedule", ""), - "size_spec": material.get("size_spec", ""), - "quantity": material.get("quantity", 0), - "unit": material.get("unit", ""), - "drawing_name": material.get("drawing_name", ""), - "area_code": material.get("area_code", ""), - "line_no": material.get("line_no", ""), - "classification_confidence": material.get("classification_confidence", 0.0), - "is_verified": False - }) - - # 저장된 material의 ID 가져오기 - material_id = result.fetchone()[0] - - # 카테고리별 상세 정보 저장 - category = material.get("classified_category", "") - - if category == "PIPE" and "pipe_details" in material: - pipe_details = material["pipe_details"] - pipe_insert_query = text(""" - INSERT INTO pipe_details ( - material_id, file_id, nominal_size, schedule, - material_standard, material_grade, material_type, - manufacturing_method, length_mm - ) VALUES ( - :material_id, :file_id, :nominal_size, :schedule, - :material_standard, :material_grade, :material_type, - :manufacturing_method, :length_mm - ) - """) - db.execute(pipe_insert_query, { - "material_id": material_id, - "file_id": file_id, - "nominal_size": material.get("size_spec", ""), - "schedule": pipe_details.get("schedule", material.get("schedule", "")), - "material_standard": pipe_details.get("material_spec", material.get("material_grade", "")), - "material_grade": material.get("material_grade", ""), - "material_type": material.get("material_grade", "").split("-")[0] if material.get("material_grade", "") else "", - "manufacturing_method": pipe_details.get("manufacturing_method", ""), - "length_mm": material.get("length", 0.0) if material.get("length", 0.0) else 0.0 # 이미 mm 단위임 - }) - - elif category == "FITTING" and "fitting_details" in material: - fitting_details = material["fitting_details"] - fitting_insert_query = 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 - ) VALUES ( - :material_id, :file_id, :fitting_type, :fitting_subtype, - :connection_method, :pressure_rating, :material_standard, - :material_grade, :main_size, :reduced_size - ) - """) - db.execute(fitting_insert_query, { - "material_id": material_id, - "file_id": file_id, - "fitting_type": fitting_details.get("fitting_type", ""), - "fitting_subtype": fitting_details.get("fitting_subtype", ""), - "connection_method": fitting_details.get("connection_method", ""), - "pressure_rating": fitting_details.get("pressure_rating", ""), - "material_standard": fitting_details.get("material_standard", material.get("material_grade", "")), - "material_grade": fitting_details.get("material_grade", material.get("material_grade", "")), - "main_size": material.get("size_spec", ""), - "reduced_size": fitting_details.get("reduced_size", "") - }) - - elif category == "VALVE" and "valve_details" in material: - valve_details = material["valve_details"] - valve_insert_query = text(""" - INSERT INTO valve_details ( - material_id, file_id, valve_type, valve_subtype, - actuator_type, connection_method, pressure_rating, - body_material, size_inches - ) VALUES ( - :material_id, :file_id, :valve_type, :valve_subtype, - :actuator_type, :connection_method, :pressure_rating, - :body_material, :size_inches - ) - """) - db.execute(valve_insert_query, { - "material_id": material_id, - "file_id": file_id, - "valve_type": valve_details.get("valve_type", ""), - "valve_subtype": valve_details.get("valve_subtype", ""), - "actuator_type": valve_details.get("actuator_type", "MANUAL"), - "connection_method": valve_details.get("connection_method", ""), - "pressure_rating": valve_details.get("pressure_rating", ""), - "body_material": material.get("material_grade", ""), - "size_inches": material.get("size_spec", "") - }) - - elif category == "FLANGE" and "flange_details" in material: - flange_details = material["flange_details"] - flange_insert_query = text(""" - INSERT INTO flange_details ( - material_id, file_id, flange_type, flange_subtype, - facing_type, pressure_rating, material_standard, - material_grade, size_inches - ) VALUES ( - :material_id, :file_id, :flange_type, :flange_subtype, - :facing_type, :pressure_rating, :material_standard, - :material_grade, :size_inches - ) - """) - db.execute(flange_insert_query, { - "material_id": material_id, - "file_id": file_id, - "flange_type": flange_details.get("flange_type", ""), - "flange_subtype": flange_details.get("flange_subtype", ""), - "facing_type": flange_details.get("facing_type", ""), - "pressure_rating": flange_details.get("pressure_rating", ""), - "material_standard": material.get("material_grade", ""), - "material_grade": material.get("material_grade", ""), - "size_inches": material.get("size_spec", "") - }) - - elif category == "BOLT" and "bolt_details" in material: - bolt_details = material["bolt_details"] - bolt_insert_query = text(""" - INSERT INTO bolt_details ( - material_id, file_id, bolt_type, bolt_subtype, - thread_standard, diameter, length, thread_pitch, - material_standard, material_grade, coating - ) VALUES ( - :material_id, :file_id, :bolt_type, :bolt_subtype, - :thread_standard, :diameter, :length, :thread_pitch, - :material_standard, :material_grade, :coating - ) - """) - db.execute(bolt_insert_query, { - "material_id": material_id, - "file_id": file_id, - "bolt_type": bolt_details.get("bolt_type", ""), - "bolt_subtype": bolt_details.get("bolt_subtype", ""), - "thread_standard": bolt_details.get("thread_standard", ""), - "diameter": material.get("size_spec", ""), - "length": bolt_details.get("length", ""), - "thread_pitch": bolt_details.get("thread_pitch", ""), - "material_standard": material.get("material_grade", ""), - "material_grade": material.get("material_grade", ""), - "coating": bolt_details.get("coating", "") - }) - - elif category == "GASKET" and "gasket_details" in material: - gasket_details = material["gasket_details"] - gasket_insert_query = text(""" - INSERT INTO gasket_details ( - material_id, file_id, gasket_type, gasket_material, - flange_size, pressure_rating, temperature_range, - thickness, inner_diameter, outer_diameter - ) VALUES ( - :material_id, :file_id, :gasket_type, :gasket_material, - :flange_size, :pressure_rating, :temperature_range, - :thickness, :inner_diameter, :outer_diameter - ) - """) - db.execute(gasket_insert_query, { - "material_id": material_id, - "file_id": file_id, - "gasket_type": gasket_details.get("gasket_type", ""), - "gasket_material": gasket_details.get("gasket_material", ""), - "flange_size": material.get("size_spec", ""), - "pressure_rating": gasket_details.get("pressure_rating", ""), - "temperature_range": gasket_details.get("temperature_range", ""), - "thickness": gasket_details.get("thickness", ""), - "inner_diameter": gasket_details.get("inner_diameter", ""), - "outer_diameter": gasket_details.get("outer_diameter", "") - }) - - elif category == "INSTRUMENT" and "instrument_details" in material: - instrument_details = material["instrument_details"] - instrument_insert_query = text(""" - INSERT INTO instrument_details ( - material_id, file_id, instrument_type, measurement_type, - measurement_range, output_signal, connection_size, - process_connection, accuracy_class - ) VALUES ( - :material_id, :file_id, :instrument_type, :measurement_type, - :measurement_range, :output_signal, :connection_size, - :process_connection, :accuracy_class - ) - """) - db.execute(instrument_insert_query, { - "material_id": material_id, - "file_id": file_id, - "instrument_type": instrument_details.get("instrument_type", ""), - "measurement_type": instrument_details.get("measurement_type", ""), - "measurement_range": instrument_details.get("measurement_range", ""), - "output_signal": instrument_details.get("output_signal", ""), - "connection_size": material.get("size_spec", ""), - "process_connection": instrument_details.get("process_connection", ""), - "accuracy_class": instrument_details.get("accuracy_class", "") - }) - - db.commit() - - return { - "success": True, - "file_id": file_id, - "filename": file.filename, - "materials_count": len(classified_materials), - "revision": revision, - "message": f"파일이 성공적으로 업로드되고 {len(classified_materials)}개의 자재가 분류되었습니다. (리비전: {revision})" - } - except Exception as e: - db.rollback() - print(f"업로드 실패: {str(e)}") - # HTTP 400 에러로 변경 - from fastapi import HTTPException - raise HTTPException(status_code=400, detail=f"파일 업로드 및 분류 실패: {str(e)}") +# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py) -def parse_file(file_path: str) -> List[Dict]: - """파일 파싱 (CSV/Excel)""" - import pandas as pd - import os - - try: - print(f"parse_file 호출됨: {file_path}") - print(f"파일 존재 여부: {os.path.exists(file_path)}") - print(f"파일 확장자: {os.path.splitext(file_path)[1]}") - - # 파일 확장자를 소문자로 변환하여 검증 - file_extension = os.path.splitext(file_path)[1].lower() - print(f"소문자 변환된 확장자: {file_extension}") - - if file_extension == '.csv': - df = pd.read_csv(file_path) - elif file_extension in ['.xls', '.xlsx']: - df = pd.read_excel(file_path) - else: - print(f"지원되지 않는 파일 형식: {file_path}") - print(f"파일 확장자: {file_extension}") - raise ValueError("지원하지 않는 파일 형식입니다.") - - print(f"파일 파싱 시작: {file_path}") - print(f"데이터프레임 형태: {df.shape}") - print(f"컬럼명: {list(df.columns)}") - - # 컬럼명 매핑 (대소문자 구분 없이) - column_mapping = { - 'description': ['DESCRIPTION', 'Description', 'description', 'DESC', 'Desc', 'desc', 'ITEM', 'Item', 'item'], - 'quantity': ['QTY', 'Quantity', 'quantity', 'QTY.', 'Qty', 'qty', 'AMOUNT', 'Amount', 'amount'], - 'length': ['LENGTH', 'Length', 'length', 'LG', 'Lg', 'lg', 'LENGTH_MM', 'Length_mm', 'length_mm'], - 'unit': ['UNIT', 'Unit', 'unit', 'UOM', 'Uom', 'uom'], - 'size': ['SIZE', 'Size', 'size', 'NOM_SIZE', 'Nom_Size', 'nom_size', 'MAIN_NOM', 'Main_Nom', 'main_nom'], - 'drawing': ['DRAWING', 'Drawing', 'drawing', 'DWG', 'Dwg', 'dwg'], - 'area': ['AREA', 'Area', 'area', 'AREA_CODE', 'Area_Code', 'area_code'], - 'line': ['LINE', 'Line', 'line', 'LINE_NO', 'Line_No', 'line_no', 'PIPELINE', 'Pipeline', 'pipeline'] - } - - # 실제 컬럼명 찾기 - found_columns = {} - for target_col, possible_names in column_mapping.items(): - for col_name in possible_names: - if col_name in df.columns: - found_columns[target_col] = col_name - break - - print(f"찾은 컬럼 매핑: {found_columns}") - - materials = [] - for index, row in df.iterrows(): - # 빈 행 건너뛰기 - if row.isna().all(): - continue - - # 안전한 값 추출 - description = str(row.get(found_columns.get('description', ''), '') or '') - quantity_raw = row.get(found_columns.get('quantity', 1), 1) - quantity = float(quantity_raw) if quantity_raw is not None else 1.0 - length_raw = row.get(found_columns.get('length', 0), 0) - length = float(length_raw) if length_raw is not None else 0.0 - unit = str(row.get(found_columns.get('unit', 'EA'), 'EA') or 'EA') - size = str(row.get(found_columns.get('size', ''), '') or '') - drawing = str(row.get(found_columns.get('drawing', ''), '') or '') - area = str(row.get(found_columns.get('area', ''), '') or '') - line = str(row.get(found_columns.get('line', ''), '') or '') - - material = { - "line_number": index + 1, - "original_description": description, - "quantity": quantity, - "length": length, - "unit": unit, - "size_spec": size, - "drawing_name": drawing, - "area_code": area, - "line_no": line - } - - # 빈 설명은 건너뛰기 - if not material["original_description"] or material["original_description"].strip() == '': - continue - - materials.append(material) - - print(f"파싱된 자재 수: {len(materials)}") - if materials: - print(f"첫 번째 자재 예시: {materials[0]}") - - return materials - except Exception as e: - print(f"파일 파싱 오류: {str(e)}") - raise Exception(f"파일 파싱 실패: {str(e)}") - -def classify_material_item(material: Dict) -> Dict: - """개별 자재 분류""" - from .services import ( - pipe_classifier, fitting_classifier, bolt_classifier, - valve_classifier, instrument_classifier, flange_classifier, - gasket_classifier, material_classifier - ) - - description = material.get("original_description", "") - size_spec = material.get("size_spec", "") - length = material.get("length", 0.0) # 길이 정보 추가 - - print(f"분류 시도: {description}") - - # 각 분류기로 분류 시도 (개선된 순서와 기준) - desc_upper = description.upper() - - # 1. 명확한 키워드 우선 확인 (높은 신뢰도) - if any(keyword in desc_upper for keyword in ['FLG', 'FLANGE', '플랜지', 'RF', 'WN', 'SO', 'BLIND']): - classification_result = flange_classifier.classify_flange("", description, size_spec, length) - print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - elif any(keyword in desc_upper for keyword in ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', '밸브', '게이트', '볼']): - classification_result = valve_classifier.classify_valve("", description, size_spec, length) - print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - elif any(keyword in desc_upper for keyword in ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'CAP', 'COUPLING', '엘보', '티', '리듀서', '캡']): - classification_result = fitting_classifier.classify_fitting("", description, size_spec, length) - print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - elif any(keyword in desc_upper for keyword in ['BOLT', 'STUD', 'NUT', 'SCREW', '볼트', '너트', '스터드']): - classification_result = bolt_classifier.classify_bolt("", description, size_spec, length) - print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - elif any(keyword in desc_upper for keyword in ['GASKET', 'GASK', '가스켓']): - classification_result = gasket_classifier.classify_gasket("", description, size_spec, length) - print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - elif any(keyword in desc_upper for keyword in ['GAUGE', 'SENSOR', 'TRANSMITTER', 'INSTRUMENT', '계기', '게이지']): - classification_result = instrument_classifier.classify_instrument("", description, size_spec, length) - print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - elif any(keyword in desc_upper for keyword in ['PIPE', 'TUBE', '파이프', '배관']): - classification_result = pipe_classifier.classify_pipe("", description, size_spec, length) - print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - else: - # 2. 일반적인 분류 시도 (낮은 신뢰도 임계값) - classification_result = flange_classifier.classify_flange("", description, size_spec, length) - print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.3: - classification_result = valve_classifier.classify_valve("", description, size_spec, length) - print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.3: - classification_result = fitting_classifier.classify_fitting("", description, size_spec, length) - print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.3: - classification_result = pipe_classifier.classify_pipe("", description, size_spec, length) - print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.3: - classification_result = bolt_classifier.classify_bolt("", description, size_spec, length) - print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.3: - classification_result = gasket_classifier.classify_gasket("", description, size_spec, length) - print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.3: - classification_result = instrument_classifier.classify_instrument("", description, size_spec, length) - print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") - - # 재질 분류 - material_result = material_classifier.classify_material(description) - - # 최종 결과 조합 - # schedule이 딕셔너리인 경우 문자열로 변환 - schedule_value = classification_result.get("schedule", "") - if isinstance(schedule_value, dict): - schedule_value = schedule_value.get("schedule", "") - - final_result = { - **material, - "classified_category": classification_result.get("category", "UNKNOWN"), - "classified_subcategory": classification_result.get("subcategory", ""), - "material_grade": material_result.get("grade", "") if material_result else "", - "schedule": schedule_value, - "size_spec": classification_result.get("size_spec", ""), - "classification_confidence": classification_result.get("overall_confidence", 0.0), - "length": length # 길이 정보 추가 - } - - # 카테고리별 상세 정보 추가 - category = classification_result.get("category", "") - - if category == "PIPE": - # PIPE 상세 정보 추출 - final_result["pipe_details"] = { - "size_inches": size_spec, - "schedule": classification_result.get("schedule", {}).get("schedule", ""), - "material_spec": classification_result.get("material", {}).get("standard", ""), - "manufacturing_method": classification_result.get("manufacturing", {}).get("method", ""), - "length_mm": length * 1000 if length else 0, # meter to mm - "outer_diameter_mm": 0.0, # 추후 계산 - "wall_thickness_mm": 0.0, # 추후 계산 - "weight_per_meter_kg": 0.0 # 추후 계산 - } - elif category == "FITTING": - # FITTING 상세 정보 추출 - final_result["fitting_details"] = { - "fitting_type": classification_result.get("fitting_type", {}).get("type", ""), - "fitting_subtype": classification_result.get("fitting_type", {}).get("subtype", ""), - "connection_method": classification_result.get("connection_method", {}).get("method", ""), - "pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""), - "material_standard": classification_result.get("material", {}).get("standard", ""), - "material_grade": classification_result.get("material", {}).get("grade", ""), - "main_size": size_spec, - "reduced_size": "" - } - elif category == "VALVE": - # VALVE 상세 정보 추출 - final_result["valve_details"] = { - "valve_type": classification_result.get("valve_type", {}).get("type", ""), - "valve_subtype": classification_result.get("valve_type", {}).get("subtype", ""), - "actuator_type": classification_result.get("actuation", {}).get("method", "MANUAL"), - "connection_method": classification_result.get("connection_method", {}).get("method", ""), - "pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""), - "body_material": classification_result.get("material", {}).get("grade", ""), - "size_inches": size_spec - } - elif category == "FLANGE": - # FLANGE 상세 정보 추출 - final_result["flange_details"] = { - "flange_type": classification_result.get("flange_type", {}).get("type", ""), - "flange_subtype": classification_result.get("flange_type", {}).get("subtype", ""), - "facing_type": classification_result.get("face_finish", {}).get("finish", ""), - "pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""), - "material_standard": classification_result.get("material", {}).get("standard", ""), - "material_grade": classification_result.get("material", {}).get("grade", ""), - "size_inches": size_spec - } - elif category == "BOLT": - # BOLT 상세 정보 추출 - final_result["bolt_details"] = { - "bolt_type": classification_result.get("fastener_type", {}).get("type", ""), - "bolt_subtype": classification_result.get("fastener_type", {}).get("subtype", ""), - "thread_standard": classification_result.get("thread_specification", {}).get("standard", ""), - "diameter": classification_result.get("dimensions", {}).get("diameter", size_spec), - "length": classification_result.get("dimensions", {}).get("length", ""), - "thread_pitch": classification_result.get("thread_specification", {}).get("pitch", ""), - "material_standard": classification_result.get("material", {}).get("standard", ""), - "material_grade": classification_result.get("material", {}).get("grade", ""), - "coating": "" - } - elif category == "GASKET": - # GASKET 상세 정보 추출 - final_result["gasket_details"] = { - "gasket_type": classification_result.get("gasket_type", {}).get("type", ""), - "gasket_material": classification_result.get("gasket_material", {}).get("material", ""), - "flange_size": size_spec, - "pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""), - "temperature_range": classification_result.get("gasket_material", {}).get("temperature_range", ""), - "thickness": classification_result.get("size_info", {}).get("thickness", ""), - "inner_diameter": classification_result.get("size_info", {}).get("inner_diameter", ""), - "outer_diameter": classification_result.get("size_info", {}).get("outer_diameter", "") - } - elif category == "INSTRUMENT": - # INSTRUMENT 상세 정보 추출 - final_result["instrument_details"] = { - "instrument_type": classification_result.get("instrument_type", {}).get("type", ""), - "measurement_type": "", - "measurement_range": classification_result.get("measurement_info", {}).get("range", ""), - "output_signal": classification_result.get("measurement_info", {}).get("signal_type", ""), - "connection_size": size_spec, - "process_connection": "", - "accuracy_class": "" - } - - return final_result +# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다 @app.get("/health") async def health_check(): diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 9864070..e4218b2 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -104,13 +104,19 @@ def parse_dataframe(df): material_grade = "" if "ASTM" in description.upper(): - astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', 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 != '': @@ -133,6 +139,8 @@ def parse_dataframe(df): '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, @@ -230,32 +238,60 @@ async def upload_file( except (ValueError, TypeError): length_value = None - classification_result = classify_pipe("", description, size_spec, length_value) - print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + # main_nom과 red_nom 추출 + main_nom = material_data.get("main_nom") + red_nom = material_data.get("red_nom") - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_fitting("", description, size_spec) - print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_valve("", description, size_spec) - print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_flange("", description, size_spec) - print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_bolt("", description, size_spec) - print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_gasket("", description, size_spec) - print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") - - if classification_result.get("overall_confidence", 0) < 0.5: - classification_result = classify_instrument("", description, size_spec) - print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + classification_result = None + try: + # EXCLUDE 분류기 우선 호출 (제외 대상 먼저 걸러냄) + from app.services.exclude_classifier import classify_exclude + classification_result = classify_exclude("", description, main_nom or "") + print(f"EXCLUDE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + # 파이프 분류기 호출 + classification_result = classify_pipe("", description, main_nom or "", length_value) + print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + # 피팅 분류기 호출 (main_nom, red_nom 개별 전달) + classification_result = classify_fitting("", description, main_nom or "", red_nom) + print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + # 플랜지 분류기 호출 (main_nom, red_nom 개별 전달) + classification_result = classify_flange("", description, main_nom or "", red_nom) + print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + # 밸브 분류기 호출 + classification_result = classify_valve("", description, main_nom or "") + print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + # 볼트 분류기 호출 + classification_result = classify_bolt("", description, main_nom or "") + print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + # 가스켓 분류기 호출 + classification_result = classify_gasket("", description, main_nom or "") + print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + # 계기 분류기 호출 + classification_result = classify_instrument("", description, main_nom or "") + print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + except 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')}") @@ -263,13 +299,13 @@ async def upload_file( 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, is_verified, created_at + 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, - :material_grade, :line_number, :row_number, :classified_category, - :classification_confidence, :is_verified, :created_at + :main_nom, :red_nom, :material_grade, :line_number, :row_number, + :classified_category, :classification_confidence, :is_verified, :created_at ) RETURNING id """) @@ -287,6 +323,8 @@ async def upload_file( "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"], @@ -309,16 +347,11 @@ async def upload_file( # material_id도 함께 저장하도록 수정 pipe_detail_insert_query = text(""" INSERT INTO pipe_details ( - material_id, file_id, material_standard, material_grade, material_type, - manufacturing_method, end_preparation, schedule, wall_thickness, - nominal_size, length_mm, material_confidence, manufacturing_confidence, - end_prep_confidence, schedule_confidence - ) - VALUES ( - :material_id, :file_id, :material_standard, :material_grade, :material_type, - :manufacturing_method, :end_preparation, :schedule, :wall_thickness, - :nominal_size, :length_mm, :material_confidence, :manufacturing_confidence, - :end_prep_confidence, :schedule_confidence + 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 ) """) @@ -329,25 +362,98 @@ async def upload_file( 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 사용 + 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, - "material_standard": material_info.get("standard"), - "material_grade": material_info.get("grade"), - "material_type": material_info.get("material_type"), - "manufacturing_method": manufacturing_info.get("method"), - "end_preparation": end_prep_info.get("type"), - "schedule": schedule_info.get("schedule"), - "wall_thickness": schedule_info.get("wall_thickness"), - "nominal_size": material_data.get("size_spec", ""), # material_data에서 직접 가져옴 - "length_mm": length_mm, - "material_confidence": material_info.get("confidence", 0.0), - "manufacturing_confidence": manufacturing_info.get("confidence", 0.0), - "end_prep_confidence": end_prep_info.get("confidence", 0.0), - "schedule_confidence": schedule_info.get("confidence", 0.0) + "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", "") + + # main_size와 reduced_size + main_size = material_data.get("main_nom") or material_data.get("size_spec", "") + reduced_size = material_data.get("red_nom", "") + + 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 + ) VALUES ( + :material_id, :file_id, :fitting_type, :fitting_subtype, + :connection_method, :pressure_rating, :material_standard, + :material_grade, :main_size, :reduced_size + ) + """), { + "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 + }) + + print(f"FITTING 상세 정보 저장 완료: {fitting_type} - {fitting_subtype}") db.commit() print(f"자재 저장 완료: {materials_inserted}개") @@ -457,14 +563,17 @@ async def get_materials( try: query = """ SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, - m.size_spec, m.material_grade, m.line_number, m.row_number, + 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, f.original_filename, f.project_id, f.job_no, f.revision, - p.official_project_code, p.project_name + p.official_project_code, p.project_name, + pd.outer_diameter, pd.schedule, pd.material_spec, pd.manufacturing_method, + pd.end_preparation, pd.length_mm 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 WHERE 1=1 """ params = {} @@ -579,6 +688,8 @@ async def get_materials( "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, @@ -588,22 +699,17 @@ async def get_materials( "created_at": m.created_at } - # 카테고리별 상세 정보 추가 + # 카테고리별 상세 정보 추가 (JOIN 결과 사용) if m.classified_category == 'PIPE': - pipe_query = text("SELECT * FROM pipe_details WHERE material_id = :material_id") - pipe_result = db.execute(pipe_query, {"material_id": m.id}) - pipe_detail = pipe_result.fetchone() - if pipe_detail: + # JOIN된 결과에서 pipe_details 정보 가져오기 + if hasattr(m, 'outer_diameter') and m.outer_diameter is not None: material_dict['pipe_details'] = { - "nominal_size": pipe_detail.nominal_size, - "schedule": pipe_detail.schedule, - "material_standard": pipe_detail.material_standard, - "material_grade": pipe_detail.material_grade, - "material_type": pipe_detail.material_type, - "manufacturing_method": pipe_detail.manufacturing_method, - "end_preparation": pipe_detail.end_preparation, - "wall_thickness": pipe_detail.wall_thickness, - "length_mm": float(pipe_detail.length_mm) if pipe_detail.length_mm else None + "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 } elif m.classified_category == 'FITTING': fitting_query = text("SELECT * FROM fitting_details WHERE material_id = :material_id") @@ -906,19 +1012,12 @@ async def get_pipe_details( "original_description": pd.original_description, "quantity": pd.quantity, "unit": pd.unit, - "material_standard": pd.material_standard, - "material_grade": pd.material_grade, - "material_type": pd.material_type, + "material_spec": pd.material_spec, "manufacturing_method": pd.manufacturing_method, "end_preparation": pd.end_preparation, "schedule": pd.schedule, - "wall_thickness": pd.wall_thickness, - "nominal_size": pd.nominal_size, + "outer_diameter": pd.outer_diameter, "length_mm": pd.length_mm, - "material_confidence": pd.material_confidence, - "manufacturing_confidence": pd.manufacturing_confidence, - "end_prep_confidence": pd.end_prep_confidence, - "schedule_confidence": pd.schedule_confidence, "created_at": pd.created_at, "updated_at": pd.updated_at } diff --git a/backend/app/services/exclude_classifier.py b/backend/app/services/exclude_classifier.py new file mode 100644 index 0000000..bc7a622 --- /dev/null +++ b/backend/app/services/exclude_classifier.py @@ -0,0 +1,85 @@ +""" +EXCLUDE 분류 시스템 +실제 자재가 아닌 계산용/제외 항목들 분류 +""" + +import re +from typing import Dict, List, Optional + +# ========== 제외 대상 타입 ========== +EXCLUDE_TYPES = { + "WELD_GAP": { + "description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"], + "characteristics": "용접 시 수축 고려용 계산 항목", + "reason": "실제 자재 아님 - 용접 갭 계산용" + }, + "CUTTING_LOSS": { + "description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"], + "characteristics": "절단 시 손실 고려용 계산 항목", + "reason": "실제 자재 아님 - 절단 로스 계산용" + }, + "SPARE_ALLOWANCE": { + "description_keywords": ["SPARE", "ALLOWANCE", "여유분", "스페어"], + "characteristics": "예비품/여유분 계산 항목", + "reason": "실제 자재 아님 - 여유분 계산용" + }, + "THICKNESS_NOTE": { + "description_keywords": ["THK", "THICK", "두께", "THICKNESS"], + "characteristics": "두께 표기용 항목", + "reason": "실제 자재 아님 - 두께 정보" + }, + "CALCULATION_ITEM": { + "description_keywords": ["CALC", "CALCULATION", "계산", "산정"], + "characteristics": "기타 계산용 항목", + "reason": "실제 자재 아님 - 계산 목적" + } +} + +def classify_exclude(dat_file: str, description: str, main_nom: str = "") -> Dict: + """ + 제외 대상 분류 + + Args: + dat_file: DAT_FILE 필드 + description: DESCRIPTION 필드 + main_nom: MAIN_NOM 필드 + + Returns: + 제외 분류 결과 + """ + + desc_upper = description.upper() + + # 제외 대상 키워드 확인 + for exclude_type, type_data in EXCLUDE_TYPES.items(): + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "category": "EXCLUDE", + "exclude_type": exclude_type, + "characteristics": type_data["characteristics"], + "reason": type_data["reason"], + "overall_confidence": 0.95, + "evidence": [f"EXCLUDE_KEYWORD: {keyword}"], + "recommendation": "BOM에서 제외 권장" + } + + # 제외 대상 아님 + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "제외 대상 키워드 없음" + } + +def is_exclude_item(description: str) -> bool: + """간단한 제외 대상 체크""" + desc_upper = description.upper() + + exclude_keywords = [ + "WELD GAP", "WELDING GAP", "GAP", + "CUTTING LOSS", "CUT LOSS", + "SPARE", "ALLOWANCE", + "THK", "THICK" + ] + + return any(keyword in desc_upper for keyword in exclude_keywords) \ No newline at end of file diff --git a/backend/app/services/fitting_classifier.py b/backend/app/services/fitting_classifier.py index 044252f..f92e7c7 100644 --- a/backend/app/services/fitting_classifier.py +++ b/backend/app/services/fitting_classifier.py @@ -39,7 +39,7 @@ FITTING_TYPES = { "dat_file_patterns": ["CNC_", "ECC_", "RED_", "REDUCER_"], "description_keywords": ["REDUCER", "RED", "리듀서"], "subtypes": { - "CONCENTRIC": ["CONCENTRIC", "CNC", "동심", "CON"], + "CONCENTRIC": ["CONCENTRIC", "CONC", "CNC", "동심", "CON"], "ECCENTRIC": ["ECCENTRIC", "ECC", "편심"] }, "requires_two_sizes": True, @@ -59,6 +59,18 @@ FITTING_TYPES = { "size_range": "1/4\" ~ 24\"" }, + "PLUG": { + "dat_file_patterns": ["PLUG_", "HEX_PLUG"], + "description_keywords": ["PLUG", "플러그", "HEX.PLUG", "HEX PLUG", "HEXAGON PLUG"], + "subtypes": { + "HEX": ["HEX", "HEXAGON", "육각"], + "SQUARE": ["SQUARE", "사각"], + "THREADED": ["THD", "THREADED", "나사", "NPT"] + }, + "common_connections": ["THREADED", "NPT"], + "size_range": "1/8\" ~ 4\"" + }, + "NIPPLE": { "dat_file_patterns": ["NIP_", "NIPPLE_"], "description_keywords": ["NIPPLE", "니플"], @@ -77,8 +89,8 @@ FITTING_TYPES = { "dat_file_patterns": ["SWG_"], "description_keywords": ["SWAGE", "스웨지"], "subtypes": { - "CONCENTRIC": ["CONCENTRIC", "CN", "CON", "동심"], - "ECCENTRIC": ["ECCENTRIC", "EC", "ECC", "편심"] + "CONCENTRIC": ["CONCENTRIC", "CONC", "CN", "CON", "동심"], + "ECCENTRIC": ["ECCENTRIC", "ECC", "EC", "편심"] }, "requires_two_sizes": True, "common_connections": ["BUTT_WELD", "SOCKET_WELD"], @@ -87,12 +99,14 @@ FITTING_TYPES = { "OLET": { "dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_", "SOCK-O-LET", "WELD-O-LET"], - "description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET"], + "description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREAD-O-LET", "THREADOLET", "SOCKLET", "SOCKET"], "subtypes": { - "SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET"], - "WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET"], - "THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL"], - "ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL"] + "SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"], + "WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET", "WELDING-O-LET"], + "THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"], + "ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"], + "NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"], + "COUPOLET": ["COUP-O-LET", "COUPOLET", "COL", "COUPLING-O-LET"] }, "requires_two_sizes": True, # 주배관 x 분기관 "common_connections": ["SOCKET_WELD", "THREADED", "BUTT_WELD"], @@ -189,7 +203,7 @@ def classify_fitting(dat_file: str, description: str, main_nom: str, dat_upper = dat_file.upper() # 1. 명칭 우선 확인 (피팅 키워드가 있으면 피팅) - fitting_keywords = ['ELBOW', 'TEE', 'REDUCER', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET'] + fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', 'PLUG', 'SOCKLET', 'SOCKET', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET'] is_fitting = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords) if not is_fitting: diff --git a/backend/scripts/06_add_main_red_nom_columns.sql b/backend/scripts/06_add_main_red_nom_columns.sql new file mode 100644 index 0000000..76d82a8 --- /dev/null +++ b/backend/scripts/06_add_main_red_nom_columns.sql @@ -0,0 +1,18 @@ +-- main_nom, red_nom 컬럼 추가 스크립트 +-- 2025.01.17 - MAIN_NOM/RED_NOM 별도 저장을 위한 스키마 개선 + +-- materials 테이블에 main_nom, red_nom 컬럼 추가 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS main_nom VARCHAR(50); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS red_nom VARCHAR(50); + +-- 인덱스 추가 (검색 성능 향상) +CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom); +CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom); +CREATE INDEX IF NOT EXISTS idx_materials_main_red_nom ON materials(main_nom, red_nom); + +-- 기존 데이터에 대한 기본값 설정 (필요시) +-- UPDATE materials SET main_nom = '', red_nom = '' WHERE main_nom IS NULL OR red_nom IS NULL; + +-- 코멘트 추가 +COMMENT ON COLUMN materials.main_nom IS 'MAIN_NOM 필드 - 주 사이즈 (예: 4", 150A)'; +COMMENT ON COLUMN materials.red_nom IS 'RED_NOM 필드 - 축소 사이즈 (Reducing 피팅/플랜지용)'; \ No newline at end of file diff --git a/backend/scripts/07_simplify_pipe_details_schema.sql b/backend/scripts/07_simplify_pipe_details_schema.sql new file mode 100644 index 0000000..0a4fcf7 --- /dev/null +++ b/backend/scripts/07_simplify_pipe_details_schema.sql @@ -0,0 +1,68 @@ +-- PIPE_DETAILS 테이블 간소화 스크립트 +-- 2025.01.17 - 실무 중심 구조 개선 + +-- 기존 테이블 백업 +CREATE TABLE IF NOT EXISTS pipe_details_backup AS SELECT * FROM pipe_details; + +-- 새로운 간소화된 테이블 생성 +DROP TABLE IF EXISTS pipe_details_new; +CREATE TABLE pipe_details_new ( + id SERIAL PRIMARY KEY, + material_id INTEGER NOT NULL, + file_id INTEGER NOT NULL, + + -- 핵심 PIPE 정보 (실무 필수) + outer_diameter VARCHAR(20), -- main_nom 기반 외경 정보 + schedule VARCHAR(10), -- SCH 40, SCH 80 등 + material_spec VARCHAR(100), -- 단일 재질 정보 (ASTM A106 GR B) + manufacturing_method VARCHAR(20), -- SEAMLESS, WELDED, CAST + end_preparation VARCHAR(20), -- POE, BOE, PEE, BEE 등 + length_mm DECIMAL(10,2), -- 길이 (mm) + + -- 추가 정보 (cutting plan 용) + area_number VARCHAR(10), -- 에리어 번호 (#01, #02) + spool_number VARCHAR(10), -- 스풀 번호 (A, B, C) + drawing_number VARCHAR(50), -- 도면 번호 + + -- 메타 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 외래키 + FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE, + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE +); + +-- 인덱스 추가 (성능) +CREATE INDEX IF NOT EXISTS idx_pipe_details_material_id ON pipe_details_new(material_id); +CREATE INDEX IF NOT EXISTS idx_pipe_details_file_id ON pipe_details_new(file_id); +CREATE INDEX IF NOT EXISTS idx_pipe_details_outer_diameter ON pipe_details_new(outer_diameter); +CREATE INDEX IF NOT EXISTS idx_pipe_details_schedule ON pipe_details_new(schedule); + +-- 기존 데이터 마이그레이션 (가능한 것만) +INSERT INTO pipe_details_new ( + material_id, file_id, outer_diameter, schedule, material_spec, + manufacturing_method, length_mm +) +SELECT + material_id, + file_id, + nominal_size as outer_diameter, -- nominal_size를 outer_diameter로 + schedule, + COALESCE(material_standard, material_grade, material_type) as material_spec, -- 중복 필드 통합 + manufacturing_method, + length_mm +FROM pipe_details +WHERE material_id IS NOT NULL; + +-- 백업 테이블로 기존 테이블 교체 +DROP TABLE IF EXISTS pipe_details; +ALTER TABLE pipe_details_new RENAME TO pipe_details; + +-- 코멘트 추가 +COMMENT ON TABLE pipe_details IS '간소화된 PIPE 상세 정보 - 실무 중심 구조'; +COMMENT ON COLUMN pipe_details.outer_diameter IS '외경 정보 (main_nom에서 추출, 예: 4", 150A)'; +COMMENT ON COLUMN pipe_details.material_spec IS '통합 재질 정보 (예: ASTM A106 GR B)'; +COMMENT ON COLUMN pipe_details.end_preparation IS '끝단 가공 (POE-BOE, PEE, BEE 등)'; +COMMENT ON COLUMN pipe_details.area_number IS '에리어 번호 (cutting plan용)'; +COMMENT ON COLUMN pipe_details.spool_number IS '스풀 번호 (cutting plan용)'; \ No newline at end of file diff --git a/backend/test_main_red_nom.py b/backend/test_main_red_nom.py new file mode 100644 index 0000000..126826c --- /dev/null +++ b/backend/test_main_red_nom.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +main_nom, red_nom 기능 테스트 스크립트 +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from app.services.fitting_classifier import classify_fitting +from app.services.flange_classifier import classify_flange + +def test_main_red_nom(): + """main_nom과 red_nom 분류 테스트""" + + print("🔧 main_nom/red_nom 분류 테스트 시작!") + print("=" * 60) + + test_cases = [ + { + "name": "일반 TEE (동일 사이즈)", + "description": "TEE, SCH 40, ASTM A234 GR WPB", + "main_nom": "4\"", + "red_nom": None, + "expected": "EQUAL TEE" + }, + { + "name": "리듀싱 TEE (다른 사이즈)", + "description": "TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB", + "main_nom": "4\"", + "red_nom": "2\"", + "expected": "REDUCING TEE" + }, + { + "name": "동심 리듀서", + "description": "RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB", + "main_nom": "6\"", + "red_nom": "4\"", + "expected": "CONCENTRIC REDUCER" + }, + { + "name": "리듀싱 플랜지", + "description": "FLG REDUCING, 300LB, ASTM A105", + "main_nom": "6\"", + "red_nom": "4\"", + "expected": "REDUCING FLANGE" + } + ] + + for i, test in enumerate(test_cases, 1): + print(f"\n{i}. {test['name']}") + print(f" 설명: {test['description']}") + print(f" MAIN_NOM: {test['main_nom']}") + print(f" RED_NOM: {test['red_nom']}") + + # 피팅 분류 테스트 + fitting_result = classify_fitting( + "", + test['description'], + test['main_nom'], + test['red_nom'] + ) + + print(f" 🔧 FITTING 분류 결과:") + print(f" 카테고리: {fitting_result.get('category')}") + print(f" 타입: {fitting_result.get('fitting_type', {}).get('type')}") + print(f" 서브타입: {fitting_result.get('fitting_type', {}).get('subtype')}") + print(f" 신뢰도: {fitting_result.get('overall_confidence', 0):.2f}") + + # 사이즈 정보 확인 + size_info = fitting_result.get('size_info', {}) + print(f" 메인 사이즈: {size_info.get('main_size')}") + print(f" 축소 사이즈: {size_info.get('reduced_size')}") + print(f" 사이즈 설명: {size_info.get('size_description')}") + + # RED_NOM이 있는 경우 REDUCING 분류 확인 + if test['red_nom']: + fitting_type = fitting_result.get('fitting_type', {}) + if 'REDUCING' in fitting_type.get('subtype', '').upper(): + print(f" ✅ REDUCING 타입 정상 인식!") + else: + print(f" ❌ REDUCING 타입 인식 실패") + + print("-" * 50) + + print("\n🎯 테스트 완료!") + +if __name__ == "__main__": + test_main_red_nom() \ No newline at end of file diff --git a/backend/test_sample.csv b/backend/test_sample.csv new file mode 100644 index 0000000..76278ff --- /dev/null +++ b/backend/test_sample.csv @@ -0,0 +1,6 @@ +description,qty,main_nom,red_nom,length +"TEE EQUAL, SCH 40, ASTM A234 GR WPB",2,4",," +"TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",1,4",2"," +"RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",1,6",4"," +"90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS",4,3",," +"PIPE SMLS, SCH 40, ASTM A106 GR B",1,2",,6000 \ No newline at end of file diff --git a/frontend/src/api.js b/frontend/src/api.js index 59e50cb..ac7cbd2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -55,7 +55,7 @@ api.interceptors.response.use( export function uploadFile(formData, options = {}) { const config = { method: 'post', - url: '/upload', + url: '/files/upload', data: formData, headers: { 'Content-Type': 'multipart/form-data' }, ...options, diff --git a/frontend/src/pages/MaterialsPage.jsx b/frontend/src/pages/MaterialsPage.jsx index 752e01f..890a95c 100644 --- a/frontend/src/pages/MaterialsPage.jsx +++ b/frontend/src/pages/MaterialsPage.jsx @@ -5,12 +5,6 @@ import { Card, CardContent, Typography, - Grid, - Chip, - Alert, - CircularProgress, - Tabs, - Tab, Table, TableBody, TableCell, @@ -19,1257 +13,471 @@ import { TableRow, Paper, Button, - FormControlLabel, - Switch + Alert, + CircularProgress, + Chip, + Divider } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import PipeDetailsCard from '../components/PipeDetailsCard'; -import FittingDetailsCard from '../components/FittingDetailsCard'; -import { Pie, Bar } from 'react-chartjs-2'; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend, - ArcElement -} from 'chart.js'; import { api } from '../api'; -ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend, - ArcElement -); - const MaterialsPage = () => { const [materials, setMaterials] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [fileId, setFileId] = useState(null); - const [activeTab, setActiveTab] = useState(0); - const [selectedCategories, setSelectedCategories] = useState([]); - const [revisionComparison, setRevisionComparison] = useState(null); - const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false); - + const [fileName, setFileName] = useState(''); const navigate = useNavigate(); - // 컴포넌트 마운트 확인 - console.log('MaterialsPage 컴포넌트 마운트됨'); - useEffect(() => { const urlParams = new URLSearchParams(window.location.search); - const id = urlParams.get('file_id'); // fileId -> file_id로 변경 + const id = urlParams.get('file_id'); + const name = urlParams.get('filename') || ''; + if (id) { setFileId(id); + setFileName(decodeURIComponent(name)); loadMaterials(id); } else { setLoading(false); - setError('파일 ID가 지정되지 않았습니다. BOM 현황 페이지에서 파일을 선택해주세요.'); + setError('파일 ID가 지정되지 않았습니다.'); } }, []); const loadMaterials = async (id) => { - console.log('자재 로딩 시작, file_id:', id); try { setLoading(true); - // limit을 충분히 크게 설정하여 모든 자재를 가져옴 - const response = await api.get('/files/materials', { params: { file_id: parseInt(id), limit: 10000 } }); - console.log('자재 데이터 로딩 성공:', response.data); + const response = await api.get('/files/materials', { + params: { file_id: parseInt(id), limit: 10000 } + }); - // API 응답이 객체로 오는 경우 materials 배열 추출 if (response.data && response.data.materials) { setMaterials(response.data.materials); - } else if (Array.isArray(response.data)) { - setMaterials(response.data); } else { - console.error('예상치 못한 응답 형식:', response.data); setMaterials([]); } - setError(null); } catch (err) { setError('자재 정보를 불러오는데 실패했습니다.'); console.error('자재 로딩 에러:', err); - console.error('에러 상세:', err.response?.data); } finally { setLoading(false); } }; - const groupMaterialsByItem = (materials) => { - const grouped = {}; - materials.forEach(material => { - const key = `${material.original_description}_${material.size_spec}_${material.material_grade}`; - if (!grouped[key]) { - grouped[key] = []; - } - grouped[key].push(material); - }); - return grouped; - }; - - const getFilteredMaterials = () => { - if (selectedCategories.length === 0) { - return materials; - } - return materials.filter(material => - selectedCategories.includes(material.classified_category) - ); - }; - - const calculateCategoryStats = () => { - const stats = {}; - // materials가 배열인지 확인 - if (!Array.isArray(materials)) { - console.error('materials is not an array:', materials); - return stats; - } + // 자재 사양서 생성 로직 + const generateMaterialSpecs = (materials) => { + const specs = {}; materials.forEach(material => { const category = material.classified_category || 'UNKNOWN'; - if (!stats[category]) { - stats[category] = { count: 0, totalQuantity: 0 }; - } - stats[category].count++; - stats[category].totalQuantity += material.quantity || 0; - }); - return stats; - }; - - const getAvailableCategories = () => { - if (!Array.isArray(materials)) return []; - const categories = [...new Set(materials.map(m => m.classified_category).filter(Boolean))]; - return categories.sort(); - }; - - const calculateClassificationStats = () => { - if (!Array.isArray(materials)) { - return { - totalItems: 0, - classifiedItems: 0, - unclassifiedItems: 0, - highConfidence: 0, - mediumConfidence: 0, - lowConfidence: 0, - categoryBreakdown: {} - }; - } - - const totalItems = materials.length; - const classifiedItems = materials.filter(m => m.classified_category && m.classified_category !== 'UNKNOWN').length; - const unclassifiedItems = totalItems - classifiedItems; - - const highConfidence = materials.filter(m => - m.classification_confidence && m.classification_confidence >= 0.8 - ).length; - const mediumConfidence = materials.filter(m => - m.classification_confidence && m.classification_confidence >= 0.5 && m.classification_confidence < 0.8 - ).length; - const lowConfidence = materials.filter(m => - m.classification_confidence && m.classification_confidence < 0.5 - ).length; - - const categoryBreakdown = {}; - materials.forEach(material => { - const category = material.classified_category || 'UNKNOWN'; - if (!categoryBreakdown[category]) { - categoryBreakdown[category] = { highConfidence: 0, mediumConfidence: 0, lowConfidence: 0 }; - } + let specKey = ''; + let specData = {}; - if (material.classification_confidence >= 0.8) { - categoryBreakdown[category].highConfidence++; - } else if (material.classification_confidence >= 0.5) { - categoryBreakdown[category].mediumConfidence++; - } else { - categoryBreakdown[category].lowConfidence++; - } - }); - - return { - totalItems, - classifiedItems, - unclassifiedItems, - highConfidence, - mediumConfidence, - lowConfidence, - categoryBreakdown - }; - }; - - const getDisplayInfo = (material) => { - const category = material.classified_category; - let details = material.classification_details || {}; - - // classification_details가 문자열인 경우 JSON 파싱 - if (typeof details === 'string') { - try { - details = JSON.parse(details); - } catch (e) { - console.error('분류 상세정보 파싱 실패:', e); - details = {}; - } - } - - switch (category) { - case 'PIPE': - // 1. classification_details에서 길이 정보 가져오기 - const cuttingDimensions = details?.cutting_dimensions || {}; - let lengthMm = cuttingDimensions?.length_mm; + if (category === 'PIPE') { + // PIPE: 재질 + 외경 + 스케줄 + 제작방식 - // 2. 백엔드에서 전달된 length 필드도 확인 - if (!lengthMm && material.length) { - lengthMm = material.length; - } + // 재질 정보 - pipe_details에서 이미 정제된 것만 사용 + const material_spec = material.pipe_details?.material_spec || material.material_grade || ''; + const outer_diameter = material.main_nom || material.pipe_details?.outer_diameter || ''; + const schedule = material.pipe_details?.schedule || ''; + const manufacturing = material.pipe_details?.manufacturing_method || ''; - if (lengthMm) { - return { - value: lengthMm, - unit: 'mm', - displayText: `${lengthMm}mm`, - isLength: true - }; - } - break; - case 'BOLT': - case 'NUT': - case 'WASHER': - return { - value: material.quantity, - unit: 'EA', - displayText: `${material.quantity} EA`, - isLength: false - }; - default: - return { - value: material.quantity, - unit: 'EA', - displayText: `${material.quantity} EA`, - isLength: false - }; - } - - // 기본값 - return { - value: material.quantity, - unit: 'EA', - displayText: `${material.quantity} EA`, - isLength: false - }; - }; - - // PIPE 분석용 헬퍼 함수들 - const groupPipesBySpecs = (pipeItems) => { - const groups = {}; - - pipeItems.forEach(item => { - const details = item.pipe_details || {}; - - // 재질-크기-스케줄-제작방식으로 키 생성 - const material = details.material_standard || item.material_grade || 'Unknown'; - let size = details.nominal_size || item.size_spec || 'Unknown'; - - // 크기 정리 (인치 표시) - if (size && size !== 'Unknown') { - size = size.replace(/["']/g, '').trim(); - if (!size.includes('"') && !size.includes('inch')) { - size += '"'; - } - } - - const schedule = details.schedule || 'Unknown'; - const manufacturing = details.manufacturing_method || 'Unknown'; - - const key = `${material}|${size}|${schedule}|${manufacturing}`; - - if (!groups[key]) { - groups[key] = { - material, - size, + specKey = `${category}|${material_spec}|${outer_diameter}|${schedule}|${manufacturing}`; + specData = { + category: 'PIPE', + material_spec, + outer_diameter, schedule, - manufacturing, - items: [], - totalLength: 0, - count: 0 - }; - } - - groups[key].items.push(item); - groups[key].count += 1; - - // 길이 합산 - if (item.pipe_details?.length_mm) { - groups[key].totalLength += item.pipe_details.length_mm; - } - }); - - // 배열로 변환하고 총 길이순으로 정렬 - return Object.values(groups).sort((a, b) => b.totalLength - a.totalLength); - }; - - const generatePipeChartData = (pipeItems, property) => { - const groups = groupPipesByProperty(pipeItems, property); - - const chartData = Object.entries(groups).map(([key, items]) => { - const totalLength = items.reduce((sum, item) => { - let lengthMm = 0; - if (item.pipe_details?.length_mm) { - lengthMm = item.pipe_details.length_mm; - } - return sum + lengthMm; - }, 0); - - return { - label: key, - value: totalLength, - count: items.length, - items: items - }; - }).sort((a, b) => b.value - a.value); - - return { - labels: chartData.map(d => d.label), - datasets: [{ - data: chartData.map(d => d.value), - backgroundColor: [ - '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', - '#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384' - ], - borderWidth: 1 - }], - chartData: chartData - }; - }; - - const generateCategoryChartData = (category, items) => { - switch (category) { - case 'PIPE': - const totalLength = items.reduce((sum, item) => { - let lengthMm = 0; - if (item.pipe_details?.length_mm) { - lengthMm = item.pipe_details.length_mm; - } - return sum + lengthMm; - }, 0); - return { - value: totalLength, + manufacturing_method: manufacturing, unit: 'mm', - displayText: `${(totalLength / 1000).toFixed(1)}m`, isLength: true }; - case 'BOLT': - case 'NUT': - case 'WASHER': - const totalQuantity = items.reduce((sum, item) => sum + (item.quantity || 0), 0); - return { - value: totalQuantity, + } else if (category === 'FITTING') { + // FITTING: 타입 + 서브타입 + 연결방식 + 압력등급 + 사이즈 + 재질 + const material_spec = material.fitting_details?.material_spec || material.material_grade || ''; + const main_nom = material.main_nom || ''; + const red_nom = material.red_nom || ''; + const size_display = red_nom ? `${main_nom} x ${red_nom}` : main_nom; + const fitting_type = material.fitting_details?.fitting_type || 'UNKNOWN'; + const fitting_subtype = material.fitting_details?.fitting_subtype || ''; + const connection_method = material.fitting_details?.connection_method || ''; + const pressure_rating = material.fitting_details?.pressure_rating || ''; + + // 전체 피팅 스펙 생성 - 중복 제거 (OLET 특별 처리) + const spec_parts = []; + + // OLET 계열 특별 처리 + if (fitting_type === 'OLET' && fitting_subtype && fitting_subtype !== 'UNKNOWN') { + // SOCKOLET, WELDOLET 등 서브타입만 표시 (OLET 생략) + spec_parts.push(fitting_subtype); + + // OLET 계열은 연결방식이 서브타입에 이미 내포됨 + // SOCKOLET = SOCKET_WELD, WELDOLET = BUTT_WELD 등 + // 따라서 connection_method 생략 + + } else if (fitting_type === 'NIPPLE') { + // NIPPLE 특별 처리 - 스케줄 + 길이 정보 포함 + spec_parts.push(fitting_type); + + // 서브타입 (CLOSE, SHORT, LONG 등) + if (fitting_subtype && fitting_subtype !== 'UNKNOWN' && fitting_subtype !== fitting_type) { + spec_parts.push(fitting_subtype); + } + + // NIPPLE 스케줄 정보 추가 (중요!) + const nipple_schedule = material.fitting_details?.schedule || material.pipe_details?.schedule; + if (nipple_schedule && nipple_schedule !== 'UNKNOWN') { + spec_parts.push(nipple_schedule); + } + + // 연결방식 + if (connection_method && connection_method !== 'UNKNOWN' && + !spec_parts.some(part => part.includes(connection_method))) { + spec_parts.push(connection_method); + } + + // NIPPLE 길이 정보 추가 (mm → m 변환 또는 그대로) + const length_mm = material.length || material.pipe_details?.length_mm; + if (length_mm && length_mm > 0) { + if (length_mm >= 1000) { + spec_parts.push(`${(length_mm / 1000).toFixed(2)}m`); + } else { + spec_parts.push(`${length_mm}mm`); + } + } + + } else { + // 일반 피팅 처리 + + // 기본 타입 (CAP, TEE, ELBOW 등) + if (fitting_type && fitting_type !== 'UNKNOWN') { + spec_parts.push(fitting_type); + } + + // 서브타입 (CONCENTRIC, HEXAGON 등) - 단, 타입과 중복되지 않을 때만 + if (fitting_subtype && fitting_subtype !== 'UNKNOWN' && fitting_subtype !== fitting_type) { + spec_parts.push(fitting_subtype); + } + + // 연결방식 (THREADED, NPT 등) - 단, 이미 포함되지 않았을 때만 + if (connection_method && connection_method !== 'UNKNOWN' && + !spec_parts.some(part => part.includes(connection_method))) { + spec_parts.push(connection_method); + } + } + + // 압력등급 (3000LB, 6000LB 등) - 모든 경우에 표시 + if (pressure_rating && pressure_rating !== 'UNKNOWN' && + !spec_parts.some(part => part.includes(pressure_rating))) { + spec_parts.push(pressure_rating); + } + + const full_fitting_spec = spec_parts.join(', '); + + specKey = `${category}|${full_fitting_spec}|${material_spec}|${size_display}`; + specData = { + category: 'FITTING', + fitting_type, + fitting_subtype, + connection_method, + pressure_rating, + full_fitting_spec, + material_spec, + size_display, + main_nom, + red_nom, unit: 'EA', - displayText: `${totalQuantity} EA`, isLength: false }; - default: - const totalQty = items.reduce((sum, item) => sum + (item.quantity || 0), 0); - return { - value: totalQty, + } else { + // 기타 자재: 기본 분류 + const material_spec = material.material_grade || ''; + const size_display = material.main_nom || material.size_spec || ''; + + specKey = `${category}|${material_spec}|${size_display}`; + specData = { + category, + material_spec, + size_display, unit: 'EA', - displayText: `${totalQty} EA`, isLength: false }; + } + + if (!specs[specKey]) { + specs[specKey] = { + ...specData, + totalQuantity: 0, + totalLength: 0, + count: 0, + items: [] + }; + } + + specs[specKey].totalQuantity += material.quantity || 0; + specs[specKey].count += 1; + specs[specKey].items.push(material); + + // PIPE의 경우 길이 합산 + if (category === 'PIPE' && material.pipe_details?.length_mm) { + specs[specKey].totalLength += material.pipe_details.length_mm; + } + }); + + return Object.values(specs); + }; + + const formatLength = (lengthMm) => { + if (!lengthMm || lengthMm === 0) return '0mm'; + + if (lengthMm >= 1000) { + return `${(lengthMm / 1000).toFixed(2)}m (${lengthMm.toLocaleString()}mm)`; } - }; - - const generateChartData = () => { - const categoryStats = calculateCategoryStats(); - const categories = Object.keys(categoryStats); - - return { - labels: categories, - datasets: [{ - label: '항목 수', - data: categories.map(cat => categoryStats[cat].count), - backgroundColor: categories.map(cat => getCategoryColor(cat) === 'primary' ? '#1976d2' : - getCategoryColor(cat) === 'secondary' ? '#9c27b0' : - getCategoryColor(cat) === 'error' ? '#d32f2f' : - getCategoryColor(cat) === 'warning' ? '#ed6c02' : - getCategoryColor(cat) === 'info' ? '#0288d1' : - getCategoryColor(cat) === 'success' ? '#2e7d32' : '#757575'), - borderWidth: 1 - }] - }; - }; - - const handleRevisionComparison = async () => { - try { - const response = await api.get(`/materials/${fileId}/revision-comparison`); - setRevisionComparison(response.data); - } catch (err) { - setError('리비전 비교를 불러오는데 실패했습니다.'); - console.error('리비전 비교 에러:', err); - } - }; - - const handleAutoRevisionComparison = async () => { - try { - const response = await api.get(`/materials/${fileId}/auto-revision-comparison`); - setRevisionComparison(response.data); - } catch (err) { - setError('자동 리비전 비교를 불러오는데 실패했습니다.'); - console.error('자동 리비전 비교 에러:', err); - } - }; - - const getPurchaseRequiredItems = () => { - if (!revisionComparison) return { added: [], changed: [] }; - - return { - added: revisionComparison.changes.added, - changed: revisionComparison.changes.changed.filter(item => item.quantity_change > 0) - }; - }; - - const calculateTotalPurchaseQuantity = () => { - const purchaseItems = getPurchaseRequiredItems(); - const addedQuantity = purchaseItems.added.reduce((sum, item) => sum + item.item.quantity, 0); - const changedQuantity = purchaseItems.changed.reduce((sum, item) => sum + item.quantity_change, 0); - return addedQuantity + changedQuantity; - }; - - const generateComparisonChartData = () => { - if (!revisionComparison) return null; - - const { added, removed, changed } = revisionComparison.changes; - - return { - labels: ['신규 추가', '삭제', '수량 증가', '수량 감소'], - datasets: [{ - label: '항목 수', - data: [ - added.length, - removed.length, - changed.filter(item => item.quantity_change > 0).length, - changed.filter(item => item.quantity_change < 0).length - ], - backgroundColor: ['#4caf50', '#f44336', '#ff9800', '#ff5722'], - borderWidth: 1 - }] - }; + return `${lengthMm.toLocaleString()}mm`; }; const getCategoryColor = (category) => { - const colorMap = { + const colors = { 'PIPE': 'primary', 'FITTING': 'secondary', - 'VALVE': 'error', - 'FLANGE': 'warning', - 'BOLT': 'info', - 'GASKET': 'success', - 'INSTRUMENT': 'primary', - 'OTHER': 'default' + 'VALVE': 'success', + 'BOLT': 'warning', + 'GASKET': 'info', + 'INSTRUMENT': 'error', + 'UNKNOWN': 'default' }; - return colorMap[category] || 'default'; + return colors[category] || 'default'; }; - const getClassifiedDescription = (material) => { - const details = material.classification_details; - if (typeof details === 'string') { - try { - const parsed = JSON.parse(details); - return parsed.description || material.original_description; - } catch (e) { - return material.original_description; - } - } - return details?.description || material.original_description; - }; - - const categoryStats = calculateCategoryStats(); - const classificationStats = calculateClassificationStats(); - const pieData = generateChartData(); - const comparisonData = generateComparisonChartData(); - - const summary = { - total_items: materials.length, - total_quantity: materials.reduce((sum, m) => sum + (m.quantity || 0), 0) - }; - - // 에러 디버깅을 위한 로그 - console.log('Rendering MaterialsPage, materials:', materials.length); - console.log('Loading:', loading, 'Error:', error); - console.log('FileId:', fileId); - - return ( - - {/* 뒤로가기 버튼 */} - - - - - 📋 자재 분류 결과 - - - 업로드된 BOM 파일의 자재 분류 결과를 확인하세요. + if (loading) { + return ( + + + + 자재 정보를 불러오는 중... + ); + } - {/* 요약 통계 */} - {materials.length > 0 && ( - - {/* 총 항목 수 */} - - - - - 📊 총 항목 - - - {summary.total_items.toLocaleString()}개 - - - 엑셀에서 추출된 총 항목 - - - - + if (error) { + return ( + + + + ⚠️ {error} + + + 💡 해당 파일에 자재 정보가 없습니다. + + + ); + } - {/* 분류 완료된 항목 */} - - - - - ✅ 분류 완료 - - - {classificationStats.classifiedItems.toLocaleString()}개 - - - {Math.round((classificationStats.classifiedItems / summary.total_items) * 100)}% 분류율 - - - - + const materialSpecs = generateMaterialSpecs(materials); + const totalSpecs = materialSpecs.length; + const categoryStats = materialSpecs.reduce((acc, spec) => { + acc[spec.category] = (acc[spec.category] || 0) + 1; + return acc; + }, {}); - {/* 분류 미완료 항목 */} - - - - - ⚠️ 분류 미완료 - - - {classificationStats.unclassifiedItems.toLocaleString()}개 - - - 수동 분류 필요 - - - - - - {/* 총 수량 */} - - - - - 📦 총 수량 - - - {summary.total_quantity.toLocaleString()} EA - - - 모든 자재의 총 수량 - - - - - - )} - - {/* 분류 결과 상세 */} - {Object.keys(categoryStats).length > 0 && ( - - - - 🔍 분류기별 결과 - - - {Object.entries(categoryStats).map(([category, stats]) => ( - - - - - - {stats.count.toLocaleString()}개 - - - - 총 수량: {stats.totalQuantity.toLocaleString()} EA - - - 비율: {Math.round((stats.count / classificationStats.totalItems) * 100)}% - - - {/* 신뢰도 정보 */} - {classificationStats.categoryBreakdown[category] && ( - - - 신뢰도: - - 높음 {classificationStats.categoryBreakdown[category].highConfidence}개 - - - 중간 {classificationStats.categoryBreakdown[category].mediumConfidence}개 - - - 낮음 {classificationStats.categoryBreakdown[category].lowConfidence}개 - - - - )} - - - ))} - - - - )} - - {/* 분류 신뢰도 요약 */} - {classificationStats.classifiedItems > 0 && ( - - - - 🎯 분류 신뢰도 요약 - - - - - - {classificationStats.highConfidence}개 - - - 높은 신뢰도 (80% 이상) - - - - - - - {classificationStats.mediumConfidence}개 - - - 중간 신뢰도 (50-80%) - - - - - - - {classificationStats.lowConfidence}개 - - - 낮은 신뢰도 (50% 미만) - - - - - - - )} - - {error && {error}} - {loading && } - - {/* 탭 네비게이션 */} - {!loading && materials.length > 0 && ( - - setActiveTab(newValue)}> - - - - - - )} - - {/* 차트 탭 */} - {!loading && materials.length > 0 && activeTab === 0 && ( - - - 📊 분류별 통계 (업체 견적 의뢰용) + return ( + + {/* 헤더 */} + + + + + 📋 자재 사양서 + + + + 업체 견적 요청용 자재 사양 목록 + + + {fileName && ( + + 파일명: {fileName} - - - {/* 파이 차트 */} - - - - - 분류별 항목 수 - - - - - - - + )} + - {/* 바 차트 */} - - - - - 분류별 수량 - - - stats.totalQuantity), - backgroundColor: Object.keys(categoryStats).map(cat => - getCategoryColor(cat) === 'primary' ? '#1976d2' : - getCategoryColor(cat) === 'secondary' ? '#9c27b0' : - getCategoryColor(cat) === 'error' ? '#d32f2f' : - getCategoryColor(cat) === 'warning' ? '#ed6c02' : - getCategoryColor(cat) === 'info' ? '#0288d1' : - getCategoryColor(cat) === 'success' ? '#2e7d32' : '#757575' - ) - }] - }} - options={{ - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: false - } - }, - scales: { - y: { - beginAtZero: true - } - } - }} - /> - - - - - - - )} + {/* 통계 요약 */} + + + + 📊 사양 요약 + + + + {Object.entries(categoryStats).map(([category, count]) => ( + + ))} + + + - {/* 상세 목록 탭 */} - {!loading && materials.length > 0 && activeTab === 1 && (() => { - const pipeItems = materials.filter(m => m.classified_category === 'PIPE'); + {/* 카테고리별 자재 사양서 */} + {Object.entries(categoryStats).map(([category, count]) => { + const categorySpecs = materialSpecs.filter(spec => spec.category === category); return ( - - - 📋 상세 자재 목록 (테스트) - - - 총 {materials.length}개 자재가 로드되었습니다. - - - {/* PIPE 분석 섹션 */} - {pipeItems.length > 0 && ( - - - - 🔧 PIPE 분석 ({pipeItems.length}개) - - - 동일한 재질-크기-스케줄-제작방식을 가진 파이프들을 그룹화하여 표시합니다. - - - - - - - 재질 + + + + 🔧 {category} 사양 ({count}개) + + + + + {category === 'PIPE' + ? '동일한 재질·외경·스케줄·제작방식의 파이프들을 그룹화하여 표시합니다.' + : '동일한 사양의 자재들을 그룹화하여 수량을 합산합니다.' + } + + + +
+ + + {category === 'PIPE' && ( + <> + 사양 외경 스케줄 제작방식 총 길이 - 개수 - - - - {groupPipesBySpecs(pipeItems).map((group, index) => ( - - {group.material} - {group.size} - {group.schedule} - {group.manufacturing} - - {(group.totalLength / 1000).toFixed(2)}m - - ({group.totalLength.toFixed(0)}mm) + + )} + {category === 'FITTING' && ( + <> + 품목 + 재질 + 사이즈 + 수량 + + )} + {!['PIPE', 'FITTING'].includes(category) && ( + <> + 재질 + 사이즈 + 수량 + + )} + {!['PIPE', 'FITTING'].includes(category) && ( + <> + 사이즈 + 수량 + + )} + 개수 + + + + {categorySpecs.map((spec, index) => ( + + {category === 'PIPE' && ( + <> + + + {spec.material_spec || 'Unknown'} - - + {spec.outer_diameter || 'Unknown'} + {spec.schedule || 'Unknown'} + {spec.manufacturing_method || 'Unknown'} + + + {formatLength(spec.totalLength)} + - - ))} - -
-
- - {/* 총계 */} - - - 총 파이프 길이: {(pipeItems.reduce((sum, item) => sum + (item.pipe_details?.length_mm || 0), 0) / 1000).toFixed(2)}m - {' '}({groupPipesBySpecs(pipeItems).length}가지 규격) - - -
-
- )} - - {/* 필터 */} - - {getAvailableCategories().map(category => ( - { - if (selectedCategories.includes(category)) { - setSelectedCategories(selectedCategories.filter(c => c !== category)); - } else { - setSelectedCategories([...selectedCategories, category]); - } - }} - clickable - /> - ))} - - - {/* 자재 테이블 */} - - - - - 라인 - 분류 - 품명 - 사이즈 - 재질 - 수량 - 단위 - 신뢰도 - - - - {getFilteredMaterials().map((material, index) => { - const displayInfo = getDisplayInfo(material); - return ( - - - {material.line_number} - - - - {getClassifiedDescription(material)} - {material.size_spec || '-'} - {material.material_grade || '-'} - {displayInfo.displayText} - {displayInfo.unit} - - = 0.8 ? 'success.main' : - material.classification_confidence >= 0.5 ? 'warning.main' : 'error.main'} - > - {Math.round((material.classification_confidence || 0) * 100)}% - + + )} + {category === 'FITTING' && ( + <> + + + {spec.full_fitting_spec || spec.fitting_type || 'UNKNOWN'} + + + {spec.material_spec || 'Unknown'} + {spec.size_display || 'Unknown'} + + + {spec.totalQuantity} {spec.unit} + + + + )} + {(!['PIPE', 'FITTING'].includes(category)) && ( + <> + {spec.material_spec || 'Unknown'} + {spec.size_display || 'Unknown'} + + + {spec.totalQuantity} {spec.unit} + + + + )} + {(!['PIPE', 'FITTING'].includes(category)) && ( + <> + {spec.size_display || 'Unknown'} + + + {spec.totalQuantity} {spec.unit} + + + + )} + + - {/* 자재별 상세 정보 카드 */} - {material.classified_category === 'PIPE' && ( - - - - - - )} - {material.classified_category === 'FITTING' && ( - - - - - - )} - {material.classified_category === 'VALVE' && ( - - - - 🚰 VALVE 상세 정보 - - - 밸브 타입 - {material.valve_details?.valve_type || '-'} - - - 작동 방식 - {material.valve_details?.actuator_type || '-'} - - - 압력 등급 - {material.valve_details?.pressure_rating || '-'} - - - 크기 - {material.valve_details?.size_inches || '-'} - - - - - - )} - - ); - })} - -
-
-
+ ))} + + + + + ); - })()} + })} - {/* 리비전 비교 탭 */} - {!loading && materials.length > 0 && activeTab === 2 && ( - - - 🔄 리비전 비교 - - - - - - - - {revisionComparison && ( - - {/* 비교 요약 */} - - - - - 📊 변경 사항 요약 - - - - - - - - - {/* 발주 필요 수량 */} - - - - - 📦 발주 필요 수량 - - - sum + item.item.quantity, 0), - getPurchaseRequiredItems().changed.reduce((sum, item) => sum + item.quantity_change, 0) - ], - backgroundColor: ['#4caf50', '#ff9800'] - }] - }} - options={{ - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: false - } - }, - scales: { - y: { - beginAtZero: true - } - } - }} - /> - - - - - - {/* 발주 필요 항목 테이블 */} - - - - - - {showOnlyPurchaseRequired ? '📋 발주 필요 항목만' : '📋 전체 변경 사항'} - - setShowOnlyPurchaseRequired(e.target.checked)} - /> - } - label="발주 필요 항목만" - /> - - - - - - 변경 유형 - 분류 - 품명 - 사이즈 - 재질 - 기존 수량 - 새 수량 - 발주 수량 - - - - {!showOnlyPurchaseRequired && ( - <> - {/* 추가된 항목 */} - {revisionComparison.changes.added.map((item, index) => ( - - - - - - - - {item.item.original_description} - {item.item.size_spec || '-'} - {item.item.material_grade || '-'} - - - {item.item.quantity} - - - {item.item.quantity} - - - - ))} - - {/* 삭제된 항목 */} - {revisionComparison.changes.removed.map((item, index) => ( - - - - - - - - {item.item.original_description} - {item.item.size_spec || '-'} - {item.item.material_grade || '-'} - {item.item.quantity} - - - - - - - - - - ))} - - {/* 수량 변경된 항목 */} - {revisionComparison.changes.changed.map((item, index) => ( - 0 ? '#FFF3E0' : '#FFEBEE' - }}> - - 0 ? '수량 증가' : '수량 감소'} - color={item.quantity_change > 0 ? 'warning' : 'error'} - size="small" - /> - - - - - {item.new_item.original_description} - {item.new_item.size_spec || '-'} - {item.new_item.material_grade || '-'} - {item.old_item.quantity} - {item.new_item.quantity} - - 0 ? 'warning.main' : 'error.main'} - sx={{ fontWeight: 'bold' }} - > - {item.quantity_change > 0 ? `+${item.quantity_change}` : '-'} - - - - ))} - - )} - - {/* 발주 필요 항목만 표시 */} - {showOnlyPurchaseRequired && ( - <> - {/* 신규 추가 항목 */} - {getPurchaseRequiredItems().added.map((item, index) => ( - - - - - - - - {item.item.original_description} - {item.item.size_spec || '-'} - {item.item.material_grade || '-'} - - - {item.item.quantity} - - - {item.item.quantity} - - - - ))} - - {/* 수량 증가 항목 */} - {getPurchaseRequiredItems().changed.map((item, index) => ( - - - - - - - - {item.new_item.original_description} - {item.new_item.size_spec || '-'} - {item.new_item.material_grade || '-'} - {item.old_item.quantity} - {item.new_item.quantity} - - - +{item.quantity_change} - - - - ))} - - )} - -
-
-
-
-
-
- )} -
- )} - - {!loading && materials.length === 0 && fileId && ( - - 해당 파일에 자재 정보가 없습니다. + {materialSpecs.length === 0 && ( + + 💡 해당 파일에 자재 정보가 없습니다. )}