diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 896f1b0..7bbc61d 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -497,6 +497,180 @@ async def upload_file( except Exception as e: print(f"VALVE 상세정보 저장 실패: {e}") + elif category == 'FLANGE' and confidence >= 0.5: + try: + flange_info = classification_result + + flange_insert_query = text(""" + INSERT INTO flange_details ( + material_id, file_id, flange_type, facing_type, + pressure_rating, material_standard, material_grade, + size_inches, classification_confidence, additional_info + ) + VALUES ( + (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), + :file_id, :flange_type, :facing_type, + :pressure_rating, :material_standard, :material_grade, + :size_inches, :classification_confidence, :additional_info + ) + """) + + db.execute(flange_insert_query, { + "file_id": file_id, + "description": material_data["original_description"], + "row_number": material_data["row_number"], + "flange_type": flange_info.get('flange_type', {}).get('type', ''), + "facing_type": flange_info.get('face_finish', {}).get('finish', ''), + "pressure_rating": flange_info.get('pressure_rating', {}).get('rating', ''), + "material_standard": flange_info.get('material', {}).get('standard', ''), + "material_grade": flange_info.get('material', {}).get('grade', ''), + "size_inches": material_data.get('size_spec', ''), + "classification_confidence": confidence, + "additional_info": json.dumps(flange_info, ensure_ascii=False) + }) + + print(f"FLANGE 상세정보 저장 완료: {material_data['original_description']}") + + except Exception as e: + print(f"FLANGE 상세정보 저장 실패: {e}") + + elif category == 'BOLT' and confidence >= 0.5: + try: + bolt_info = classification_result + + bolt_insert_query = text(""" + INSERT INTO bolt_details ( + material_id, file_id, bolt_type, thread_type, + diameter, length, material_standard, material_grade, + coating_type, includes_nut, includes_washer, + classification_confidence, additional_info + ) + VALUES ( + (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), + :file_id, :bolt_type, :thread_type, + :diameter, :length, :material_standard, :material_grade, + :coating_type, :includes_nut, :includes_washer, + :classification_confidence, :additional_info + ) + """) + + # BOLT 분류기 결과 구조에 맞게 데이터 추출 + bolt_details = bolt_info.get('bolt_details', {}) + material_info = bolt_info.get('material', {}) + + db.execute(bolt_insert_query, { + "file_id": file_id, + "description": material_data["original_description"], + "row_number": material_data["row_number"], + "bolt_type": bolt_details.get('type', ''), + "thread_type": bolt_details.get('thread_type', ''), + "diameter": bolt_details.get('diameter', ''), + "length": bolt_details.get('length', ''), + "material_standard": material_info.get('standard', ''), + "material_grade": material_info.get('grade', ''), + "coating_type": material_info.get('coating', ''), + "includes_nut": bolt_details.get('includes_nut', False), + "includes_washer": bolt_details.get('includes_washer', False), + "classification_confidence": confidence, + "additional_info": json.dumps(bolt_info, ensure_ascii=False) + }) + + print(f"BOLT 상세정보 저장 완료: {material_data['original_description']}") + + except Exception as e: + print(f"BOLT 상세정보 저장 실패: {e}") + + elif category == 'GASKET' and confidence >= 0.5: + try: + gasket_info = classification_result + + gasket_insert_query = text(""" + INSERT INTO gasket_details ( + material_id, file_id, gasket_type, gasket_subtype, + material_type, size_inches, pressure_rating, + thickness, temperature_range, fire_safe, + classification_confidence, additional_info + ) + VALUES ( + (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), + :file_id, :gasket_type, :gasket_subtype, + :material_type, :size_inches, :pressure_rating, + :thickness, :temperature_range, :fire_safe, + :classification_confidence, :additional_info + ) + """) + + # GASKET 분류기 결과 구조에 맞게 데이터 추출 + gasket_type_info = gasket_info.get('gasket_type', {}) + material_info = gasket_info.get('material', {}) + + db.execute(gasket_insert_query, { + "file_id": file_id, + "description": material_data["original_description"], + "row_number": material_data["row_number"], + "gasket_type": gasket_type_info.get('type', ''), + "gasket_subtype": gasket_type_info.get('subtype', ''), + "material_type": material_info.get('type', ''), + "size_inches": material_data.get('size_spec', ''), + "pressure_rating": gasket_info.get('pressure_rating', ''), + "thickness": gasket_info.get('thickness', ''), + "temperature_range": material_info.get('temperature_range', ''), + "fire_safe": gasket_info.get('fire_safe', False), + "classification_confidence": confidence, + "additional_info": json.dumps(gasket_info, ensure_ascii=False) + }) + + print(f"GASKET 상세정보 저장 완료: {material_data['original_description']}") + + except Exception as e: + print(f"GASKET 상세정보 저장 실패: {e}") + + elif category == 'INSTRUMENT' and confidence >= 0.5: + try: + inst_info = classification_result + + inst_insert_query = text(""" + INSERT INTO instrument_details ( + material_id, file_id, instrument_type, instrument_subtype, + measurement_type, measurement_range, accuracy, + connection_type, connection_size, body_material, + classification_confidence, additional_info + ) + VALUES ( + (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), + :file_id, :instrument_type, :instrument_subtype, + :measurement_type, :measurement_range, :accuracy, + :connection_type, :connection_size, :body_material, + :classification_confidence, :additional_info + ) + """) + + # INSTRUMENT 분류기 결과 구조에 맞게 데이터 추출 + inst_type_info = inst_info.get('instrument_type', {}) + measurement_info = inst_info.get('measurement', {}) + connection_info = inst_info.get('connection', {}) + + db.execute(inst_insert_query, { + "file_id": file_id, + "description": material_data["original_description"], + "row_number": material_data["row_number"], + "instrument_type": inst_type_info.get('type', ''), + "instrument_subtype": inst_type_info.get('subtype', ''), + "measurement_type": measurement_info.get('type', ''), + "measurement_range": measurement_info.get('range', ''), + "accuracy": measurement_info.get('accuracy', ''), + "connection_type": connection_info.get('type', ''), + "connection_size": connection_info.get('size', ''), + "body_material": inst_info.get('material', ''), + "classification_confidence": confidence, + "additional_info": json.dumps(inst_info, ensure_ascii=False) + }) + + print(f"INSTRUMENT 상세정보 저장 완료: {material_data['original_description']}") + + except Exception as e: + print(f"INSTRUMENT 상세정보 저장 실패: {e}") + materials_inserted += 1 db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 9df1b56..fa68e31 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -87,7 +87,7 @@ async def get_files( "original_filename": f.original_filename, "name": f.original_filename, "job_no": f.job_no, # job_no 사용 - "bom_name": f.original_filename, # 파일명을 BOM 이름으로 사용 + "bom_name": f.bom_name or f.original_filename, # 실제 bom_name 값 사용, 없으면 파일명 "bom_type": f.file_type or "unknown", # file_type을 BOM 종류로 사용 "status": "active" if f.is_active else "inactive", # is_active 상태 "file_size": f.file_size, @@ -118,7 +118,26 @@ async def delete_file( if not file: return {"error": "파일을 찾을 수 없습니다"} - # 관련 자재 데이터 삭제 + # 먼저 상세 테이블의 데이터 삭제 (외래 키 제약 조건 때문) + # 각 자재 타입별 상세 테이블 데이터 삭제 + detail_tables = [ + 'pipe_details', 'fitting_details', 'valve_details', + 'flange_details', 'bolt_details', 'gasket_details', + 'instrument_details' + ] + + # 해당 파일의 materials ID 조회 + material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id") + material_ids_result = db.execute(material_ids_query, {"file_id": file_id}) + material_ids = [row[0] for row in material_ids_result] + + if material_ids: + # 각 상세 테이블에서 관련 데이터 삭제 + for table in detail_tables: + delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)") + db.execute(delete_detail_query, {"material_ids": material_ids}) + + # materials 테이블 데이터 삭제 materials_query = text("DELETE FROM materials WHERE file_id = :file_id") db.execute(materials_query, {"file_id": file_id}) @@ -267,17 +286,17 @@ async def upload_file( file_type = "excel" if file.filename.endswith(('.xls', '.xlsx')) else "csv" if file.filename.endswith('.csv') else "unknown" # BOM 종류별 자동 리비전 관리 - if bom_type and not parent_bom_id: - # 같은 job_no의 같은 파일명에 대한 최신 리비전 조회 + 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 original_filename = :filename + 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, - "filename": file.filename + "bom_name": bom_name }) latest_file = result.fetchone() @@ -300,10 +319,10 @@ async def upload_file( insert_query = text(""" INSERT INTO files ( job_no, filename, original_filename, file_path, - file_size, upload_date, revision, file_type, uploaded_by + 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 + :file_size, NOW(), :revision, :file_type, :uploaded_by, :bom_name ) RETURNING id """) @@ -315,7 +334,8 @@ async def upload_file( "file_size": file_size, "revision": revision, "file_type": file_type, - "uploaded_by": "system" + "uploaded_by": "system", + "bom_name": bom_name }) file_id = result.fetchone()[0] @@ -372,9 +392,10 @@ async def upload_file( :quantity, :unit, :drawing_name, :area_code, :line_no, :classification_confidence, :is_verified, NOW() ) + RETURNING id """) - db.execute(insert_material_query, { + result = db.execute(insert_material_query, { "file_id": file_id, "line_number": material.get("line_number", 0), "original_description": material.get("original_description", ""), @@ -391,6 +412,191 @@ async def upload_file( "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() @@ -442,6 +648,7 @@ def parse_file(file_path: str) -> List[Dict]: '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'] @@ -470,6 +677,7 @@ def parse_file(file_path: str) -> List[Dict]: 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 '') @@ -480,6 +688,7 @@ def parse_file(file_path: str) -> List[Dict]: "quantity": quantity, "length": length, "unit": unit, + "size_spec": size, "drawing_name": drawing, "area_code": area, "line_no": line @@ -590,6 +799,92 @@ def classify_material_item(material: Dict) -> Dict: "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 @app.get("/health") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index aa95029..9864070 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -67,7 +67,10 @@ def generate_unique_filename(original_filename: str) -> str: def parse_dataframe(df): df = df.dropna(how='all') + # 원본 컬럼명 출력 + print(f"원본 컬럼들: {list(df.columns)}") df.columns = df.columns.str.strip().str.lower() + print(f"소문자 변환 후: {list(df.columns)}") column_mapping = { 'description': ['description', 'item', 'material', '품명', '자재명'], @@ -87,6 +90,8 @@ def parse_dataframe(df): mapped_columns[standard_col] = possible_name break + print(f"찾은 컬럼 매핑: {mapped_columns}") + materials = [] for index, row in df.iterrows(): description = str(row.get(mapped_columns.get('description', ''), '')) @@ -269,6 +274,13 @@ async def upload_file( 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"], @@ -291,20 +303,19 @@ async def upload_file( if classification_result.get("category") == "PIPE": print("PIPE 상세 정보 저장 시작") - # 길이 정보 추출 - length_mm = None - if "length_info" in classification_result: - length_mm = classification_result["length_info"].get("length_mm") + # 길이 정보 추출 - material_data에서 직접 가져옴 + length_mm = material_data.get("length", 0.0) if material_data.get("length") else None + # material_id도 함께 저장하도록 수정 pipe_detail_insert_query = text(""" INSERT INTO pipe_details ( - file_id, material_standard, material_grade, material_type, + 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 ( - :file_id, :material_standard, :material_grade, :material_type, + :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 @@ -319,6 +330,7 @@ async def upload_file( size_info = classification_result.get("size_info", {}) 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"), @@ -327,7 +339,7 @@ async def upload_file( "end_preparation": end_prep_info.get("type"), "schedule": schedule_info.get("schedule"), "wall_thickness": schedule_info.get("wall_thickness"), - "nominal_size": size_info.get("nominal_size"), + "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), @@ -447,6 +459,7 @@ async def get_materials( 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.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 FROM materials m @@ -552,33 +565,85 @@ async def get_materials( count_result = db.execute(text(count_query), count_params) total_count = count_result.fetchone()[0] + # 각 자재의 상세 정보도 가져오기 + material_list = [] + for m in materials: + 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, + "material_grade": m.material_grade, + "line_number": m.line_number, + "row_number": m.row_number, + "classified_category": m.classified_category, + "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0, + "classification_details": m.classification_details, + "created_at": m.created_at + } + + # 카테고리별 상세 정보 추가 + 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: + 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 + } + elif m.classified_category == 'FITTING': + fitting_query = text("SELECT * FROM fitting_details WHERE material_id = :material_id") + fitting_result = db.execute(fitting_query, {"material_id": m.id}) + fitting_detail = fitting_result.fetchone() + if fitting_detail: + material_dict['fitting_details'] = { + "fitting_type": fitting_detail.fitting_type, + "fitting_subtype": fitting_detail.fitting_subtype, + "connection_method": fitting_detail.connection_method, + "pressure_rating": fitting_detail.pressure_rating, + "material_standard": fitting_detail.material_standard, + "material_grade": fitting_detail.material_grade, + "main_size": fitting_detail.main_size, + "reduced_size": fitting_detail.reduced_size + } + 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 + } + + material_list.append(material_dict) + return { "success": True, "total_count": total_count, "returned_count": len(materials), "skip": skip, "limit": limit, - "materials": [ - { - "id": m.id, - "file_id": m.file_id, - "filename": m.original_filename, - "project_id": m.project_id, - "project_code": m.official_project_code, - "project_name": m.project_name, - "original_description": m.original_description, - "quantity": float(m.quantity) if m.quantity else 0, - "unit": m.unit, - "size_spec": m.size_spec, - "material_grade": m.material_grade, - "line_number": m.line_number, - "row_number": m.row_number, - "classified_category": m.classified_category, - "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0, - "created_at": m.created_at - } - for m in materials - ] + "materials": material_list } except Exception as e: @@ -863,6 +928,116 @@ async def get_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, diff --git a/frontend/src/components/FileUpload.jsx b/frontend/src/components/FileUpload.jsx index 90ebcfa..e455f2d 100644 --- a/frontend/src/components/FileUpload.jsx +++ b/frontend/src/components/FileUpload.jsx @@ -132,11 +132,16 @@ function FileUpload({ selectedProject, onUploadSuccess }) { formData.append('file', file); formData.append('job_no', selectedProject.job_no); formData.append('revision', 'Rev.0'); + formData.append('bom_name', file.name); // BOM 이름으로 파일명 사용 + formData.append('bom_type', 'excel'); // 파일 타입 + formData.append('description', ''); // 설명 (빈 문자열) console.log('FormData 내용:', { fileName: file.name, jobNo: selectedProject.job_no, - revision: 'Rev.0' + revision: 'Rev.0', + bomName: file.name, + bomType: 'excel' }); try { diff --git a/frontend/src/components/FittingDetailsCard.jsx b/frontend/src/components/FittingDetailsCard.jsx new file mode 100644 index 0000000..ff7d10e --- /dev/null +++ b/frontend/src/components/FittingDetailsCard.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material'; + +const FittingDetailsCard = ({ material }) => { + const fittingDetails = material.fitting_details || {}; + + return ( + + + + + 🔗 FITTING 상세 정보 + + = 0.8 ? 'success' : 'warning'} + size="small" + /> + + + + + + + 자재명 + + {material.original_description} + + + + + 피팅 타입 + + {fittingDetails.fitting_type || '-'} + + + + + 세부 타입 + + {fittingDetails.fitting_subtype || '-'} + + + + + 연결 방식 + + {fittingDetails.connection_method || '-'} + + + + + 압력 등급 + + {fittingDetails.pressure_rating || '-'} + + + + + 재질 규격 + + {fittingDetails.material_standard || '-'} + + + + + 재질 등급 + + {fittingDetails.material_grade || material.material_grade || '-'} + + + + + 주 사이즈 + + {fittingDetails.main_size || material.size_spec || '-'} + + + + + 축소 사이즈 + + {fittingDetails.reduced_size || '-'} + + + + + 수량 + + {material.quantity} {material.unit} + + + + + + ); +}; + +export default FittingDetailsCard; \ No newline at end of file diff --git a/frontend/src/components/PipeDetailsCard.jsx b/frontend/src/components/PipeDetailsCard.jsx index 0f79250..5e59f4a 100644 --- a/frontend/src/components/PipeDetailsCard.jsx +++ b/frontend/src/components/PipeDetailsCard.jsx @@ -1,28 +1,96 @@ import React from 'react'; -import { Card, CardContent, Typography, Box } from '@mui/material'; +import { Card, CardContent, Typography, Box, Grid, Chip, Divider } from '@mui/material'; -const PipeDetailsCard = ({ material, fileId }) => { - // 간단한 테스트 버전 +const PipeDetailsCard = ({ material }) => { + const pipeDetails = material.pipe_details || {}; + return ( - - PIPE 상세 정보 (테스트) - - - - 자재명: {material.original_description} - - - 분류: {material.classified_category} - - - 사이즈: {material.size_spec || '정보 없음'} - - - 수량: {material.quantity} {material.unit} + + + 🔧 PIPE 상세 정보 + = 0.8 ? 'success' : 'warning'} + size="small" + /> + + + + + + 자재명 + + {material.original_description} + + + + + 크기 + + {pipeDetails.size_inches || material.size_spec || '-'} + + + + + 스케줄 + + {pipeDetails.schedule_type || '-'} + + + + + 재질 + + {pipeDetails.material_spec || material.material_grade || '-'} + + + + + 제작방식 + + {pipeDetails.manufacturing_method || '-'} + + + + + 길이 + + {pipeDetails.length_mm ? `${pipeDetails.length_mm}mm` : '-'} + + + + + 외경 + + {pipeDetails.outer_diameter_mm ? `${pipeDetails.outer_diameter_mm}mm` : '-'} + + + + + 두께 + + {pipeDetails.wall_thickness_mm ? `${pipeDetails.wall_thickness_mm}mm` : '-'} + + + + + 중량 + + {pipeDetails.weight_per_meter_kg ? `${pipeDetails.weight_per_meter_kg}kg/m` : '-'} + + + + + 수량 + + {material.quantity} {material.unit} + + + ); diff --git a/frontend/src/pages/BOMStatusPage.jsx b/frontend/src/pages/BOMStatusPage.jsx index fbe47b3..78d28fc 100644 --- a/frontend/src/pages/BOMStatusPage.jsx +++ b/frontend/src/pages/BOMStatusPage.jsx @@ -1,15 +1,20 @@ import React, { useState, useEffect } from 'react'; -import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert } from '@mui/material'; +import { Box, Typography, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, CircularProgress, Alert, TextField, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; import { useSearchParams, useNavigate } from 'react-router-dom'; +import { uploadFile as uploadFileApi } from '../api'; const BOMStatusPage = () => { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [uploading, setUploading] = useState(false); - const [file, setFile] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [bomName, setBomName] = useState(''); + const [revisionDialog, setRevisionDialog] = useState({ open: false, bomName: '', parentId: null }); + const [revisionFile, setRevisionFile] = useState(null); const [searchParams] = useSearchParams(); const jobNo = searchParams.get('job_no'); + const jobName = searchParams.get('job_name'); const navigate = useNavigate(); // 파일 목록 불러오기 @@ -39,98 +44,325 @@ const BOMStatusPage = () => { // eslint-disable-next-line }, [jobNo]); + // BOM 이름 중복 체크 + const checkDuplicateBOM = () => { + return files.some(file => + file.bom_name === bomName || + file.original_filename === bomName || + file.filename === bomName + ); + }; + // 파일 업로드 핸들러 - const handleUpload = async (e) => { - e.preventDefault(); - if (!file) return; + const handleUpload = async () => { + if (!selectedFile) { + setError('파일을 선택해주세요.'); + return; + } + + if (!bomName.trim()) { + setError('BOM 이름을 입력해주세요.'); + return; + } + setUploading(true); setError(''); + try { - const formData = new FormData(); - formData.append('file', file); - formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요 - const res = await fetch('http://localhost:8000/upload', { - method: 'POST', - body: formData - }); - if (!res.ok) throw new Error('업로드 실패'); - setFile(null); - fetchFiles(); + const isDuplicate = checkDuplicateBOM(); + if (isDuplicate && !confirm(`"${bomName}"은(는) 이미 존재하는 BOM입니다. 새로운 리비전으로 업로드하시겠습니까?`)) { + setUploading(false); + return; + } + + const formData = new FormData(); + formData.append('file', selectedFile); + formData.append('job_no', jobNo); + formData.append('revision', 'Rev.0'); + formData.append('bom_name', bomName); + formData.append('bom_type', 'excel'); + formData.append('description', ''); + + const response = await uploadFileApi(formData); + + if (response.data.success) { + setSelectedFile(null); + setBomName(''); + // 파일 input 초기화 + const fileInput = document.getElementById('file-input'); + if (fileInput) fileInput.value = ''; + + fetchFiles(); + alert(`업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision || 'Rev.0'}`); + } else { + setError(response.data.message || '업로드에 실패했습니다.'); + } } catch (e) { - setError('파일 업로드에 실패했습니다.'); + console.error('업로드 에러:', e); + if (e.response?.data?.detail) { + setError(e.response.data.detail); + } else { + setError('파일 업로드에 실패했습니다.'); + } } finally { setUploading(false); } }; + // 리비전 업로드 핸들러 + const handleRevisionUpload = async () => { + if (!revisionFile) { + setError('파일을 선택해주세요.'); + return; + } + + setUploading(true); + setError(''); + + try { + const formData = new FormData(); + formData.append('file', revisionFile); + formData.append('job_no', jobNo); + formData.append('revision', 'Rev.0'); // 백엔드에서 자동 증가 + formData.append('bom_name', revisionDialog.bomName); + formData.append('bom_type', 'excel'); + formData.append('description', ''); + formData.append('parent_bom_id', revisionDialog.parentId); + + const response = await uploadFileApi(formData); + + if (response.data.success) { + setRevisionDialog({ open: false, bomName: '', parentId: null }); + setRevisionFile(null); + fetchFiles(); + alert(`리비전 업로드 성공! ${response.data.materials_count || 0}개의 자재가 분류되었습니다.\n리비전: ${response.data.revision}`); + } else { + setError(response.data.message || '리비전 업로드에 실패했습니다.'); + } + } catch (e) { + console.error('리비전 업로드 에러:', e); + if (e.response?.data?.detail) { + setError(e.response.data.detail); + } else { + setError('리비전 업로드에 실패했습니다.'); + } + } finally { + setUploading(false); + } + }; + + // BOM별로 그룹화 + const groupFilesByBOM = () => { + const grouped = {}; + files.forEach(file => { + const bomKey = file.bom_name || file.original_filename || file.filename; + if (!grouped[bomKey]) { + grouped[bomKey] = []; + } + grouped[bomKey].push(file); + }); + + // 각 그룹을 리비전 순으로 정렬 + Object.keys(grouped).forEach(key => { + grouped[key].sort((a, b) => { + const revA = parseInt(a.revision?.replace('Rev.', '') || '0'); + const revB = parseInt(b.revision?.replace('Rev.', '') || '0'); + return revB - revA; // 최신 리비전이 먼저 오도록 + }); + }); + + return grouped; + }; + return ( BOM 업로드 및 현황 -
- setFile(e.target.files[0])} - disabled={uploading} + {jobNo && jobName && ( + + {jobNo} - {jobName} + + )} + + {/* 파일 업로드 폼 */} + + 새 BOM 업로드 + + setBomName(e.target.value)} + placeholder="예: PIPING_BOM_A구역" + required + size="small" + helperText="동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다" + /> + + setSelectedFile(e.target.files[0])} + style={{ flex: 1 }} /> - - + + {selectedFile && ( + + 선택된 파일: {selectedFile.name} + + )} + +
+ {error && {error}} - {loading && } - - - - - 파일명 - 리비전 - 세부내역 - 리비전 - 삭제 - - - - {files.map(file => ( - - {file.original_filename || file.filename} - {file.revision} - - - - - - - -
+ + + BOM 이름 + 파일명 + 리비전 + 자재 수 + 업로드 일시 + 작업 + + + + {Object.entries(groupFilesByBOM()).map(([bomKey, bomFiles]) => ( + bomFiles.map((file, index) => ( + - 삭제 - - - - ))} - -
-
+ + + {file.bom_name || bomKey} + + {index === 0 && bomFiles.length > 1 && ( + + (최신 리비전) + + )} + + {file.filename || file.original_filename} + + + {file.revision || 'Rev.0'} + + + {file.parsed_count || '-'} + + {file.upload_date ? new Date(file.upload_date).toLocaleString('ko-KR') : '-'} + + + + {index === 0 && ( + + )} + + + + )) + ))} + + + + )} + + {/* 리비전 업로드 다이얼로그 */} + setRevisionDialog({ open: false, bomName: '', parentId: null })}> + 리비전 업로드 + + + BOM 이름: {revisionDialog.bomName} + + + 새로운 리비전 파일을 선택하세요. 리비전 번호는 자동으로 증가합니다. + + setRevisionFile(e.target.files[0])} + style={{ marginTop: 16 }} + /> + {revisionFile && ( + + 선택된 파일: {revisionFile.name} + + )} + + + + + +
); }; diff --git a/frontend/src/pages/MaterialsPage.jsx b/frontend/src/pages/MaterialsPage.jsx index 9b7859e..752e01f 100644 --- a/frontend/src/pages/MaterialsPage.jsx +++ b/frontend/src/pages/MaterialsPage.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Box, Card, @@ -21,7 +22,9 @@ import { FormControlLabel, Switch } 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, @@ -55,9 +58,14 @@ const MaterialsPage = () => { const [revisionComparison, setRevisionComparison] = useState(null); const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false); + const navigate = useNavigate(); + + // 컴포넌트 마운트 확인 + console.log('MaterialsPage 컴포넌트 마운트됨'); + useEffect(() => { const urlParams = new URLSearchParams(window.location.search); - const id = urlParams.get('fileId'); + const id = urlParams.get('file_id'); // fileId -> file_id로 변경 if (id) { setFileId(id); loadMaterials(id); @@ -71,9 +79,20 @@ const MaterialsPage = () => { console.log('자재 로딩 시작, file_id:', id); try { setLoading(true); - const response = await api.get('/files/materials', { params: { file_id: parseInt(id) } }); + // limit을 충분히 크게 설정하여 모든 자재를 가져옴 + const response = await api.get('/files/materials', { params: { file_id: parseInt(id), limit: 10000 } }); console.log('자재 데이터 로딩 성공:', response.data); - setMaterials(response.data); + + // 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('자재 정보를 불러오는데 실패했습니다.'); @@ -107,6 +126,12 @@ const MaterialsPage = () => { const calculateCategoryStats = () => { const stats = {}; + // materials가 배열인지 확인 + if (!Array.isArray(materials)) { + console.error('materials is not an array:', materials); + return stats; + } + materials.forEach(material => { const category = material.classified_category || 'UNKNOWN'; if (!stats[category]) { @@ -119,11 +144,24 @@ const MaterialsPage = () => { }; 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; @@ -226,25 +264,103 @@ const MaterialsPage = () => { }; }; + // 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, + 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) => { - const details = item.classification_details || {}; - const cuttingDimensions = details?.cutting_dimensions || {}; - let lengthMm = cuttingDimensions?.length_mm; - - // 백엔드에서 전달된 length 필드도 확인 - if (!lengthMm && item.length) { - lengthMm = item.length; + let lengthMm = 0; + if (item.pipe_details?.length_mm) { + lengthMm = item.pipe_details.length_mm; } - - return sum + (lengthMm || 0); + return sum + lengthMm; }, 0); return { value: totalLength, unit: 'mm', - displayText: `${totalLength}mm`, + displayText: `${(totalLength / 1000).toFixed(1)}m`, isLength: true }; case 'BOLT': @@ -382,8 +498,22 @@ const MaterialsPage = () => { 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 ( + {/* 뒤로가기 버튼 */} + + 📋 자재 분류 결과 @@ -669,15 +799,79 @@ const MaterialsPage = () => { )} {/* 상세 목록 탭 */} - {!loading && materials.length > 0 && activeTab === 1 && ( - - - 📋 상세 자재 목록 (테스트) - - - 총 {materials.length}개 자재가 로드되었습니다. - - + {!loading && materials.length > 0 && activeTab === 1 && (() => { + const pipeItems = materials.filter(m => m.classified_category === 'PIPE'); + + return ( + + + 📋 상세 자재 목록 (테스트) + + + 총 {materials.length}개 자재가 로드되었습니다. + + + {/* PIPE 분석 섹션 */} + {pipeItems.length > 0 && ( + + + + 🔧 PIPE 분석 ({pipeItems.length}개) + + + 동일한 재질-크기-스케줄-제작방식을 가진 파이프들을 그룹화하여 표시합니다. + + + + + + + 재질 + 외경 + 스케줄 + 제작방식 + 총 길이 + 개수 + + + + {groupPipesBySpecs(pipeItems).map((group, index) => ( + + {group.material} + {group.size} + {group.schedule} + {group.manufacturing} + + {(group.totalLength / 1000).toFixed(2)}m + + ({group.totalLength.toFixed(0)}mm) + + + + + + + ))} + +
+
+ + {/* 총계 */} + + + 총 파이프 길이: {(pipeItems.reduce((sum, item) => sum + (item.pipe_details?.length_mm || 0), 0) / 1000).toFixed(2)}m + {' '}({groupPipesBySpecs(pipeItems).length}가지 규격) + + +
+
+ )} + {/* 필터 */} {getAvailableCategories().map(category => ( @@ -740,11 +934,45 @@ const MaterialsPage = () => {
- {/* PIPE 상세 정보 */} + {/* 자재별 상세 정보 카드 */} {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 || '-'} + + + )} @@ -755,7 +983,8 @@ const MaterialsPage = () => {
- )} + ); + })()} {/* 리비전 비교 탭 */} {!loading && materials.length > 0 && activeTab === 2 && (