feat: 자재 분류 시스템 대폭 개선

🔧 주요 개선사항:
- EXCLUDE 분류기 추가 (WELD GAP 등 제외 대상 처리)
- FITTING 분류기 키워드 확장 (ELL, RED 추가)
- PIPE 재질 중복 문제 해결 (material_grade 파싱 개선)
- NIPPLE 특별 처리 추가 (스케줄 + 길이 정보 포함)
- OLET 타입 중복 표시 제거

📊 분류 정확도:
- UNKNOWN: 0개 (100% 분류 성공)
- EXCLUDE: 1,014개 (제외 대상)
- 실제 자재: 1,823개 정확 분류

🎯 해결된 문제:
- PIPE 재질 'ASTM A106 ASTM A106' → 'ASTM A106 GR B'
- WELD GAP 오분류 → EXCLUDE 카테고리
- FITTING 키워드 인식 실패 → ELL, RED 키워드 추가
- 프론트엔드 중복 표시 제거
This commit is contained in:
Hyungi Ahn
2025-07-18 10:28:02 +09:00
parent 82f057a0c9
commit 25ce3590ee
11 changed files with 857 additions and 1923 deletions

View File

@@ -374,15 +374,13 @@ async def upload_file(
pipe_insert_query = text("""
INSERT INTO pipe_details (
material_id, file_id, size_inches, schedule_type, material_spec,
manufacturing_method, length_mm, outer_diameter_mm, wall_thickness_mm,
weight_per_meter_kg, classification_confidence, additional_info
material_id, file_id, outer_diameter, schedule,
material_spec, manufacturing_method, length_mm
)
VALUES (
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
:file_id, :size_inches, :schedule_type, :material_spec,
:manufacturing_method, :length_mm, :outer_diameter_mm, :wall_thickness_mm,
:weight_per_meter_kg, :classification_confidence, :additional_info
:file_id, :outer_diameter, :schedule,
:material_spec, :manufacturing_method, :length_mm
)
""")
@@ -390,16 +388,12 @@ async def upload_file(
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"size_inches": pipe_info.get('nominal_diameter', ''),
"schedule_type": pipe_info.get('schedule', ''),
"outer_diameter": pipe_info.get('nominal_diameter', ''),
"schedule": pipe_info.get('schedule', ''),
"material_spec": pipe_info.get('material_spec', ''),
"manufacturing_method": pipe_info.get('manufacturing_method', ''),
"length_mm": length_mm,
"outer_diameter_mm": pipe_info.get('outer_diameter_mm'),
"wall_thickness_mm": pipe_info.get('wall_thickness_mm'),
"weight_per_meter_kg": pipe_info.get('weight_per_meter_kg'),
"classification_confidence": classification_result.get('overall_confidence', 0.0),
"additional_info": json.dumps(pipe_info, ensure_ascii=False)
})
print(f"PIPE 상세정보 저장 완료: {material_data['original_description']}")
@@ -713,7 +707,7 @@ async def get_materials(
try:
query = """
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
m.size_spec, m.material_grade, m.line_number, m.row_number,
m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.line_number, m.row_number,
m.classified_category, m.classification_confidence, m.classification_details,
m.created_at,
f.original_filename, f.project_id, f.job_no, f.revision,

View File

@@ -236,656 +236,9 @@ async def root():
# print(f"Jobs 조회 에러: {str(e)}")
# return {"error": f"Jobs 조회 실패: {str(e)}"}
# 파일 업로드 API
@app.post("/upload")
async def upload_file(
file: UploadFile = File(...),
job_no: str = Form(...), # project_id 대신 job_no 사용
bom_name: str = Form(""), # BOM 이름 추가
bom_type: str = Form(""),
revision: str = Form("Rev.0"),
parent_bom_id: Optional[int] = Form(None),
description: str = Form(""),
db: Session = Depends(get_db)
):
"""파일 업로드 및 자재 분류 (자동 리비전 관리)"""
try:
print("=== main.py 업로드 API 호출됨 ===")
print(f"파일명: {file.filename}")
print(f"job_no: {job_no}")
print(f"bom_name: {bom_name}")
print(f"bom_type: {bom_type}")
# job_no로 job 확인
job_query = text("SELECT job_no FROM jobs WHERE job_no = :job_no AND is_active = true")
job_result = db.execute(job_query, {"job_no": job_no})
job = job_result.fetchone()
if not job:
return {"error": f"Job No. '{job_no}'에 해당하는 작업을 찾을 수 없습니다."}
# 업로드 디렉토리 생성
upload_dir = "uploads"
os.makedirs(upload_dir, exist_ok=True)
# 파일 저장
if file.filename:
file_path = os.path.join(upload_dir, file.filename)
print(f"파일 저장 경로: {file_path}")
print(f"원본 파일명: {file.filename}")
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
print(f"파일 저장 완료: {file_path}")
else:
return {"error": "파일명이 없습니다."}
# 파일 크기 계산
file_size = os.path.getsize(file_path)
# 파일 타입 결정
file_type = "excel" if file.filename.endswith(('.xls', '.xlsx')) else "csv" if file.filename.endswith('.csv') else "unknown"
# BOM 종류별 자동 리비전 관리
if bom_name and not parent_bom_id:
# 같은 job_no의 같은 BOM 이름에 대한 최신 리비전 조회
latest_revision_query = text("""
SELECT revision FROM files
WHERE job_no = :job_no AND bom_name = :bom_name
ORDER BY revision DESC LIMIT 1
""")
result = db.execute(latest_revision_query, {
"job_no": job_no,
"bom_name": bom_name
})
latest_file = result.fetchone()
if latest_file:
# 기존 리비전이 있으면 다음 리비전 번호 생성
current_rev = latest_file.revision
if current_rev.startswith("Rev."):
try:
rev_num = int(current_rev.replace("Rev.", ""))
revision = f"Rev.{rev_num + 1}"
except ValueError:
revision = "Rev.1"
else:
revision = "Rev.1"
else:
# 첫 번째 업로드인 경우 Rev.0
revision = "Rev.0"
# 데이터베이스에 파일 정보 저장
insert_query = text("""
INSERT INTO files (
job_no, filename, original_filename, file_path,
file_size, upload_date, revision, file_type, uploaded_by, bom_name
) VALUES (
:job_no, :filename, :original_filename, :file_path,
:file_size, NOW(), :revision, :file_type, :uploaded_by, :bom_name
) RETURNING id
""")
result = db.execute(insert_query, {
"job_no": job_no,
"filename": file.filename,
"original_filename": file.filename,
"file_path": file_path,
"file_size": file_size,
"revision": revision,
"file_type": file_type,
"uploaded_by": "system",
"bom_name": bom_name
})
file_id = result.fetchone()[0]
# 1차: 파일 파싱 (CSV/Excel 파일 읽기)
materials_data = parse_file(file_path)
# 2차: 각 자재를 분류기로 분류
classified_materials = []
# 리비전 업로드인 경우 기존 분류 정보 가져오기
existing_classifications = {}
if parent_bom_id:
parent_materials = db.execute(
text("SELECT original_description, classified_category, classified_subcategory, material_grade, schedule, size_spec FROM materials WHERE file_id = :file_id"),
{"file_id": parent_bom_id}
).fetchall()
for material in parent_materials:
existing_classifications[material.original_description] = {
"classified_category": material.classified_category,
"classified_subcategory": material.classified_subcategory,
"material_grade": material.material_grade,
"schedule": material.schedule,
"size_spec": material.size_spec
}
for material in materials_data:
# 리비전 업로드인 경우 기존 분류 사용, 아니면 새로 분류
if parent_bom_id and material.get("original_description") in existing_classifications:
existing_class = existing_classifications[material.get("original_description")]
classified_material = {
**material,
**existing_class,
"classification_confidence": 1.0 # 기존 분류이므로 높은 신뢰도
}
else:
classified_material = classify_material_item(material)
classified_materials.append(classified_material)
# 3차: 분류된 자재를 데이터베이스에 저장
for material in classified_materials:
insert_material_query = text("""
INSERT INTO materials (
file_id, line_number, original_description,
classified_category, classified_subcategory,
material_grade, schedule, size_spec,
quantity, unit, drawing_name, area_code, line_no,
classification_confidence, is_verified, created_at
) VALUES (
:file_id, :line_number, :original_description,
:classified_category, :classified_subcategory,
:material_grade, :schedule, :size_spec,
:quantity, :unit, :drawing_name, :area_code, :line_no,
:classification_confidence, :is_verified, NOW()
)
RETURNING id
""")
result = db.execute(insert_material_query, {
"file_id": file_id,
"line_number": material.get("line_number", 0),
"original_description": material.get("original_description", ""),
"classified_category": material.get("classified_category", ""),
"classified_subcategory": material.get("classified_subcategory", ""),
"material_grade": material.get("material_grade", ""),
"schedule": material.get("schedule", ""),
"size_spec": material.get("size_spec", ""),
"quantity": material.get("quantity", 0),
"unit": material.get("unit", ""),
"drawing_name": material.get("drawing_name", ""),
"area_code": material.get("area_code", ""),
"line_no": material.get("line_no", ""),
"classification_confidence": material.get("classification_confidence", 0.0),
"is_verified": False
})
# 저장된 material의 ID 가져오기
material_id = result.fetchone()[0]
# 카테고리별 상세 정보 저장
category = material.get("classified_category", "")
if category == "PIPE" and "pipe_details" in material:
pipe_details = material["pipe_details"]
pipe_insert_query = text("""
INSERT INTO pipe_details (
material_id, file_id, nominal_size, schedule,
material_standard, material_grade, material_type,
manufacturing_method, length_mm
) VALUES (
:material_id, :file_id, :nominal_size, :schedule,
:material_standard, :material_grade, :material_type,
:manufacturing_method, :length_mm
)
""")
db.execute(pipe_insert_query, {
"material_id": material_id,
"file_id": file_id,
"nominal_size": material.get("size_spec", ""),
"schedule": pipe_details.get("schedule", material.get("schedule", "")),
"material_standard": pipe_details.get("material_spec", material.get("material_grade", "")),
"material_grade": material.get("material_grade", ""),
"material_type": material.get("material_grade", "").split("-")[0] if material.get("material_grade", "") else "",
"manufacturing_method": pipe_details.get("manufacturing_method", ""),
"length_mm": material.get("length", 0.0) if material.get("length", 0.0) else 0.0 # 이미 mm 단위임
})
elif category == "FITTING" and "fitting_details" in material:
fitting_details = material["fitting_details"]
fitting_insert_query = text("""
INSERT INTO fitting_details (
material_id, file_id, fitting_type, fitting_subtype,
connection_method, pressure_rating, material_standard,
material_grade, main_size, reduced_size
) VALUES (
:material_id, :file_id, :fitting_type, :fitting_subtype,
:connection_method, :pressure_rating, :material_standard,
:material_grade, :main_size, :reduced_size
)
""")
db.execute(fitting_insert_query, {
"material_id": material_id,
"file_id": file_id,
"fitting_type": fitting_details.get("fitting_type", ""),
"fitting_subtype": fitting_details.get("fitting_subtype", ""),
"connection_method": fitting_details.get("connection_method", ""),
"pressure_rating": fitting_details.get("pressure_rating", ""),
"material_standard": fitting_details.get("material_standard", material.get("material_grade", "")),
"material_grade": fitting_details.get("material_grade", material.get("material_grade", "")),
"main_size": material.get("size_spec", ""),
"reduced_size": fitting_details.get("reduced_size", "")
})
elif category == "VALVE" and "valve_details" in material:
valve_details = material["valve_details"]
valve_insert_query = text("""
INSERT INTO valve_details (
material_id, file_id, valve_type, valve_subtype,
actuator_type, connection_method, pressure_rating,
body_material, size_inches
) VALUES (
:material_id, :file_id, :valve_type, :valve_subtype,
:actuator_type, :connection_method, :pressure_rating,
:body_material, :size_inches
)
""")
db.execute(valve_insert_query, {
"material_id": material_id,
"file_id": file_id,
"valve_type": valve_details.get("valve_type", ""),
"valve_subtype": valve_details.get("valve_subtype", ""),
"actuator_type": valve_details.get("actuator_type", "MANUAL"),
"connection_method": valve_details.get("connection_method", ""),
"pressure_rating": valve_details.get("pressure_rating", ""),
"body_material": material.get("material_grade", ""),
"size_inches": material.get("size_spec", "")
})
elif category == "FLANGE" and "flange_details" in material:
flange_details = material["flange_details"]
flange_insert_query = text("""
INSERT INTO flange_details (
material_id, file_id, flange_type, flange_subtype,
facing_type, pressure_rating, material_standard,
material_grade, size_inches
) VALUES (
:material_id, :file_id, :flange_type, :flange_subtype,
:facing_type, :pressure_rating, :material_standard,
:material_grade, :size_inches
)
""")
db.execute(flange_insert_query, {
"material_id": material_id,
"file_id": file_id,
"flange_type": flange_details.get("flange_type", ""),
"flange_subtype": flange_details.get("flange_subtype", ""),
"facing_type": flange_details.get("facing_type", ""),
"pressure_rating": flange_details.get("pressure_rating", ""),
"material_standard": material.get("material_grade", ""),
"material_grade": material.get("material_grade", ""),
"size_inches": material.get("size_spec", "")
})
elif category == "BOLT" and "bolt_details" in material:
bolt_details = material["bolt_details"]
bolt_insert_query = text("""
INSERT INTO bolt_details (
material_id, file_id, bolt_type, bolt_subtype,
thread_standard, diameter, length, thread_pitch,
material_standard, material_grade, coating
) VALUES (
:material_id, :file_id, :bolt_type, :bolt_subtype,
:thread_standard, :diameter, :length, :thread_pitch,
:material_standard, :material_grade, :coating
)
""")
db.execute(bolt_insert_query, {
"material_id": material_id,
"file_id": file_id,
"bolt_type": bolt_details.get("bolt_type", ""),
"bolt_subtype": bolt_details.get("bolt_subtype", ""),
"thread_standard": bolt_details.get("thread_standard", ""),
"diameter": material.get("size_spec", ""),
"length": bolt_details.get("length", ""),
"thread_pitch": bolt_details.get("thread_pitch", ""),
"material_standard": material.get("material_grade", ""),
"material_grade": material.get("material_grade", ""),
"coating": bolt_details.get("coating", "")
})
elif category == "GASKET" and "gasket_details" in material:
gasket_details = material["gasket_details"]
gasket_insert_query = text("""
INSERT INTO gasket_details (
material_id, file_id, gasket_type, gasket_material,
flange_size, pressure_rating, temperature_range,
thickness, inner_diameter, outer_diameter
) VALUES (
:material_id, :file_id, :gasket_type, :gasket_material,
:flange_size, :pressure_rating, :temperature_range,
:thickness, :inner_diameter, :outer_diameter
)
""")
db.execute(gasket_insert_query, {
"material_id": material_id,
"file_id": file_id,
"gasket_type": gasket_details.get("gasket_type", ""),
"gasket_material": gasket_details.get("gasket_material", ""),
"flange_size": material.get("size_spec", ""),
"pressure_rating": gasket_details.get("pressure_rating", ""),
"temperature_range": gasket_details.get("temperature_range", ""),
"thickness": gasket_details.get("thickness", ""),
"inner_diameter": gasket_details.get("inner_diameter", ""),
"outer_diameter": gasket_details.get("outer_diameter", "")
})
elif category == "INSTRUMENT" and "instrument_details" in material:
instrument_details = material["instrument_details"]
instrument_insert_query = text("""
INSERT INTO instrument_details (
material_id, file_id, instrument_type, measurement_type,
measurement_range, output_signal, connection_size,
process_connection, accuracy_class
) VALUES (
:material_id, :file_id, :instrument_type, :measurement_type,
:measurement_range, :output_signal, :connection_size,
:process_connection, :accuracy_class
)
""")
db.execute(instrument_insert_query, {
"material_id": material_id,
"file_id": file_id,
"instrument_type": instrument_details.get("instrument_type", ""),
"measurement_type": instrument_details.get("measurement_type", ""),
"measurement_range": instrument_details.get("measurement_range", ""),
"output_signal": instrument_details.get("output_signal", ""),
"connection_size": material.get("size_spec", ""),
"process_connection": instrument_details.get("process_connection", ""),
"accuracy_class": instrument_details.get("accuracy_class", "")
})
db.commit()
return {
"success": True,
"file_id": file_id,
"filename": file.filename,
"materials_count": len(classified_materials),
"revision": revision,
"message": f"파일이 성공적으로 업로드되고 {len(classified_materials)}개의 자재가 분류되었습니다. (리비전: {revision})"
}
except Exception as e:
db.rollback()
print(f"업로드 실패: {str(e)}")
# HTTP 400 에러로 변경
from fastapi import HTTPException
raise HTTPException(status_code=400, detail=f"파일 업로드 및 분류 실패: {str(e)}")
# 파일 업로드는 /files/upload 엔드포인트를 사용하세요 (routers/files.py)
def parse_file(file_path: str) -> List[Dict]:
"""파일 파싱 (CSV/Excel)"""
import pandas as pd
import os
try:
print(f"parse_file 호출됨: {file_path}")
print(f"파일 존재 여부: {os.path.exists(file_path)}")
print(f"파일 확장자: {os.path.splitext(file_path)[1]}")
# 파일 확장자를 소문자로 변환하여 검증
file_extension = os.path.splitext(file_path)[1].lower()
print(f"소문자 변환된 확장자: {file_extension}")
if file_extension == '.csv':
df = pd.read_csv(file_path)
elif file_extension in ['.xls', '.xlsx']:
df = pd.read_excel(file_path)
else:
print(f"지원되지 않는 파일 형식: {file_path}")
print(f"파일 확장자: {file_extension}")
raise ValueError("지원하지 않는 파일 형식입니다.")
print(f"파일 파싱 시작: {file_path}")
print(f"데이터프레임 형태: {df.shape}")
print(f"컬럼명: {list(df.columns)}")
# 컬럼명 매핑 (대소문자 구분 없이)
column_mapping = {
'description': ['DESCRIPTION', 'Description', 'description', 'DESC', 'Desc', 'desc', 'ITEM', 'Item', 'item'],
'quantity': ['QTY', 'Quantity', 'quantity', 'QTY.', 'Qty', 'qty', 'AMOUNT', 'Amount', 'amount'],
'length': ['LENGTH', 'Length', 'length', 'LG', 'Lg', 'lg', 'LENGTH_MM', 'Length_mm', 'length_mm'],
'unit': ['UNIT', 'Unit', 'unit', 'UOM', 'Uom', 'uom'],
'size': ['SIZE', 'Size', 'size', 'NOM_SIZE', 'Nom_Size', 'nom_size', 'MAIN_NOM', 'Main_Nom', 'main_nom'],
'drawing': ['DRAWING', 'Drawing', 'drawing', 'DWG', 'Dwg', 'dwg'],
'area': ['AREA', 'Area', 'area', 'AREA_CODE', 'Area_Code', 'area_code'],
'line': ['LINE', 'Line', 'line', 'LINE_NO', 'Line_No', 'line_no', 'PIPELINE', 'Pipeline', 'pipeline']
}
# 실제 컬럼명 찾기
found_columns = {}
for target_col, possible_names in column_mapping.items():
for col_name in possible_names:
if col_name in df.columns:
found_columns[target_col] = col_name
break
print(f"찾은 컬럼 매핑: {found_columns}")
materials = []
for index, row in df.iterrows():
# 빈 행 건너뛰기
if row.isna().all():
continue
# 안전한 값 추출
description = str(row.get(found_columns.get('description', ''), '') or '')
quantity_raw = row.get(found_columns.get('quantity', 1), 1)
quantity = float(quantity_raw) if quantity_raw is not None else 1.0
length_raw = row.get(found_columns.get('length', 0), 0)
length = float(length_raw) if length_raw is not None else 0.0
unit = str(row.get(found_columns.get('unit', 'EA'), 'EA') or 'EA')
size = str(row.get(found_columns.get('size', ''), '') or '')
drawing = str(row.get(found_columns.get('drawing', ''), '') or '')
area = str(row.get(found_columns.get('area', ''), '') or '')
line = str(row.get(found_columns.get('line', ''), '') or '')
material = {
"line_number": index + 1,
"original_description": description,
"quantity": quantity,
"length": length,
"unit": unit,
"size_spec": size,
"drawing_name": drawing,
"area_code": area,
"line_no": line
}
# 빈 설명은 건너뛰기
if not material["original_description"] or material["original_description"].strip() == '':
continue
materials.append(material)
print(f"파싱된 자재 수: {len(materials)}")
if materials:
print(f"첫 번째 자재 예시: {materials[0]}")
return materials
except Exception as e:
print(f"파일 파싱 오류: {str(e)}")
raise Exception(f"파일 파싱 실패: {str(e)}")
def classify_material_item(material: Dict) -> Dict:
"""개별 자재 분류"""
from .services import (
pipe_classifier, fitting_classifier, bolt_classifier,
valve_classifier, instrument_classifier, flange_classifier,
gasket_classifier, material_classifier
)
description = material.get("original_description", "")
size_spec = material.get("size_spec", "")
length = material.get("length", 0.0) # 길이 정보 추가
print(f"분류 시도: {description}")
# 각 분류기로 분류 시도 (개선된 순서와 기준)
desc_upper = description.upper()
# 1. 명확한 키워드 우선 확인 (높은 신뢰도)
if any(keyword in desc_upper for keyword in ['FLG', 'FLANGE', '플랜지', 'RF', 'WN', 'SO', 'BLIND']):
classification_result = flange_classifier.classify_flange("", description, size_spec, length)
print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
elif any(keyword in desc_upper for keyword in ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', '밸브', '게이트', '']):
classification_result = valve_classifier.classify_valve("", description, size_spec, length)
print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
elif any(keyword in desc_upper for keyword in ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'CAP', 'COUPLING', '엘보', '', '리듀서', '']):
classification_result = fitting_classifier.classify_fitting("", description, size_spec, length)
print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
elif any(keyword in desc_upper for keyword in ['BOLT', 'STUD', 'NUT', 'SCREW', '볼트', '너트', '스터드']):
classification_result = bolt_classifier.classify_bolt("", description, size_spec, length)
print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
elif any(keyword in desc_upper for keyword in ['GASKET', 'GASK', '가스켓']):
classification_result = gasket_classifier.classify_gasket("", description, size_spec, length)
print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
elif any(keyword in desc_upper for keyword in ['GAUGE', 'SENSOR', 'TRANSMITTER', 'INSTRUMENT', '계기', '게이지']):
classification_result = instrument_classifier.classify_instrument("", description, size_spec, length)
print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
elif any(keyword in desc_upper for keyword in ['PIPE', 'TUBE', '파이프', '배관']):
classification_result = pipe_classifier.classify_pipe("", description, size_spec, length)
print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
else:
# 2. 일반적인 분류 시도 (낮은 신뢰도 임계값)
classification_result = flange_classifier.classify_flange("", description, size_spec, length)
print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.3:
classification_result = valve_classifier.classify_valve("", description, size_spec, length)
print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.3:
classification_result = fitting_classifier.classify_fitting("", description, size_spec, length)
print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.3:
classification_result = pipe_classifier.classify_pipe("", description, size_spec, length)
print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.3:
classification_result = bolt_classifier.classify_bolt("", description, size_spec, length)
print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.3:
classification_result = gasket_classifier.classify_gasket("", description, size_spec, length)
print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.3:
classification_result = instrument_classifier.classify_instrument("", description, size_spec, length)
print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}")
# 재질 분류
material_result = material_classifier.classify_material(description)
# 최종 결과 조합
# schedule이 딕셔너리인 경우 문자열로 변환
schedule_value = classification_result.get("schedule", "")
if isinstance(schedule_value, dict):
schedule_value = schedule_value.get("schedule", "")
final_result = {
**material,
"classified_category": classification_result.get("category", "UNKNOWN"),
"classified_subcategory": classification_result.get("subcategory", ""),
"material_grade": material_result.get("grade", "") if material_result else "",
"schedule": schedule_value,
"size_spec": classification_result.get("size_spec", ""),
"classification_confidence": classification_result.get("overall_confidence", 0.0),
"length": length # 길이 정보 추가
}
# 카테고리별 상세 정보 추가
category = classification_result.get("category", "")
if category == "PIPE":
# PIPE 상세 정보 추출
final_result["pipe_details"] = {
"size_inches": size_spec,
"schedule": classification_result.get("schedule", {}).get("schedule", ""),
"material_spec": classification_result.get("material", {}).get("standard", ""),
"manufacturing_method": classification_result.get("manufacturing", {}).get("method", ""),
"length_mm": length * 1000 if length else 0, # meter to mm
"outer_diameter_mm": 0.0, # 추후 계산
"wall_thickness_mm": 0.0, # 추후 계산
"weight_per_meter_kg": 0.0 # 추후 계산
}
elif category == "FITTING":
# FITTING 상세 정보 추출
final_result["fitting_details"] = {
"fitting_type": classification_result.get("fitting_type", {}).get("type", ""),
"fitting_subtype": classification_result.get("fitting_type", {}).get("subtype", ""),
"connection_method": classification_result.get("connection_method", {}).get("method", ""),
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
"material_standard": classification_result.get("material", {}).get("standard", ""),
"material_grade": classification_result.get("material", {}).get("grade", ""),
"main_size": size_spec,
"reduced_size": ""
}
elif category == "VALVE":
# VALVE 상세 정보 추출
final_result["valve_details"] = {
"valve_type": classification_result.get("valve_type", {}).get("type", ""),
"valve_subtype": classification_result.get("valve_type", {}).get("subtype", ""),
"actuator_type": classification_result.get("actuation", {}).get("method", "MANUAL"),
"connection_method": classification_result.get("connection_method", {}).get("method", ""),
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
"body_material": classification_result.get("material", {}).get("grade", ""),
"size_inches": size_spec
}
elif category == "FLANGE":
# FLANGE 상세 정보 추출
final_result["flange_details"] = {
"flange_type": classification_result.get("flange_type", {}).get("type", ""),
"flange_subtype": classification_result.get("flange_type", {}).get("subtype", ""),
"facing_type": classification_result.get("face_finish", {}).get("finish", ""),
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
"material_standard": classification_result.get("material", {}).get("standard", ""),
"material_grade": classification_result.get("material", {}).get("grade", ""),
"size_inches": size_spec
}
elif category == "BOLT":
# BOLT 상세 정보 추출
final_result["bolt_details"] = {
"bolt_type": classification_result.get("fastener_type", {}).get("type", ""),
"bolt_subtype": classification_result.get("fastener_type", {}).get("subtype", ""),
"thread_standard": classification_result.get("thread_specification", {}).get("standard", ""),
"diameter": classification_result.get("dimensions", {}).get("diameter", size_spec),
"length": classification_result.get("dimensions", {}).get("length", ""),
"thread_pitch": classification_result.get("thread_specification", {}).get("pitch", ""),
"material_standard": classification_result.get("material", {}).get("standard", ""),
"material_grade": classification_result.get("material", {}).get("grade", ""),
"coating": ""
}
elif category == "GASKET":
# GASKET 상세 정보 추출
final_result["gasket_details"] = {
"gasket_type": classification_result.get("gasket_type", {}).get("type", ""),
"gasket_material": classification_result.get("gasket_material", {}).get("material", ""),
"flange_size": size_spec,
"pressure_rating": classification_result.get("pressure_rating", {}).get("rating", ""),
"temperature_range": classification_result.get("gasket_material", {}).get("temperature_range", ""),
"thickness": classification_result.get("size_info", {}).get("thickness", ""),
"inner_diameter": classification_result.get("size_info", {}).get("inner_diameter", ""),
"outer_diameter": classification_result.get("size_info", {}).get("outer_diameter", "")
}
elif category == "INSTRUMENT":
# INSTRUMENT 상세 정보 추출
final_result["instrument_details"] = {
"instrument_type": classification_result.get("instrument_type", {}).get("type", ""),
"measurement_type": "",
"measurement_range": classification_result.get("measurement_info", {}).get("range", ""),
"output_signal": classification_result.get("measurement_info", {}).get("signal_type", ""),
"connection_size": size_spec,
"process_connection": "",
"accuracy_class": ""
}
return final_result
# parse_file과 classify_material_item 함수는 routers/files.py로 이동되었습니다
@app.get("/health")
async def health_check():

View File

@@ -104,13 +104,19 @@ def parse_dataframe(df):
material_grade = ""
if "ASTM" in description.upper():
astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper())
# ASTM 표준과 등급만 추출, end_preparation(BOE, POE, BBE 등)은 제외
astm_match = re.search(r'ASTM\s+([A-Z0-9]+(?:\s+GR\s+[A-Z0-9]+)?)', description.upper())
if astm_match:
material_grade = astm_match.group(0).strip()
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
# main_nom과 red_nom 별도 저장 (원본 값 유지)
main_nom = main_size if main_size != 'nan' and main_size != '' else None
red_nom = red_size if red_size != 'nan' and red_size != '' else None
# 기존 size_spec도 유지 (호환성을 위해)
if main_size != 'nan' and red_size != 'nan' and red_size != '':
size_spec = f"{main_size} x {red_size}"
elif main_size != 'nan' and main_size != '':
@@ -133,6 +139,8 @@ def parse_dataframe(df):
'quantity': quantity,
'unit': "EA",
'size_spec': size_spec,
'main_nom': main_nom, # 추가
'red_nom': red_nom, # 추가
'material_grade': material_grade,
'length': length_value,
'line_number': index + 1,
@@ -230,32 +238,60 @@ async def upload_file(
except (ValueError, TypeError):
length_value = None
classification_result = classify_pipe("", description, size_spec, length_value)
print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
# main_nom과 red_nom 추출
main_nom = material_data.get("main_nom")
red_nom = material_data.get("red_nom")
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_fitting("", description, size_spec)
print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_valve("", description, size_spec)
print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_flange("", description, size_spec)
print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_bolt("", description, size_spec)
print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_gasket("", description, size_spec)
print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_instrument("", description, size_spec)
print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
classification_result = None
try:
# EXCLUDE 분류기 우선 호출 (제외 대상 먼저 걸러냄)
from app.services.exclude_classifier import classify_exclude
classification_result = classify_exclude("", description, main_nom or "")
print(f"EXCLUDE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
# 파이프 분류기 호출
classification_result = classify_pipe("", description, main_nom or "", length_value)
print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
# 피팅 분류기 호출 (main_nom, red_nom 개별 전달)
classification_result = classify_fitting("", description, main_nom or "", red_nom)
print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
# 플랜지 분류기 호출 (main_nom, red_nom 개별 전달)
classification_result = classify_flange("", description, main_nom or "", red_nom)
print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
# 밸브 분류기 호출
classification_result = classify_valve("", description, main_nom or "")
print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
# 볼트 분류기 호출
classification_result = classify_bolt("", description, main_nom or "")
print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
# 가스켓 분류기 호출
classification_result = classify_gasket("", description, main_nom or "")
print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
if classification_result.get("overall_confidence", 0) < 0.5:
# 계기 분류기 호출
classification_result = classify_instrument("", description, main_nom or "")
print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
except Exception as e:
print(f"분류기 실행 중 오류 발생: {e}")
# 기본 분류 결과 생성
classification_result = {
"category": "UNKNOWN",
"overall_confidence": 0.0,
"reason": f"분류기 오류: {str(e)}"
}
print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}")
@@ -263,13 +299,13 @@ async def upload_file(
material_insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
material_grade, line_number, row_number, classified_category,
classification_confidence, is_verified, created_at
main_nom, red_nom, material_grade, line_number, row_number,
classified_category, classification_confidence, is_verified, created_at
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:material_grade, :line_number, :row_number, :classified_category,
:classification_confidence, :is_verified, :created_at
:main_nom, :red_nom, :material_grade, :line_number, :row_number,
:classified_category, :classification_confidence, :is_verified, :created_at
)
RETURNING id
""")
@@ -287,6 +323,8 @@ async def upload_file(
"quantity": material_data["quantity"],
"unit": material_data["unit"],
"size_spec": material_data["size_spec"],
"main_nom": material_data.get("main_nom"), # 추가
"red_nom": material_data.get("red_nom"), # 추가
"material_grade": material_data["material_grade"],
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
@@ -309,16 +347,11 @@ async def upload_file(
# material_id도 함께 저장하도록 수정
pipe_detail_insert_query = text("""
INSERT INTO pipe_details (
material_id, file_id, material_standard, material_grade, material_type,
manufacturing_method, end_preparation, schedule, wall_thickness,
nominal_size, length_mm, material_confidence, manufacturing_confidence,
end_prep_confidence, schedule_confidence
)
VALUES (
:material_id, :file_id, :material_standard, :material_grade, :material_type,
:manufacturing_method, :end_preparation, :schedule, :wall_thickness,
:nominal_size, :length_mm, :material_confidence, :manufacturing_confidence,
:end_prep_confidence, :schedule_confidence
material_id, file_id, outer_diameter, schedule,
material_spec, manufacturing_method, end_preparation, length_mm
) VALUES (
:material_id, :file_id, :outer_diameter, :schedule,
:material_spec, :manufacturing_method, :end_preparation, :length_mm
)
""")
@@ -329,25 +362,98 @@ async def upload_file(
schedule_info = classification_result.get("schedule", {})
size_info = classification_result.get("size_info", {})
# main_nom을 outer_diameter로 활용
outer_diameter = material_data.get("main_nom") or material_data.get("size_spec", "")
# end_preparation 정보 추출 (분류 결과에서)
end_prep = ""
if isinstance(end_prep_info, dict):
end_prep = end_prep_info.get("type", "")
else:
end_prep = str(end_prep_info) if end_prep_info else ""
# 재질 정보 - 이미 정제된 material_grade 사용
material_spec = material_data.get("material_grade", "")
# 제조방법 추출
manufacturing_method = ""
if isinstance(manufacturing_info, dict):
manufacturing_method = manufacturing_info.get("method", "UNKNOWN")
else:
manufacturing_method = str(manufacturing_info) if manufacturing_info else "UNKNOWN"
# 스케줄 정보 추출
schedule = ""
if isinstance(schedule_info, dict):
schedule = schedule_info.get("schedule", "UNKNOWN")
else:
schedule = str(schedule_info) if schedule_info else "UNKNOWN"
db.execute(pipe_detail_insert_query, {
"material_id": material_id,
"file_id": file_id,
"material_standard": material_info.get("standard"),
"material_grade": material_info.get("grade"),
"material_type": material_info.get("material_type"),
"manufacturing_method": manufacturing_info.get("method"),
"end_preparation": end_prep_info.get("type"),
"schedule": schedule_info.get("schedule"),
"wall_thickness": schedule_info.get("wall_thickness"),
"nominal_size": material_data.get("size_spec", ""), # material_data에서 직접 가져옴
"length_mm": length_mm,
"material_confidence": material_info.get("confidence", 0.0),
"manufacturing_confidence": manufacturing_info.get("confidence", 0.0),
"end_prep_confidence": end_prep_info.get("confidence", 0.0),
"schedule_confidence": schedule_info.get("confidence", 0.0)
"outer_diameter": outer_diameter,
"schedule": schedule,
"material_spec": material_spec,
"manufacturing_method": manufacturing_method,
"end_preparation": end_prep,
"length_mm": material_data.get("length", 0.0) if material_data.get("length") else 0.0
})
print("PIPE 상세 정보 저장 완료")
# FITTING 분류 결과인 경우 상세 정보 저장
elif classification_result.get("category") == "FITTING":
print("FITTING 상세 정보 저장 시작")
# 피팅 정보 추출
fitting_type_info = classification_result.get("fitting_type", {})
connection_info = classification_result.get("connection_method", {})
pressure_info = classification_result.get("pressure_rating", {})
material_info = classification_result.get("material", {})
# 피팅 타입 및 서브타입
fitting_type = fitting_type_info.get("type", "UNKNOWN")
fitting_subtype = fitting_type_info.get("subtype", "UNKNOWN")
# 연결 방식
connection_method = connection_info.get("method", "UNKNOWN")
# 압력 등급
pressure_rating = pressure_info.get("rating", "UNKNOWN")
# 재질 정보
material_standard = material_info.get("standard", "")
material_grade = material_info.get("grade", "")
# main_size와 reduced_size
main_size = material_data.get("main_nom") or material_data.get("size_spec", "")
reduced_size = material_data.get("red_nom", "")
db.execute(text("""
INSERT INTO fitting_details (
material_id, file_id, fitting_type, fitting_subtype,
connection_method, pressure_rating, material_standard,
material_grade, main_size, reduced_size
) VALUES (
:material_id, :file_id, :fitting_type, :fitting_subtype,
:connection_method, :pressure_rating, :material_standard,
:material_grade, :main_size, :reduced_size
)
"""), {
"material_id": material_id,
"file_id": file_id,
"fitting_type": fitting_type,
"fitting_subtype": fitting_subtype,
"connection_method": connection_method,
"pressure_rating": pressure_rating,
"material_standard": material_standard,
"material_grade": material_grade,
"main_size": main_size,
"reduced_size": reduced_size
})
print(f"FITTING 상세 정보 저장 완료: {fitting_type} - {fitting_subtype}")
db.commit()
print(f"자재 저장 완료: {materials_inserted}")
@@ -457,14 +563,17 @@ async def get_materials(
try:
query = """
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
m.size_spec, m.material_grade, m.line_number, m.row_number,
m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.line_number, m.row_number,
m.created_at, m.classified_category, m.classification_confidence,
m.classification_details,
f.original_filename, f.project_id, f.job_no, f.revision,
p.official_project_code, p.project_name
p.official_project_code, p.project_name,
pd.outer_diameter, pd.schedule, pd.material_spec, pd.manufacturing_method,
pd.end_preparation, pd.length_mm
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
LEFT JOIN projects p ON f.project_id = p.id
LEFT JOIN pipe_details pd ON m.id = pd.material_id
WHERE 1=1
"""
params = {}
@@ -579,6 +688,8 @@ async def get_materials(
"quantity": float(m.quantity) if m.quantity else 0,
"unit": m.unit,
"size_spec": m.size_spec,
"main_nom": m.main_nom, # 추가
"red_nom": m.red_nom, # 추가
"material_grade": m.material_grade,
"line_number": m.line_number,
"row_number": m.row_number,
@@ -588,22 +699,17 @@ async def get_materials(
"created_at": m.created_at
}
# 카테고리별 상세 정보 추가
# 카테고리별 상세 정보 추가 (JOIN 결과 사용)
if m.classified_category == 'PIPE':
pipe_query = text("SELECT * FROM pipe_details WHERE material_id = :material_id")
pipe_result = db.execute(pipe_query, {"material_id": m.id})
pipe_detail = pipe_result.fetchone()
if pipe_detail:
# JOIN된 결과에서 pipe_details 정보 가져오기
if hasattr(m, 'outer_diameter') and m.outer_diameter is not None:
material_dict['pipe_details'] = {
"nominal_size": pipe_detail.nominal_size,
"schedule": pipe_detail.schedule,
"material_standard": pipe_detail.material_standard,
"material_grade": pipe_detail.material_grade,
"material_type": pipe_detail.material_type,
"manufacturing_method": pipe_detail.manufacturing_method,
"end_preparation": pipe_detail.end_preparation,
"wall_thickness": pipe_detail.wall_thickness,
"length_mm": float(pipe_detail.length_mm) if pipe_detail.length_mm else None
"outer_diameter": m.outer_diameter,
"schedule": m.schedule,
"material_spec": m.material_spec,
"manufacturing_method": m.manufacturing_method,
"end_preparation": m.end_preparation,
"length_mm": float(m.length_mm) if m.length_mm else None
}
elif m.classified_category == 'FITTING':
fitting_query = text("SELECT * FROM fitting_details WHERE material_id = :material_id")
@@ -906,19 +1012,12 @@ async def get_pipe_details(
"original_description": pd.original_description,
"quantity": pd.quantity,
"unit": pd.unit,
"material_standard": pd.material_standard,
"material_grade": pd.material_grade,
"material_type": pd.material_type,
"material_spec": pd.material_spec,
"manufacturing_method": pd.manufacturing_method,
"end_preparation": pd.end_preparation,
"schedule": pd.schedule,
"wall_thickness": pd.wall_thickness,
"nominal_size": pd.nominal_size,
"outer_diameter": pd.outer_diameter,
"length_mm": pd.length_mm,
"material_confidence": pd.material_confidence,
"manufacturing_confidence": pd.manufacturing_confidence,
"end_prep_confidence": pd.end_prep_confidence,
"schedule_confidence": pd.schedule_confidence,
"created_at": pd.created_at,
"updated_at": pd.updated_at
}

View File

@@ -0,0 +1,85 @@
"""
EXCLUDE 분류 시스템
실제 자재가 아닌 계산용/제외 항목들 분류
"""
import re
from typing import Dict, List, Optional
# ========== 제외 대상 타입 ==========
EXCLUDE_TYPES = {
"WELD_GAP": {
"description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
"characteristics": "용접 시 수축 고려용 계산 항목",
"reason": "실제 자재 아님 - 용접 갭 계산용"
},
"CUTTING_LOSS": {
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
"characteristics": "절단 시 손실 고려용 계산 항목",
"reason": "실제 자재 아님 - 절단 로스 계산용"
},
"SPARE_ALLOWANCE": {
"description_keywords": ["SPARE", "ALLOWANCE", "여유분", "스페어"],
"characteristics": "예비품/여유분 계산 항목",
"reason": "실제 자재 아님 - 여유분 계산용"
},
"THICKNESS_NOTE": {
"description_keywords": ["THK", "THICK", "두께", "THICKNESS"],
"characteristics": "두께 표기용 항목",
"reason": "실제 자재 아님 - 두께 정보"
},
"CALCULATION_ITEM": {
"description_keywords": ["CALC", "CALCULATION", "계산", "산정"],
"characteristics": "기타 계산용 항목",
"reason": "실제 자재 아님 - 계산 목적"
}
}
def classify_exclude(dat_file: str, description: str, main_nom: str = "") -> Dict:
"""
제외 대상 분류
Args:
dat_file: DAT_FILE 필드
description: DESCRIPTION 필드
main_nom: MAIN_NOM 필드
Returns:
제외 분류 결과
"""
desc_upper = description.upper()
# 제외 대상 키워드 확인
for exclude_type, type_data in EXCLUDE_TYPES.items():
for keyword in type_data["description_keywords"]:
if keyword in desc_upper:
return {
"category": "EXCLUDE",
"exclude_type": exclude_type,
"characteristics": type_data["characteristics"],
"reason": type_data["reason"],
"overall_confidence": 0.95,
"evidence": [f"EXCLUDE_KEYWORD: {keyword}"],
"recommendation": "BOM에서 제외 권장"
}
# 제외 대상 아님
return {
"category": "UNKNOWN",
"overall_confidence": 0.0,
"reason": "제외 대상 키워드 없음"
}
def is_exclude_item(description: str) -> bool:
"""간단한 제외 대상 체크"""
desc_upper = description.upper()
exclude_keywords = [
"WELD GAP", "WELDING GAP", "GAP",
"CUTTING LOSS", "CUT LOSS",
"SPARE", "ALLOWANCE",
"THK", "THICK"
]
return any(keyword in desc_upper for keyword in exclude_keywords)

View File

@@ -39,7 +39,7 @@ FITTING_TYPES = {
"dat_file_patterns": ["CNC_", "ECC_", "RED_", "REDUCER_"],
"description_keywords": ["REDUCER", "RED", "리듀서"],
"subtypes": {
"CONCENTRIC": ["CONCENTRIC", "CNC", "동심", "CON"],
"CONCENTRIC": ["CONCENTRIC", "CONC", "CNC", "동심", "CON"],
"ECCENTRIC": ["ECCENTRIC", "ECC", "편심"]
},
"requires_two_sizes": True,
@@ -59,6 +59,18 @@ FITTING_TYPES = {
"size_range": "1/4\" ~ 24\""
},
"PLUG": {
"dat_file_patterns": ["PLUG_", "HEX_PLUG"],
"description_keywords": ["PLUG", "플러그", "HEX.PLUG", "HEX PLUG", "HEXAGON PLUG"],
"subtypes": {
"HEX": ["HEX", "HEXAGON", "육각"],
"SQUARE": ["SQUARE", "사각"],
"THREADED": ["THD", "THREADED", "나사", "NPT"]
},
"common_connections": ["THREADED", "NPT"],
"size_range": "1/8\" ~ 4\""
},
"NIPPLE": {
"dat_file_patterns": ["NIP_", "NIPPLE_"],
"description_keywords": ["NIPPLE", "니플"],
@@ -77,8 +89,8 @@ FITTING_TYPES = {
"dat_file_patterns": ["SWG_"],
"description_keywords": ["SWAGE", "스웨지"],
"subtypes": {
"CONCENTRIC": ["CONCENTRIC", "CN", "CON", "동심"],
"ECCENTRIC": ["ECCENTRIC", "EC", "ECC", "편심"]
"CONCENTRIC": ["CONCENTRIC", "CONC", "CN", "CON", "동심"],
"ECCENTRIC": ["ECCENTRIC", "ECC", "EC", "편심"]
},
"requires_two_sizes": True,
"common_connections": ["BUTT_WELD", "SOCKET_WELD"],
@@ -87,12 +99,14 @@ FITTING_TYPES = {
"OLET": {
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_", "SOCK-O-LET", "WELD-O-LET"],
"description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET"],
"description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREAD-O-LET", "THREADOLET", "SOCKLET", "SOCKET"],
"subtypes": {
"SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET"],
"WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET"],
"THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL"],
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL"]
"SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"],
"WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET", "WELDING-O-LET"],
"THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"],
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"],
"NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"],
"COUPOLET": ["COUP-O-LET", "COUPOLET", "COL", "COUPLING-O-LET"]
},
"requires_two_sizes": True, # 주배관 x 분기관
"common_connections": ["SOCKET_WELD", "THREADED", "BUTT_WELD"],
@@ -189,7 +203,7 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
dat_upper = dat_file.upper()
# 1. 명칭 우선 확인 (피팅 키워드가 있으면 피팅)
fitting_keywords = ['ELBOW', 'TEE', 'REDUCER', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', '엘보', '', '리듀서', '', '니플', '스웨지', '올렛', '커플링', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET']
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', 'PLUG', 'SOCKLET', 'SOCKET', '엘보', '', '리듀서', '', '니플', '스웨지', '올렛', '커플링', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET']
is_fitting = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
if not is_fitting:

View File

@@ -0,0 +1,18 @@
-- main_nom, red_nom 컬럼 추가 스크립트
-- 2025.01.17 - MAIN_NOM/RED_NOM 별도 저장을 위한 스키마 개선
-- materials 테이블에 main_nom, red_nom 컬럼 추가
ALTER TABLE materials ADD COLUMN IF NOT EXISTS main_nom VARCHAR(50);
ALTER TABLE materials ADD COLUMN IF NOT EXISTS red_nom VARCHAR(50);
-- 인덱스 추가 (검색 성능 향상)
CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom);
CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom);
CREATE INDEX IF NOT EXISTS idx_materials_main_red_nom ON materials(main_nom, red_nom);
-- 기존 데이터에 대한 기본값 설정 (필요시)
-- UPDATE materials SET main_nom = '', red_nom = '' WHERE main_nom IS NULL OR red_nom IS NULL;
-- 코멘트 추가
COMMENT ON COLUMN materials.main_nom IS 'MAIN_NOM 필드 - 주 사이즈 (예: 4", 150A)';
COMMENT ON COLUMN materials.red_nom IS 'RED_NOM 필드 - 축소 사이즈 (Reducing 피팅/플랜지용)';

View File

@@ -0,0 +1,68 @@
-- PIPE_DETAILS 테이블 간소화 스크립트
-- 2025.01.17 - 실무 중심 구조 개선
-- 기존 테이블 백업
CREATE TABLE IF NOT EXISTS pipe_details_backup AS SELECT * FROM pipe_details;
-- 새로운 간소화된 테이블 생성
DROP TABLE IF EXISTS pipe_details_new;
CREATE TABLE pipe_details_new (
id SERIAL PRIMARY KEY,
material_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
-- 핵심 PIPE 정보 (실무 필수)
outer_diameter VARCHAR(20), -- main_nom 기반 외경 정보
schedule VARCHAR(10), -- SCH 40, SCH 80 등
material_spec VARCHAR(100), -- 단일 재질 정보 (ASTM A106 GR B)
manufacturing_method VARCHAR(20), -- SEAMLESS, WELDED, CAST
end_preparation VARCHAR(20), -- POE, BOE, PEE, BEE 등
length_mm DECIMAL(10,2), -- 길이 (mm)
-- 추가 정보 (cutting plan 용)
area_number VARCHAR(10), -- 에리어 번호 (#01, #02)
spool_number VARCHAR(10), -- 스풀 번호 (A, B, C)
drawing_number VARCHAR(50), -- 도면 번호
-- 메타 정보
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 외래키
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE,
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
);
-- 인덱스 추가 (성능)
CREATE INDEX IF NOT EXISTS idx_pipe_details_material_id ON pipe_details_new(material_id);
CREATE INDEX IF NOT EXISTS idx_pipe_details_file_id ON pipe_details_new(file_id);
CREATE INDEX IF NOT EXISTS idx_pipe_details_outer_diameter ON pipe_details_new(outer_diameter);
CREATE INDEX IF NOT EXISTS idx_pipe_details_schedule ON pipe_details_new(schedule);
-- 기존 데이터 마이그레이션 (가능한 것만)
INSERT INTO pipe_details_new (
material_id, file_id, outer_diameter, schedule, material_spec,
manufacturing_method, length_mm
)
SELECT
material_id,
file_id,
nominal_size as outer_diameter, -- nominal_size를 outer_diameter로
schedule,
COALESCE(material_standard, material_grade, material_type) as material_spec, -- 중복 필드 통합
manufacturing_method,
length_mm
FROM pipe_details
WHERE material_id IS NOT NULL;
-- 백업 테이블로 기존 테이블 교체
DROP TABLE IF EXISTS pipe_details;
ALTER TABLE pipe_details_new RENAME TO pipe_details;
-- 코멘트 추가
COMMENT ON TABLE pipe_details IS '간소화된 PIPE 상세 정보 - 실무 중심 구조';
COMMENT ON COLUMN pipe_details.outer_diameter IS '외경 정보 (main_nom에서 추출, 예: 4", 150A)';
COMMENT ON COLUMN pipe_details.material_spec IS '통합 재질 정보 (예: ASTM A106 GR B)';
COMMENT ON COLUMN pipe_details.end_preparation IS '끝단 가공 (POE-BOE, PEE, BEE 등)';
COMMENT ON COLUMN pipe_details.area_number IS '에리어 번호 (cutting plan용)';
COMMENT ON COLUMN pipe_details.spool_number IS '스풀 번호 (cutting plan용)';

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
main_nom, red_nom 기능 테스트 스크립트
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from app.services.fitting_classifier import classify_fitting
from app.services.flange_classifier import classify_flange
def test_main_red_nom():
"""main_nom과 red_nom 분류 테스트"""
print("🔧 main_nom/red_nom 분류 테스트 시작!")
print("=" * 60)
test_cases = [
{
"name": "일반 TEE (동일 사이즈)",
"description": "TEE, SCH 40, ASTM A234 GR WPB",
"main_nom": "4\"",
"red_nom": None,
"expected": "EQUAL TEE"
},
{
"name": "리듀싱 TEE (다른 사이즈)",
"description": "TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",
"main_nom": "4\"",
"red_nom": "2\"",
"expected": "REDUCING TEE"
},
{
"name": "동심 리듀서",
"description": "RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",
"main_nom": "6\"",
"red_nom": "4\"",
"expected": "CONCENTRIC REDUCER"
},
{
"name": "리듀싱 플랜지",
"description": "FLG REDUCING, 300LB, ASTM A105",
"main_nom": "6\"",
"red_nom": "4\"",
"expected": "REDUCING FLANGE"
}
]
for i, test in enumerate(test_cases, 1):
print(f"\n{i}. {test['name']}")
print(f" 설명: {test['description']}")
print(f" MAIN_NOM: {test['main_nom']}")
print(f" RED_NOM: {test['red_nom']}")
# 피팅 분류 테스트
fitting_result = classify_fitting(
"",
test['description'],
test['main_nom'],
test['red_nom']
)
print(f" 🔧 FITTING 분류 결과:")
print(f" 카테고리: {fitting_result.get('category')}")
print(f" 타입: {fitting_result.get('fitting_type', {}).get('type')}")
print(f" 서브타입: {fitting_result.get('fitting_type', {}).get('subtype')}")
print(f" 신뢰도: {fitting_result.get('overall_confidence', 0):.2f}")
# 사이즈 정보 확인
size_info = fitting_result.get('size_info', {})
print(f" 메인 사이즈: {size_info.get('main_size')}")
print(f" 축소 사이즈: {size_info.get('reduced_size')}")
print(f" 사이즈 설명: {size_info.get('size_description')}")
# RED_NOM이 있는 경우 REDUCING 분류 확인
if test['red_nom']:
fitting_type = fitting_result.get('fitting_type', {})
if 'REDUCING' in fitting_type.get('subtype', '').upper():
print(f" ✅ REDUCING 타입 정상 인식!")
else:
print(f" ❌ REDUCING 타입 인식 실패")
print("-" * 50)
print("\n🎯 테스트 완료!")
if __name__ == "__main__":
test_main_red_nom()

6
backend/test_sample.csv Normal file
View File

@@ -0,0 +1,6 @@
description,qty,main_nom,red_nom,length
"TEE EQUAL, SCH 40, ASTM A234 GR WPB",2,4",,"
"TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB",1,4",2","
"RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB",1,6",4","
"90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS",4,3",,"
"PIPE SMLS, SCH 40, ASTM A106 GR B",1,2",,6000
Can't render this file because it contains an unexpected character in line 2 and column 42.

View File

@@ -55,7 +55,7 @@ api.interceptors.response.use(
export function uploadFile(formData, options = {}) {
const config = {
method: 'post',
url: '/upload',
url: '/files/upload',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
...options,

File diff suppressed because it is too large Load Diff