import pandas as pd import re from typing import List, Dict, Optional import uuid from datetime import datetime from pathlib import Path # 허용된 확장자 ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} class BOMParser: """BOM 파일 파싱을 담당하는 클래스""" @staticmethod def validate_extension(filename: str) -> bool: return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS @staticmethod def generate_unique_filename(original_filename: str) -> str: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") unique_id = str(uuid.uuid4())[:8] stem = Path(original_filename).stem suffix = Path(original_filename).suffix return f"{stem}_{timestamp}_{unique_id}{suffix}" @staticmethod def detect_format(df: pd.DataFrame) -> str: """ 엑셀 헤더를 분석하여 양식을 감지합니다. Returns: 'INVENTOR': 인벤터 추출 양식 (NO., NAME, Q'ty, LENGTH & THICKNESS...) 'STANDARD': 기존 표준/퍼지 매핑 엑셀 (DWG_NAME, LINE_NUM, MAIN_NOM...) """ columns = [str(c).strip().upper() for c in df.columns] # 인벤터 양식 특징 (오타 포함) INVENTOR_KEYWORDS = ['LENGTH & THICKNESS', 'DESCIPTION', "Q'TY"] for keyword in INVENTOR_KEYWORDS: if any(keyword in col for col in columns): return 'INVENTOR' # 표준 양식 특징 STANDARD_KEYWORDS = ['MAIN_NOM', 'DWG_NAME', 'LINE_NUM'] for keyword in STANDARD_KEYWORDS: if any(keyword in col for col in columns): return 'STANDARD' return 'STANDARD' # 기본값 @classmethod def parse_file(cls, file_path: str) -> List[Dict]: """파일 경로를 받아 적절한 파서를 통해 데이터를 추출합니다.""" file_extension = Path(file_path).suffix.lower() try: if file_extension == ".csv": df = pd.read_csv(file_path, encoding='utf-8') elif file_extension in [".xlsx", ".xls"]: # xlrd 엔진 명시 (xls 지원) if file_extension == ".xls": df = pd.read_excel(file_path, sheet_name=0, engine='xlrd') else: df = pd.read_excel(file_path, sheet_name=0, engine='openpyxl') else: raise ValueError("지원하지 않는 파일 형식") # 데이터프레임 전처리 (빈 행 제거 등) df = df.dropna(how='all') # 양식 감지 format_type = cls.detect_format(df) print(f"📋 감지된 BOM 양식: {format_type}") if format_type == 'INVENTOR': return cls._parse_inventor_bom(df) else: return cls._parse_standard_bom(df) except Exception as e: raise ValueError(f"파일 파싱 실패: {str(e)}") @staticmethod def _parse_standard_bom(df: pd.DataFrame) -> List[Dict]: """기존의 퍼지 매핑 방식 파서 (표준 양식)""" # 컬럼명 전처리 df.columns = df.columns.str.strip().str.upper() # 대소문자 구분 없는 매핑을 위해 컬럼명 대문자화 사용 column_mapping = { 'description': ['DESCRIPTION', 'ITEM', 'MATERIAL', '품명', '자재명', 'SPECIFICATION'], 'quantity': ['QTY', 'QUANTITY', 'EA', '수량', 'AMOUNT'], 'main_size': ['MAIN_NOM', 'NOMINAL_DIAMETER', 'ND', '주배관', 'SIZE'], 'red_size': ['RED_NOM', 'REDUCED_DIAMETER', '축소배관'], 'length': ['LENGTH', 'LEN', '길이'], 'weight': ['WEIGHT', 'WT', '중량'], 'dwg_name': ['DWG_NAME', 'DRAWING', '도면명', '도면번호'], 'line_num': ['LINE_NUM', 'LINE_NUMBER', '라인번호'] } mapped_columns = {} for standard_col, possible_names in column_mapping.items(): for possible_name in possible_names: # 대문자로 비교 possible_upper = possible_name.upper() if possible_upper in df.columns: mapped_columns[standard_col] = possible_upper break print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}") materials = [] for index, row in df.iterrows(): description = str(row.get(mapped_columns.get('description', ''), '')) # 제외 항목 처리 description_upper = description.upper() if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or '웰드갭' in description_upper or '용접갭' in description_upper): continue # 수량 처리 quantity_raw = row.get(mapped_columns.get('quantity', ''), 0) try: quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0 except: quantity = 0 # 재질 등급 추출 (ASTM) material_grade = "" if "ASTM" in description_upper: astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\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 = main_size if main_size != 'nan' and main_size != '' else None red_nom = red_size if red_size != 'nan' and red_size != '' else None if main_size != 'nan' and red_size != 'nan' and red_size != '': size_spec = f"{main_size} x {red_size}" elif main_size != 'nan' and main_size != '': size_spec = main_size else: size_spec = "" # 길이 처리 length_raw = row.get(mapped_columns.get('length', ''), '') length_value = None if pd.notna(length_raw) and str(length_raw).strip() != '': try: length_value = float(str(length_raw).strip()) except: length_value = None # 도면/라인 번호 dwg_name = row.get(mapped_columns.get('dwg_name', ''), '') dwg_name = str(dwg_name).strip() if pd.notna(dwg_name) and str(dwg_name).strip() not in ['', 'nan', 'None'] else None line_num = row.get(mapped_columns.get('line_num', ''), '') line_num = str(line_num).strip() if pd.notna(line_num) and str(line_num).strip() not in ['', 'nan', 'None'] else None if description and description not in ['nan', 'None', '']: materials.append({ 'original_description': description, 'quantity': quantity, 'unit': "EA", 'size_spec': size_spec, 'main_nom': main_nom, 'red_nom': red_nom, 'material_grade': material_grade, 'length': length_value, 'dwg_name': dwg_name, 'line_num': line_num, 'line_number': index + 1, 'row_number': index + 1 }) return materials @staticmethod def _parse_inventor_bom(df: pd.DataFrame) -> List[Dict]: """ [신규] 인벤터 추출 양식 파서 헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK 특징: Size 컬럼 부재, NAME에 주요 정보 포함 """ print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.") # 컬럼명 전처리 (좌우 공백 제거 및 대문자화) df.columns = df.columns.str.strip().str.upper() # 인벤터 전용 매핑 col_name = 'NAME' col_qty = "Q'TY" col_desc = 'DESCIPTION' # 오타 그대로 반영 col_remark = 'REMARK' col_length = 'LENGTH & THICKNESS' # 길이 정보가 여기 있을 수 있음 materials = [] for index, row in df.iterrows(): # 1. 품명 (NAME 컬럼 우선 사용) name_val = str(row.get(col_name, '')).strip() desc_val = str(row.get(col_desc, '')).strip() # NAME과 DESCIPTION 병합 (필요시) # 보통 인벤터에서 NAME이 'ADAPTER OD 1/4" x NPT(F) 1/2"' 같이 상세 스펙 # DESCIPTION은 'SS-400-7-8' 같은 자재 코드일 수 있음 # 일단 NAME을 메인 description으로 사용하고, DESCIPTION을 괄호에 추가 if desc_val and desc_val not in ['nan', 'None', '']: full_description = f"{name_val} ({desc_val})" else: full_description = name_val if not full_description or full_description in ['nan', 'None', '']: continue # 2. 수량 qty_raw = row.get(col_qty, 0) try: quantity = float(qty_raw) if pd.notna(qty_raw) else 0 except: quantity = 0 # 3. 사이즈 추출 (NAME 컬럼 분석) # 패턴: 1/2", 1/4", 100A, 50A, 10x20 등 size_spec = "" main_nom = None red_nom = None # 인치/MM 사이즈 추출 시도 # 예: "ADAPTER OD 1/4" x NPT(F) 1/2"" -> 1/4" x 1/2" # 예: "ELBOW 90D 100A" -> 100A # 인치 패턴 (1/2", 3/4" 등) inch_sizes = re.findall(r'(\d+(?:/\d+)?)"', name_val) # A단위 패턴 (100A, 50A 등) a_sizes = re.findall(r'(\d+)A', name_val) if inch_sizes: if len(inch_sizes) >= 2: main_nom = f'{inch_sizes[0]}"' red_nom = f'{inch_sizes[1]}"' size_spec = f'{main_nom} x {red_nom}' else: main_nom = f'{inch_sizes[0]}"' size_spec = main_nom elif a_sizes: if len(a_sizes) >= 2: main_nom = f'{a_sizes[0]}A' red_nom = f'{a_sizes[1]}A' size_spec = f'{main_nom} x {red_nom}' else: main_nom = f'{a_sizes[0]}A' size_spec = main_nom # 4. 재질 정보 material_grade = "" # NAME이나 DESCIPTION에서 재질 키워드 찾기 (SS, SUS, A105 등) combined_text = (full_description + " " + desc_val).upper() if "SUS" in combined_text or "SS" in combined_text: if "304" in combined_text: material_grade = "SUS304" elif "316" in combined_text: material_grade = "SUS316" else: material_grade = "SUS" elif "A105" in combined_text: material_grade = "A105" # 5. 길이 정보 length_value = None length_raw = row.get(col_length, '') # 값이 있고 숫자로 변환 가능하면 사용 if pd.notna(length_raw) and str(length_raw).strip(): try: # '100 mm' 등의 형식 처리 필요할 수 있음 length_str = str(length_raw).lower().replace('mm', '').strip() length_value = float(length_str) except: pass materials.append({ 'original_description': full_description, 'quantity': quantity, 'unit': "EA", 'size_spec': size_spec, 'main_nom': main_nom, 'red_nom': red_nom, 'material_grade': material_grade, 'length': length_value, 'dwg_name': None, # 인벤터 파일엔 도면명 컬럼이 없음 'line_num': None, 'line_number': index + 1, 'row_number': index + 1 }) return materials