Files
TK-BOM-Project/backend/app/services/excel_parser.py

301 lines
12 KiB
Python

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