엑셀 파싱 이원화(표준/인벤터) 및 자재 분류기(Plate, H-Beam, Swagelok) 개선

This commit is contained in:
Hyungi Ahn
2026-01-08 11:14:25 +09:00
parent 6ad1ef7aad
commit afea8428b2
7 changed files with 1059 additions and 1290 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
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

View File

@@ -248,78 +248,23 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
)
# 6. 최종 결과 조합
# --- 계장용(Instrument/Swagelok) 피팅 감지 로직 추가 ---
instrument_keywords = ["SWAGELOK", "DK-LOK", "TUBE FITTING", "UNION", "FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"]
is_instrument = any(kw in desc_upper for kw in instrument_keywords)
if is_instrument:
fitting_type["category"] = "INSTRUMENT_FITTING"
if "SWAGELOK" in desc_upper: fitting_type["brand"] = "SWAGELOK"
# Tube OD 추출 (예: 1/4", 6MM, 12MM)
tube_match = re.search(r'(\d+(?:/\d+)?)\s*(?:\"|INCH|MM)\s*(?:OD|TUBE)', desc_upper)
if tube_match:
fitting_type["tube_od"] = tube_match.group(0)
return {
"category": "FITTING",
# 재질 정보 (공통 모듈)
"material": {
"standard": material_result.get('standard', 'UNKNOWN'),
"grade": material_result.get('grade', 'UNKNOWN'),
"material_type": material_result.get('material_type', 'UNKNOWN'),
"confidence": material_result.get('confidence', 0.0)
},
# 피팅 특화 정보
"fitting_type": {
"type": fitting_type_result.get('type', 'UNKNOWN'),
"subtype": fitting_type_result.get('subtype', 'UNKNOWN'),
"confidence": fitting_type_result.get('confidence', 0.0),
"evidence": fitting_type_result.get('evidence', [])
},
"connection_method": {
"method": connection_result.get('method', 'UNKNOWN'),
"confidence": connection_result.get('confidence', 0.0),
"matched_code": connection_result.get('matched_code', ''),
"size_range": connection_result.get('size_range', ''),
"pressure_range": connection_result.get('pressure_range', '')
},
"pressure_rating": {
"rating": pressure_result.get('rating', 'UNKNOWN'),
"confidence": pressure_result.get('confidence', 0.0),
"max_pressure": pressure_result.get('max_pressure', ''),
"common_use": pressure_result.get('common_use', '')
},
"manufacturing": {
"method": manufacturing_result.get('method', 'UNKNOWN'),
"confidence": manufacturing_result.get('confidence', 0.0),
"evidence": manufacturing_result.get('evidence', []),
"characteristics": manufacturing_result.get('characteristics', '')
},
"size_info": {
"main_size": main_nom,
"reduced_size": red_nom,
"size_description": format_fitting_size(main_nom, red_nom),
"requires_two_sizes": fitting_type_result.get('requires_two_sizes', False)
},
"schedule_info": {
"schedule": schedule_result.get('schedule', 'UNKNOWN'),
"schedule_number": schedule_result.get('schedule_number', ''),
"wall_thickness": schedule_result.get('wall_thickness', ''),
"pressure_class": schedule_result.get('pressure_class', ''),
"confidence": schedule_result.get('confidence', 0.0)
},
# 전체 신뢰도
"overall_confidence": calculate_fitting_confidence({
"material": material_result.get('confidence', 0),
"fitting_type": fitting_type_result.get('confidence', 0),
"connection": connection_result.get('confidence', 0),
"pressure": pressure_result.get('confidence', 0)
}),
# 통합분류기 호환성을 위한 confidence 필드
"confidence": calculate_fitting_confidence({
"material": material_result.get('confidence', 0),
"fitting_type": fitting_type_result.get('confidence', 0),
"connection": connection_result.get('confidence', 0),
"pressure": pressure_result.get('confidence', 0)
})
}
"fitting_type": fitting_type,
def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict:
"""

View File

@@ -13,10 +13,18 @@ LEVEL1_TYPE_KEYWORDS = {
"VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "", "글로브", "체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너"],
"FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"],
"PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"],
"FITTING": ["SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET", "SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET", "OLET", "ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG", "엘보", "", "리듀서", "", "니플", "커플링", "플러그", "CONC", "ECC"],
"FITTING": [
"SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET",
"SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET", "OLET",
"ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG",
"엘보", "", "리듀서", "", "니플", "커플링", "플러그", "CONC", "ECC",
"SWAGELOK", "UNION", "CONNECTOR", "FERRULE", "NUT & FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"
],
"GASKET": ["GASKET", "GASK", "가스켓", "SWG", "SPIRAL"],
"INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"],
"SUPPORT": ["URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링"]
"SUPPORT": ["URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링"],
"PLATE": ["PLATE", "PL", "CHECKER PLATE", "판재", "철판"],
"STRUCTURAL": ["H-BEAM", "BEAM", "ANGLE", "CHANNEL", "H-SECTION", "I-BEAM", "형강", "앵글", "채널"]
}
# Level 2: 서브타입 키워드 (구체화)
@@ -171,6 +179,37 @@ def classify_material_integrated(description: str, main_nom: str = "",
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
sorted_keywords = sorted(keywords, key=len, reverse=True)
for keyword in sorted_keywords:
# [강화된 로직] 짧은 키워드나 중의적 키워드에 대한 엄격한 검사
is_strict_match = True
# 1. "PL" 키워드 검사 (PLATE)
if keyword == "PL":
# 단독 단어이거나 숫자 뒤에 붙는 경우만 허용 (예: 10PL, 10 PL)
# COUPLING, NIPPLE, PLUG 등에 포함된 PL은 제외
pl_pattern = r'(\b|\d)PL\b'
if not re.search(pl_pattern, desc_upper):
is_strict_match = False
# 2. "ANGLE" 키워드 검사 (STRUCTURAL)
elif keyword == "ANGLE" or keyword == "앵글":
# VALVE와 함께 쓰이면 제외 (ANGLE VALVE)
if "VALVE" in desc_upper or "밸브" in desc_upper:
is_strict_match = False
# 3. "UNION" 키워드 검사 (FITTING)
elif keyword == "UNION":
# 계장용인지 파이프용인지 구분은 fitting_classifier에서 하되,
# 여기서는 일단 FITTING으로 잡히도록 둠.
pass
# 4. "BEAM" 키워드 검사 (STRUCTURAL)
elif keyword == "BEAM":
# "BEAM CLAMP" 같은 경우 SUPPORT로 가야 함 (SUPPORT가 우선순위 높으므로 괜찮음)
pass
if not is_strict_match:
continue
# 전체 문자열에서 찾기
if keyword in desc_upper:
detected_types.append((material_type, keyword))

View File

@@ -0,0 +1,592 @@
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Dict, Optional
import json
from datetime import datetime
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
from app.services.bolt_classifier import classify_bolt
from app.services.flange_classifier import classify_flange
from app.services.fitting_classifier import classify_fitting
from app.services.gasket_classifier import classify_gasket
from app.services.instrument_classifier import classify_instrument
from app.services.valve_classifier import classify_valve
from app.services.support_classifier import classify_support
from app.services.plate_classifier import classify_plate
from app.services.structural_classifier import classify_structural
from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info
from app.services.material_grade_extractor import extract_full_material_grade
class MaterialService:
"""자재 처리 및 저장을 담당하는 서비스"""
@staticmethod
def process_and_save_materials(
db: Session,
file_id: int,
materials_data: List[Dict],
revision_comparison: Optional[Dict] = None,
parent_file_id: Optional[int] = None,
purchased_materials_map: Optional[Dict] = None
) -> int:
"""
자재 목록을 분류하고 DB에 저장합니다.
Args:
db: DB 세션
file_id: 파일 ID
materials_data: 파싱된 자재 데이터 목록
revision_comparison: 리비전 비교 결과
parent_file_id: 이전 리비전 파일 ID
purchased_materials_map: 구매 확정된 자재 매핑 정보
Returns:
저장된 자재 수
"""
materials_inserted = 0
# 변경/신규 자재 키 집합 (리비전 추적용)
changed_materials_keys = set()
new_materials_keys = set()
# 리비전 업로드인 경우 변경사항 분석
if parent_file_id is not None:
MaterialService._analyze_changes(
db, parent_file_id, materials_data,
changed_materials_keys, new_materials_keys
)
# 변경 없는 자재 (확정된 자재) 먼저 처리
if revision_comparison and revision_comparison.get("has_previous_confirmation", False):
unchanged_materials = revision_comparison.get("unchanged_materials", [])
for material_data in unchanged_materials:
MaterialService._save_unchanged_material(db, file_id, material_data)
materials_inserted += 1
# 분류가 필요한 자재 처리 (신규 또는 변경된 자재)
# revision_comparison에서 필터링된 목록이 있으면 그것을 사용, 아니면 전체
materials_to_classify = materials_data
if revision_comparison and revision_comparison.get("materials_to_classify"):
materials_to_classify = revision_comparison.get("materials_to_classify")
print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}")
for material_data in materials_to_classify:
MaterialService._classify_and_save_single_material(
db, file_id, material_data,
changed_materials_keys, new_materials_keys,
purchased_materials_map
)
materials_inserted += 1
return materials_inserted
@staticmethod
def _analyze_changes(db: Session, parent_file_id: int, materials_data: List[Dict],
changed_keys: set, new_keys: set):
"""이전 리비전과 비교하여 변경/신규 자재를 식별합니다."""
try:
prev_materials_query = text("""
SELECT original_description, size_spec, material_grade, main_nom,
drawing_name, line_no, quantity
FROM materials
WHERE file_id = :parent_file_id
""")
prev_materials = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
prev_dict = {}
for pm in prev_materials:
key = MaterialService._generate_material_key(
pm.drawing_name, pm.line_no, pm.original_description,
pm.size_spec, pm.material_grade
)
prev_dict[key] = float(pm.quantity) if pm.quantity else 0
for mat in materials_data:
new_key = MaterialService._generate_material_key(
mat.get("dwg_name"), mat.get("line_num"), mat["original_description"],
mat.get("size_spec"), mat.get("material_grade")
)
if new_key in prev_dict:
if abs(prev_dict[new_key] - float(mat.get("quantity", 0))) > 0.001:
changed_keys.add(new_key)
else:
new_keys.add(new_key)
except Exception as e:
print(f"❌ 변경사항 분석 실패: {e}")
@staticmethod
def _generate_material_key(dwg, line, desc, size, grade):
"""자재 고유 키 생성"""
parts = []
if dwg: parts.append(str(dwg))
elif line: parts.append(str(line))
parts.append(str(desc))
parts.append(str(size or ''))
parts.append(str(grade or ''))
return "|".join(parts)
@staticmethod
def _save_unchanged_material(db: Session, file_id: int, material_data: Dict):
"""변경 없는(확정된) 자재 저장"""
previous_item = material_data.get("previous_item", {})
query = text("""
INSERT INTO materials (
file_id, original_description, classified_category, confidence,
quantity, unit, size_spec, material_grade, specification,
reused_from_confirmation, created_at
) VALUES (
:file_id, :desc, :category, 1.0,
:qty, :unit, :size, :grade, :spec,
TRUE, :created_at
)
""")
db.execute(query, {
"file_id": file_id,
"desc": material_data["original_description"],
"category": previous_item.get("category", "UNCLASSIFIED"),
"qty": material_data["quantity"],
"unit": material_data.get("unit", "EA"),
"size": material_data.get("size_spec", ""),
"grade": previous_item.get("material", ""),
"spec": previous_item.get("specification", ""),
"created_at": datetime.now()
})
@staticmethod
def _classify_and_save_single_material(
db: Session, file_id: int, material_data: Dict,
changed_keys: set, new_keys: set, purchased_map: Optional[Dict]
):
"""단일 자재 분류 및 저장 (상세 정보 포함)"""
description = material_data["original_description"]
main_nom = material_data.get("main_nom", "")
red_nom = material_data.get("red_nom", "")
length_val = material_data.get("length")
# 1. 통합 분류
integrated_result = classify_material_integrated(description, main_nom, red_nom, length_val)
classification_result = integrated_result
# 2. 상세 분류
if not should_exclude_material(description):
category = integrated_result.get('category')
if category == "PIPE":
classification_result = classify_pipe_for_purchase("", description, main_nom, length_val)
elif category == "FITTING":
classification_result = classify_fitting("", description, main_nom, red_nom)
elif category == "FLANGE":
classification_result = classify_flange("", description, main_nom, red_nom)
elif category == "VALVE":
classification_result = classify_valve("", description, main_nom)
elif category == "BOLT":
classification_result = classify_bolt("", description, main_nom)
elif category == "GASKET":
classification_result = classify_gasket("", description, main_nom)
elif category == "INSTRUMENT":
classification_result = classify_instrument("", description, main_nom)
elif category == "SUPPORT":
classification_result = classify_support("", description, main_nom)
elif category == "PLATE":
classification_result = classify_plate("", description, main_nom)
elif category == "STRUCTURAL":
classification_result = classify_structural("", description, main_nom)
# 신뢰도 조정
if integrated_result.get('confidence', 0) < 0.5:
classification_result['overall_confidence'] = min(
classification_result.get('overall_confidence', 1.0),
integrated_result.get('confidence', 0.0) + 0.2
)
else:
classification_result = {"category": "EXCLUDE", "overall_confidence": 0.95}
# 3. 구매 확정 정보 상속 확인
is_purchase_confirmed = False
purchase_confirmed_at = None
purchase_confirmed_by = None
if purchased_map:
key = f"{description.strip().upper()}|{material_data.get('size_spec', '')}"
if key in purchased_map:
info = purchased_map[key]
is_purchase_confirmed = True
purchase_confirmed_at = info.get("purchase_confirmed_at")
purchase_confirmed_by = info.get("purchase_confirmed_by")
# 4. 자재 기본 정보 저장
full_grade = extract_full_material_grade(description) or material_data.get("material_grade", "")
insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
classified_category, classification_confidence, is_verified,
drawing_name, line_no, created_at,
purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by,
revision_status
) VALUES (
:file_id, :desc, :qty, :unit, :size,
:main, :red, :grade, :full_grade, :line_num, :row_num,
:category, :confidence, :verified,
:dwg, :line, :created_at,
:confirmed, :confirmed_at, :confirmed_by,
:status
) RETURNING id
""")
# 리비전 상태 결정
mat_key = MaterialService._generate_material_key(
material_data.get("dwg_name"), material_data.get("line_num"), description,
material_data.get("size_spec"), material_data.get("material_grade")
)
rev_status = 'changed' if mat_key in changed_keys else ('active' if mat_key in new_keys else None)
result = db.execute(insert_query, {
"file_id": file_id,
"desc": description,
"qty": material_data["quantity"],
"unit": material_data["unit"],
"size": material_data.get("size_spec", ""),
"main": main_nom,
"red": red_nom,
"grade": material_data.get("material_grade", ""),
"full_grade": full_grade,
"line_num": material_data.get("line_number"),
"row_num": material_data.get("row_number"),
"category": classification_result.get("category", "UNCLASSIFIED"),
"confidence": classification_result.get("overall_confidence", 0.0),
"verified": False,
"dwg": material_data.get("dwg_name"),
"line": material_data.get("line_num"),
"created_at": datetime.now(),
"confirmed": is_purchase_confirmed,
"confirmed_at": purchase_confirmed_at,
"confirmed_by": purchase_confirmed_by,
"status": rev_status
})
material_id = result.fetchone()[0]
# 5. 상세 정보 저장 (별도 메서드로 분리)
MaterialService._save_material_details(
db, material_id, file_id, classification_result, material_data
)
@staticmethod
def _save_material_details(db: Session, material_id: int, file_id: int,
result: Dict, data: Dict):
"""카테고리별 상세 정보 저장"""
category = result.get("category")
if category == "PIPE":
MaterialService._save_pipe_details(db, material_id, file_id, result, data)
elif category == "FITTING":
MaterialService._save_fitting_details(db, material_id, file_id, result, data)
elif category == "FLANGE":
MaterialService._save_flange_details(db, material_id, file_id, result, data)
elif category == "BOLT":
MaterialService._save_bolt_details(db, material_id, file_id, result, data)
elif category == "VALVE":
MaterialService._save_valve_details(db, material_id, file_id, result, data)
elif category == "GASKET":
MaterialService._save_gasket_details(db, material_id, file_id, result, data)
elif category == "SUPPORT":
MaterialService._save_support_details(db, material_id, file_id, result, data)
elif category == "PLATE":
MaterialService._save_plate_details(db, material_id, file_id, result, data)
elif category == "STRUCTURAL":
MaterialService._save_structural_details(db, material_id, file_id, result, data)
@staticmethod
def _save_plate_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
"""판재 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
details = res.get("details", {})
spec = f"{details.get('thickness')}T x {details.get('dimensions')}"
db.execute(text("""
UPDATE materials
SET size_spec = :size, material_grade = :mat
WHERE id = :id
"""), {"size": spec, "mat": details.get("material"), "id": mid})
@staticmethod
def _save_structural_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
"""형강 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
details = res.get("details", {})
spec = f"{details.get('type')} {details.get('dimension')}"
db.execute(text("""
UPDATE materials
SET size_spec = :size
WHERE id = :id
"""), {"size": spec, "id": mid})
# --- 각 카테고리별 상세 저장 메서드들 (기존 로직 이관) ---
@staticmethod
def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int):
"""이전 리비전의 구매신청 정보를 상속합니다."""
try:
print(f"🔄 구매신청 정보 상속 처리 시작...")
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
prev_purchase_summary = text("""
SELECT
m.original_description,
m.size_spec,
m.material_grade,
m.drawing_name,
COUNT(DISTINCT pri.material_id) as purchased_count,
SUM(pri.quantity) as total_purchased_qty,
MIN(pri.request_id) as request_id
FROM materials m
JOIN purchase_request_items pri ON m.id = pri.material_id
WHERE m.file_id = :parent_file_id
GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name
""")
prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall()
# 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속
for prev_purchase in prev_purchases:
purchased_count = prev_purchase.purchased_count
# 새 리비전에서 같은 그룹의 자재 조회 (순서대로)
new_group_materials = text("""
SELECT id, quantity
FROM materials
WHERE file_id = :file_id
AND original_description = :description
AND COALESCE(size_spec, '') = :size_spec
AND COALESCE(material_grade, '') = :material_grade
AND COALESCE(drawing_name, '') = :drawing_name
ORDER BY id
LIMIT :limit
""")
new_materials = db.execute(new_group_materials, {
"file_id": current_file_id,
"description": prev_purchase.original_description,
"size_spec": prev_purchase.size_spec or '',
"material_grade": prev_purchase.material_grade or '',
"drawing_name": prev_purchase.drawing_name or '',
"limit": purchased_count
}).fetchall()
# 구매신청 수량만큼만 상속
for new_mat in new_materials:
inherit_query = text("""
INSERT INTO purchase_request_items (
request_id, material_id, quantity, unit, user_requirement
) VALUES (
:request_id, :material_id, :quantity, 'EA', ''
)
ON CONFLICT DO NOTHING
""")
db.execute(inherit_query, {
"request_id": prev_purchase.request_id,
"material_id": new_mat.id,
"quantity": new_mat.quantity
})
inherited_count = len(new_materials)
if inherited_count > 0:
print(f"{prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속")
# 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리
# db.commit()
print(f"✅ 구매신청 정보 상속 완료")
except Exception as e:
print(f"❌ 구매신청 정보 상속 실패: {str(e)}")
# 상속 실패는 전체 프로세스를 중단하지 않음
@staticmethod
def _save_pipe_details(db, mid, fid, res, data):
# PIPE 상세 저장 로직
end_prep_info = extract_end_preparation_info(data["original_description"])
# 1. End Prep 정보 저장
db.execute(text("""
INSERT INTO pipe_end_preparations (
material_id, file_id, end_preparation_type, end_preparation_code,
machining_required, cutting_note, original_description, confidence
) VALUES (
:mid, :fid, :type, :code, :req, :note, :desc, :conf
)
"""), {
"mid": mid, "fid": fid,
"type": end_prep_info["end_preparation_type"],
"code": end_prep_info["end_preparation_code"],
"req": end_prep_info["machining_required"],
"note": end_prep_info["cutting_note"],
"desc": end_prep_info["original_description"],
"conf": end_prep_info["confidence"]
})
# 2. Pipe Details 저장
length_info = res.get("length_info", {})
length_mm = length_info.get("length_mm") or data.get("length", 0.0)
mat_info = res.get("material", {})
sch_info = res.get("schedule", {})
# 재질 정보 업데이트
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO pipe_details (
material_id, file_id, outer_diameter, schedule,
material_spec, manufacturing_method, length_mm
) VALUES (:mid, :fid, :od, :sch, :spec, :method, :len)
"""), {
"mid": mid, "fid": fid,
"od": data.get("main_nom") or data.get("size_spec"),
"sch": sch_info.get("schedule", "UNKNOWN") if isinstance(sch_info, dict) else str(sch_info),
"spec": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"method": res.get("manufacturing", {}).get("method", "UNKNOWN") if isinstance(res.get("manufacturing"), dict) else "UNKNOWN",
"len": length_mm or 0.0
})
@staticmethod
def _save_fitting_details(db, mid, fid, res, data):
fit_type = res.get("fitting_type", {})
mat_info = res.get("material", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO fitting_details (
material_id, file_id, fitting_type, fitting_subtype,
connection_method, pressure_rating, material_grade,
main_size, reduced_size
) VALUES (:mid, :fid, :type, :subtype, :conn, :rating, :grade, :main, :red)
"""), {
"mid": mid, "fid": fid,
"type": fit_type.get("type", "UNKNOWN") if isinstance(fit_type, dict) else str(fit_type),
"subtype": fit_type.get("subtype", "UNKNOWN") if isinstance(fit_type, dict) else "UNKNOWN",
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"main": data.get("main_nom") or data.get("size_spec"),
"red": data.get("red_nom", "")
})
@staticmethod
def _save_flange_details(db, mid, fid, res, data):
flg_type = res.get("flange_type", {})
mat_info = res.get("material", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO flange_details (
material_id, file_id, flange_type, pressure_rating,
facing_type, material_grade, size_inches
) VALUES (:mid, :fid, :type, :rating, :face, :grade, :size)
"""), {
"mid": mid, "fid": fid,
"type": flg_type.get("type", "UNKNOWN") if isinstance(flg_type, dict) else str(flg_type),
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"face": res.get("face_finish", {}).get("finish", "UNKNOWN") if isinstance(res.get("face_finish"), dict) else "UNKNOWN",
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"size": data.get("main_nom") or data.get("size_spec")
})
@staticmethod
def _save_bolt_details(db, mid, fid, res, data):
fast_type = res.get("fastener_type", {})
mat_info = res.get("material", {})
dim_info = res.get("dimensions", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
# 볼트 타입 결정 (특수 용도 고려)
bolt_type = fast_type.get("type", "UNKNOWN") if isinstance(fast_type, dict) else str(fast_type)
special_apps = res.get("special_applications", {}).get("detected_applications", [])
if "LT" in special_apps: bolt_type = "LT_BOLT"
elif "PSV" in special_apps: bolt_type = "PSV_BOLT"
# 코팅 타입
desc_upper = data["original_description"].upper()
coating = "UNKNOWN"
if "GALV" in desc_upper: coating = "GALVANIZED"
elif "ZINC" in desc_upper: coating = "ZINC_PLATED"
db.execute(text("""
INSERT INTO bolt_details (
material_id, file_id, bolt_type, thread_type,
diameter, length, material_grade, coating_type
) VALUES (:mid, :fid, :type, :thread, :dia, :len, :grade, :coating)
"""), {
"mid": mid, "fid": fid,
"type": bolt_type,
"thread": res.get("thread_specification", {}).get("standard", "UNKNOWN") if isinstance(res.get("thread_specification"), dict) else "UNKNOWN",
"dia": dim_info.get("nominal_size", data.get("main_nom", "")),
"len": dim_info.get("length", ""),
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"coating": coating
})
@staticmethod
def _save_valve_details(db, mid, fid, res, data):
val_type = res.get("valve_type", {})
mat_info = res.get("material", {})
if mat_info.get("grade"):
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
{"g": mat_info.get("grade"), "id": mid})
db.execute(text("""
INSERT INTO valve_details (
material_id, file_id, valve_type, connection_method,
pressure_rating, body_material, size_inches
) VALUES (:mid, :fid, :type, :conn, :rating, :body, :size)
"""), {
"mid": mid, "fid": fid,
"type": val_type.get("type", "UNKNOWN") if isinstance(val_type, dict) else str(val_type),
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"body": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
"size": data.get("main_nom") or data.get("size_spec")
})
@staticmethod
def _save_gasket_details(db, mid, fid, res, data):
gask_type = res.get("gasket_type", {})
db.execute(text("""
INSERT INTO gasket_details (
material_id, file_id, gasket_type, pressure_rating, size_inches
) VALUES (:mid, :fid, :type, :rating, :size)
"""), {
"mid": mid, "fid": fid,
"type": gask_type.get("type", "UNKNOWN") if isinstance(gask_type, dict) else str(gask_type),
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
"size": data.get("main_nom") or data.get("size_spec")
})
@staticmethod
def _save_support_details(db, mid, fid, res, data):
db.execute(text("""
INSERT INTO support_details (
material_id, file_id, support_type, pipe_size
) VALUES (:mid, :fid, :type, :size)
"""), {
"mid": mid, "fid": fid,
"type": res.get("support_type", "UNKNOWN"),
"size": res.get("size_info", {}).get("pipe_size", "")
})

View File

@@ -0,0 +1,50 @@
import re
from typing import Dict, Optional
def classify_plate(tag: str, description: str, main_nom: str = "") -> Dict:
"""
판재(PLATE) 분류기
규격 예: PLATE 10T x 1219 x 2438
"""
desc_upper = description.upper()
# 1. 두께(Thickness) 추출
# 패턴: 10T, 10.5T, THK 10, THK. 10, t=10
thickness = None
t_match = re.search(r'(\d+(?:\.\d+)?)\s*T\b', desc_upper)
if not t_match:
t_match = re.search(r'(?:THK\.?|t=)\s*(\d+(?:\.\d+)?)', desc_upper, re.IGNORECASE)
if t_match:
thickness = t_match.group(1)
# 2. 규격(Dimensions) 추출
# 패턴: 1219x2438, 4'x8', 1000*2000
dimensions = ""
dim_match = re.search(r'(\d+(?:\.\d+)?)\s*[X\*]\s*(\d+(?:\.\d+)?)(?:\s*[X\*]\s*(\d+(?:\.\d+)?))?', desc_upper)
if dim_match:
groups = [g for g in dim_match.groups() if g]
dimensions = " x ".join(groups)
# 3. 재질 추출
material = "UNKNOWN"
# 압력용기용 및 일반 구조용 강판 재질 추가
plate_materials = [
"SUS304", "SUS316", "SUS321", "SS400", "A36", "SM490",
"SA516", "A516", "SA283", "A283", "SA537", "A537", "POS-M"
]
for mat in plate_materials:
if mat in desc_upper:
material = mat
break
return {
"category": "PLATE",
"overall_confidence": 0.9,
"details": {
"thickness": thickness,
"dimensions": dimensions,
"material": material
}
}

View File

@@ -0,0 +1,34 @@
import re
from typing import Dict
def classify_structural(tag: str, description: str, main_nom: str = "") -> Dict:
"""
형강(STRUCTURAL) 분류기
규격 예: H-BEAM 100x100x6x8
"""
desc_upper = description.upper()
# 1. 타입 식별
struct_type = "UNKNOWN"
if "H-BEAM" in desc_upper or "H-SECTION" in desc_upper: struct_type = "H-BEAM"
elif "ANGLE" in desc_upper or "L-SECTION" in desc_upper: struct_type = "ANGLE"
elif "CHANNEL" in desc_upper or "C-SECTION" in desc_upper: struct_type = "CHANNEL"
elif "BEAM" in desc_upper: struct_type = "I-BEAM"
# 2. 규격 추출
# 패턴: 100x100, 100x100x6x8, 50x50x5T, H-200x200
dimension = ""
# 숫자와 x 또는 *로 구성된 패턴, 앞에 H- 등이 붙을 수 있음
dim_match = re.search(r'(?:H-|L-|C-)?(\d+(?:\.\d+)?(?:\s*[X\*]\s*\d+(?:\.\d+)?){1,3})', desc_upper)
if dim_match:
dimension = dim_match.group(1).replace("*", "x")
return {
"category": "STRUCTURAL",
"overall_confidence": 0.9,
"details": {
"type": struct_type,
"dimension": dimension
}
}