Files
TK-BOM-Project/backend/app/api/files.py
Hyungi Ahn 3dd301cb57 볼트 분류 개선 및 업로드 성능 최적화
- 볼트 길이 추출 로직 개선: '70.0000 LG' 형태 인식 추가
- 재질 중복 표시 수정: 'ASTM A193 ASTM A193 B7' → 'B7'
- A193/A194 등급 추출 로직 개선: 'GR B7/2H' 형태 지원
- bolt_details 테이블에 pressure_rating 컬럼 추가
- 볼트 분류기 오분류 방지: 플랜지/피팅이 볼트로 분류되지 않도록 수정
- 업로드 성능 개선: 키워드 기반 빠른 분류기 선택 로직 추가
- 분류 키워드 대폭 확장: 피팅/파이프/플랜지 키워드 추가
2025-07-18 12:48:24 +09:00

1181 lines
55 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional
import os
import shutil
from datetime import datetime
import uuid
import pandas as pd
import re
import json
from pathlib import Path
from ..database import get_db
from app.services.material_classifier import classify_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.pipe_classifier import classify_pipe
from app.services.valve_classifier import classify_valve
router = APIRouter()
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
@router.get("/")
async def get_files_info():
return {
"message": "파일 관리 API",
"allowed_extensions": list(ALLOWED_EXTENSIONS),
"upload_directory": str(UPLOAD_DIR)
}
@router.get("/test")
async def test_endpoint():
return {"status": "파일 API가 정상 작동합니다!"}
@router.post("/add-missing-columns")
async def add_missing_columns(db: Session = Depends(get_db)):
"""누락된 컬럼들 추가"""
try:
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER"))
db.commit()
return {
"success": True,
"message": "누락된 컬럼들이 추가되었습니다",
"added_columns": ["files.parsed_count", "materials.row_number"]
}
except Exception as e:
db.rollback()
return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"}
def validate_file_extension(filename: str) -> bool:
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
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}"
def parse_dataframe(df):
df = df.dropna(how='all')
# 원본 컬럼명 유지 (소문자 변환하지 않음)
df.columns = df.columns.str.strip()
column_mapping = {
'description': ['description', 'item', 'material', '품명', '자재명'],
'quantity': ['qty', 'quantity', 'ea', '수량'],
'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'],
'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:
# 대소문자 구분 없이 매핑
for col in df.columns:
if possible_name.lower() == col.lower():
mapped_columns[standard_col] = col
break
if standard_col in mapped_columns:
break
print(f"찾은 컬럼 매핑: {mapped_columns}")
materials = []
for index, row in df.iterrows():
description = str(row.get(mapped_columns.get('description', ''), ''))
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
try:
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
except:
quantity = 0
# 길이 정보 파싱
length_raw = row.get(mapped_columns.get('length', ''), None)
length_value = None
if pd.notna(length_raw) and length_raw != '':
try:
length_value = float(length_raw)
except:
length_value = None
material_grade = ""
if "ASTM" in description.upper():
astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', 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', ''), ''))
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 = ""
if description and description not in ['nan', 'None', '']:
materials.append({
'original_description': description,
'quantity': quantity,
'unit': "EA",
'size_spec': size_spec,
'material_grade': material_grade,
'length': length_value,
'line_number': index + 1,
'row_number': index + 1
})
return materials
def parse_file_data(file_path):
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"]:
df = pd.read_excel(file_path, sheet_name=0)
else:
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식")
return parse_dataframe(df)
except Exception as e:
raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}")
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
project_id: int = Form(...),
revision: str = Form("Rev.0"),
db: Session = Depends(get_db)
):
if not validate_file_extension(str(file.filename)):
raise HTTPException(
status_code=400,
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
)
if file.size and file.size > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
unique_filename = generate_unique_filename(str(file.filename))
file_path = UPLOAD_DIR / unique_filename
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
try:
materials_data = parse_file_data(str(file_path))
parsed_count = len(materials_data)
# 파일 정보 저장
file_insert_query = text("""
INSERT INTO files (filename, original_filename, file_path, project_id, revision, description, file_size, parsed_count, is_active)
VALUES (:filename, :original_filename, :file_path, :project_id, :revision, :description, :file_size, :parsed_count, :is_active)
RETURNING id
""")
file_result = db.execute(file_insert_query, {
"filename": unique_filename,
"original_filename": file.filename,
"file_path": str(file_path),
"project_id": project_id,
"revision": revision,
"description": f"BOM 파일 - {parsed_count}개 자재",
"file_size": file.size,
"parsed_count": parsed_count,
"is_active": True
})
file_id = file_result.fetchone()[0]
# 자재 데이터 저장 (분류 포함)
materials_inserted = 0
for material_data in materials_data:
# 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등)
description = material_data["original_description"]
size_spec = material_data["size_spec"]
# 각 분류기로 시도 (올바른 매개변수 사용)
print(f"분류 시도: {description}")
# 분류기 호출 시 타임아웃 및 예외 처리
classification_result = None
try:
# 파이프 분류기 호출 시 length 매개변수 전달
length_value = None
if 'length' in material_data:
try:
length_value = float(material_data['length'])
except:
length_value = None
# None이면 0.0으로 대체
if length_value is None:
length_value = 0.0
# 타임아웃 설정 (10초)
import signal
def timeout_handler(signum, frame):
raise TimeoutError("분류기 실행 시간 초과")
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(10) # 10초 타임아웃
try:
classification_result = classify_pipe("", description, size_spec, length_value)
print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
finally:
signal.alarm(0) # 타임아웃 해제
if classification_result.get("overall_confidence", 0) < 0.5:
signal.alarm(10)
try:
classification_result = classify_fitting("", description, size_spec)
print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
finally:
signal.alarm(0)
if classification_result.get("overall_confidence", 0) < 0.5:
signal.alarm(10)
try:
classification_result = classify_valve("", description, size_spec)
print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
finally:
signal.alarm(0)
if classification_result.get("overall_confidence", 0) < 0.5:
signal.alarm(10)
try:
classification_result = classify_flange("", description, size_spec)
print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
finally:
signal.alarm(0)
if classification_result.get("overall_confidence", 0) < 0.5:
signal.alarm(10)
try:
classification_result = classify_bolt("", description, size_spec)
print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
finally:
signal.alarm(0)
if classification_result.get("overall_confidence", 0) < 0.5:
signal.alarm(10)
try:
classification_result = classify_gasket("", description, size_spec)
print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
finally:
signal.alarm(0)
if classification_result.get("overall_confidence", 0) < 0.5:
signal.alarm(10)
try:
classification_result = classify_instrument("", description, size_spec)
print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
finally:
signal.alarm(0)
except (TimeoutError, 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')}")
# 분류 결과에서 상세 정보 추출
if classification_result.get('category') == 'PIPE':
classification_details = classification_result
elif classification_result.get('category') == 'FITTING':
classification_details = classification_result
elif classification_result.get('category') == 'VALVE':
classification_details = classification_result
else:
classification_details = {}
# DB에 저장 시 JSON 직렬화
classification_details = json.dumps(classification_details, ensure_ascii=False)
# 디버깅: 저장 직전 데이터 확인
print(f"=== 자재[{materials_inserted + 1}] 저장 직전 ===")
print(f"자재명: {material_data['original_description']}")
print(f"분류결과: {classification_result.get('category')}")
print(f"신뢰도: {classification_result.get('overall_confidence', 0)}")
print(f"classification_details 길이: {len(classification_details)}")
print(f"classification_details 샘플: {classification_details[:200]}...")
print("=" * 50)
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, classification_details, is_verified, created_at
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:material_grade, :line_number, :row_number, :classified_category,
:classification_confidence, :classification_details, :is_verified, :created_at
)
""")
db.execute(material_insert_query, {
"file_id": file_id,
"original_description": material_data["original_description"],
"quantity": material_data["quantity"],
"unit": material_data["unit"],
"size_spec": material_data["size_spec"],
"material_grade": material_data["material_grade"],
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
"classified_category": classification_result.get("category", "UNKNOWN"),
"classification_confidence": classification_result.get("overall_confidence", 0.0),
"classification_details": classification_details,
"is_verified": False,
"created_at": datetime.now()
})
# 각 카테고리별로 상세 테이블에 저장
category = classification_result.get('category')
confidence = classification_result.get('overall_confidence', 0)
if category == 'PIPE' and confidence >= 0.5:
try:
# 분류 결과에서 파이프 상세 정보 추출
pipe_info = classification_result
# cutting_dimensions에서 length 정보 가져오기
cutting_dims = pipe_info.get('cutting_dimensions', {})
length_mm = cutting_dims.get('length_mm')
# length_mm가 없으면 원본 데이터의 length 사용
if not length_mm and material_data.get('length'):
length_mm = material_data['length']
pipe_insert_query = text("""
INSERT INTO pipe_details (
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, :outer_diameter, :schedule,
:material_spec, :manufacturing_method, :length_mm
)
""")
db.execute(pipe_insert_query, {
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"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,
})
print(f"PIPE 상세정보 저장 완료: {material_data['original_description']}")
except Exception as e:
print(f"PIPE 상세정보 저장 실패: {e}")
# 에러가 발생해도 전체 프로세스는 계속 진행
elif category == 'FITTING' and confidence >= 0.5:
try:
fitting_info = classification_result
fitting_insert_query = text("""
INSERT INTO fitting_details (
material_id, file_id, fitting_type, fitting_subtype,
connection_method, connection_code, pressure_rating, max_pressure,
manufacturing_method, material_standard, material_grade, material_type,
main_size, reduced_size, classification_confidence, additional_info
)
VALUES (
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
:file_id, :fitting_type, :fitting_subtype,
:connection_method, :connection_code, :pressure_rating, :max_pressure,
:manufacturing_method, :material_standard, :material_grade, :material_type,
:main_size, :reduced_size, :classification_confidence, :additional_info
)
""")
db.execute(fitting_insert_query, {
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"fitting_type": fitting_info.get('fitting_type', {}).get('type', ''),
"fitting_subtype": fitting_info.get('fitting_type', {}).get('subtype', ''),
"connection_method": fitting_info.get('connection_method', {}).get('method', ''),
"connection_code": fitting_info.get('connection_method', {}).get('matched_code', ''),
"pressure_rating": fitting_info.get('pressure_rating', {}).get('rating', ''),
"max_pressure": fitting_info.get('pressure_rating', {}).get('max_pressure', ''),
"manufacturing_method": fitting_info.get('manufacturing', {}).get('method', ''),
"material_standard": fitting_info.get('material', {}).get('standard', ''),
"material_grade": fitting_info.get('material', {}).get('grade', ''),
"material_type": fitting_info.get('material', {}).get('material_type', ''),
"main_size": fitting_info.get('size_info', {}).get('main_size', ''),
"reduced_size": fitting_info.get('size_info', {}).get('reduced_size', ''),
"classification_confidence": confidence,
"additional_info": json.dumps(fitting_info, ensure_ascii=False)
})
print(f"FITTING 상세정보 저장 완료: {material_data['original_description']}")
except Exception as e:
print(f"FITTING 상세정보 저장 실패: {e}")
elif category == 'VALVE' and confidence >= 0.5:
try:
valve_info = classification_result
valve_insert_query = text("""
INSERT INTO valve_details (
material_id, file_id, valve_type, valve_subtype, actuator_type,
connection_method, pressure_rating, pressure_class,
body_material, trim_material, size_inches,
fire_safe, low_temp_service, classification_confidence, additional_info
)
VALUES (
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
:file_id, :valve_type, :valve_subtype, :actuator_type,
:connection_method, :pressure_rating, :pressure_class,
:body_material, :trim_material, :size_inches,
:fire_safe, :low_temp_service, :classification_confidence, :additional_info
)
""")
db.execute(valve_insert_query, {
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"valve_type": valve_info.get('valve_type', ''),
"valve_subtype": valve_info.get('valve_subtype', ''),
"actuator_type": valve_info.get('actuator_type', ''),
"connection_method": valve_info.get('connection_method', ''),
"pressure_rating": valve_info.get('pressure_rating', ''),
"pressure_class": valve_info.get('pressure_class', ''),
"body_material": valve_info.get('body_material', ''),
"trim_material": valve_info.get('trim_material', ''),
"size_inches": valve_info.get('size', ''),
"fire_safe": valve_info.get('fire_safe', False),
"low_temp_service": valve_info.get('low_temp_service', False),
"classification_confidence": confidence,
"additional_info": json.dumps(valve_info, ensure_ascii=False)
})
print(f"VALVE 상세정보 저장 완료: {material_data['original_description']}")
except Exception as e:
print(f"VALVE 상세정보 저장 실패: {e}")
elif category == 'FLANGE' and confidence >= 0.5:
try:
flange_info = classification_result
flange_insert_query = text("""
INSERT INTO flange_details (
material_id, file_id, flange_type, facing_type,
pressure_rating, material_standard, material_grade,
size_inches, classification_confidence, additional_info
)
VALUES (
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
:file_id, :flange_type, :facing_type,
:pressure_rating, :material_standard, :material_grade,
:size_inches, :classification_confidence, :additional_info
)
""")
db.execute(flange_insert_query, {
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"flange_type": flange_info.get('flange_type', {}).get('type', ''),
"facing_type": flange_info.get('face_finish', {}).get('finish', ''),
"pressure_rating": flange_info.get('pressure_rating', {}).get('rating', ''),
"material_standard": flange_info.get('material', {}).get('standard', ''),
"material_grade": flange_info.get('material', {}).get('grade', ''),
"size_inches": material_data.get('size_spec', ''),
"classification_confidence": confidence,
"additional_info": json.dumps(flange_info, ensure_ascii=False)
})
print(f"FLANGE 상세정보 저장 완료: {material_data['original_description']}")
except Exception as e:
print(f"FLANGE 상세정보 저장 실패: {e}")
elif category == 'BOLT' and confidence >= 0.5:
try:
bolt_info = classification_result
bolt_insert_query = text("""
INSERT INTO bolt_details (
material_id, file_id, bolt_type, thread_type,
diameter, length, material_standard, material_grade,
coating_type, includes_nut, includes_washer,
classification_confidence, additional_info
)
VALUES (
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
:file_id, :bolt_type, :thread_type,
:diameter, :length, :material_standard, :material_grade,
:coating_type, :includes_nut, :includes_washer,
:classification_confidence, :additional_info
)
""")
# BOLT 분류기 결과 구조에 맞게 데이터 추출
bolt_details = bolt_info.get('bolt_details', {})
material_info = bolt_info.get('material', {})
db.execute(bolt_insert_query, {
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"bolt_type": bolt_details.get('type', ''),
"thread_type": bolt_details.get('thread_type', ''),
"diameter": bolt_details.get('diameter', ''),
"length": bolt_details.get('length', ''),
"material_standard": material_info.get('standard', ''),
"material_grade": material_info.get('grade', ''),
"coating_type": material_info.get('coating', ''),
"includes_nut": bolt_details.get('includes_nut', False),
"includes_washer": bolt_details.get('includes_washer', False),
"classification_confidence": confidence,
"additional_info": json.dumps(bolt_info, ensure_ascii=False)
})
print(f"BOLT 상세정보 저장 완료: {material_data['original_description']}")
except Exception as e:
print(f"BOLT 상세정보 저장 실패: {e}")
elif category == 'GASKET' and confidence >= 0.5:
try:
gasket_info = classification_result
gasket_insert_query = text("""
INSERT INTO gasket_details (
material_id, file_id, gasket_type, gasket_subtype,
material_type, size_inches, pressure_rating,
thickness, temperature_range, fire_safe,
classification_confidence, additional_info
)
VALUES (
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
:file_id, :gasket_type, :gasket_subtype,
:material_type, :size_inches, :pressure_rating,
:thickness, :temperature_range, :fire_safe,
:classification_confidence, :additional_info
)
""")
# GASKET 분류기 결과 구조에 맞게 데이터 추출
gasket_type_info = gasket_info.get('gasket_type', {})
gasket_material_info = gasket_info.get('gasket_material', {})
pressure_info = gasket_info.get('pressure_rating', {})
size_info = gasket_info.get('size_info', {})
temp_info = gasket_info.get('temperature_info', {})
# SWG 상세 정보 추출
swg_details = gasket_material_info.get('swg_details', {})
additional_info = {
"swg_details": swg_details,
"face_type": swg_details.get('face_type', ''),
"construction": swg_details.get('detailed_construction', ''),
"filler": swg_details.get('filler', ''),
"outer_ring": swg_details.get('outer_ring', ''),
"inner_ring": swg_details.get('inner_ring', '')
}
db.execute(gasket_insert_query, {
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"gasket_type": gasket_type_info.get('type', ''),
"gasket_subtype": gasket_type_info.get('subtype', ''),
"material_type": gasket_material_info.get('material', ''),
"size_inches": material_data.get('main_nom', '') or material_data.get('size_spec', ''),
"pressure_rating": pressure_info.get('rating', ''),
"thickness": swg_details.get('thickness', None),
"temperature_range": temp_info.get('range', ''),
"fire_safe": gasket_info.get('fire_safe', False),
"classification_confidence": confidence,
"additional_info": json.dumps(additional_info, ensure_ascii=False)
})
print(f"GASKET 상세정보 저장 완료: {material_data['original_description']}")
except Exception as e:
print(f"GASKET 상세정보 저장 실패: {e}")
elif category == 'INSTRUMENT' and confidence >= 0.5:
try:
inst_info = classification_result
inst_insert_query = text("""
INSERT INTO instrument_details (
material_id, file_id, instrument_type, instrument_subtype,
measurement_type, measurement_range, accuracy,
connection_type, connection_size, body_material,
classification_confidence, additional_info
)
VALUES (
(SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number),
:file_id, :instrument_type, :instrument_subtype,
:measurement_type, :measurement_range, :accuracy,
:connection_type, :connection_size, :body_material,
:classification_confidence, :additional_info
)
""")
# INSTRUMENT 분류기 결과 구조에 맞게 데이터 추출
inst_type_info = inst_info.get('instrument_type', {})
measurement_info = inst_info.get('measurement', {})
connection_info = inst_info.get('connection', {})
db.execute(inst_insert_query, {
"file_id": file_id,
"description": material_data["original_description"],
"row_number": material_data["row_number"],
"instrument_type": inst_type_info.get('type', ''),
"instrument_subtype": inst_type_info.get('subtype', ''),
"measurement_type": measurement_info.get('type', ''),
"measurement_range": measurement_info.get('range', ''),
"accuracy": measurement_info.get('accuracy', ''),
"connection_type": connection_info.get('type', ''),
"connection_size": connection_info.get('size', ''),
"body_material": inst_info.get('material', ''),
"classification_confidence": confidence,
"additional_info": json.dumps(inst_info, ensure_ascii=False)
})
print(f"INSTRUMENT 상세정보 저장 완료: {material_data['original_description']}")
except Exception as e:
print(f"INSTRUMENT 상세정보 저장 실패: {e}")
materials_inserted += 1
db.commit()
return {
"success": True,
"message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨",
"original_filename": file.filename,
"file_id": file_id,
"parsed_materials_count": parsed_count,
"saved_materials_count": materials_inserted,
"sample_materials": materials_data[:3] if materials_data else []
}
except Exception as e:
db.rollback()
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
@router.get("/materials")
async def get_materials(
project_id: Optional[int] = None,
file_id: Optional[int] = None,
job_no: Optional[str] = None,
filename: Optional[str] = None,
revision: Optional[str] = None,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
item_type: Optional[str] = None,
material_grade: Optional[str] = None,
size_spec: Optional[str] = None,
file_filter: Optional[str] = None,
sort_by: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능)
"""
try:
query = """
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
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,
p.official_project_code, p.project_name
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
LEFT JOIN projects p ON f.project_id = p.id
WHERE 1=1
"""
params = {}
if project_id:
query += " AND f.project_id = :project_id"
params["project_id"] = project_id
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
if job_no:
query += " AND f.job_no = :job_no"
params["job_no"] = job_no
if filename:
query += " AND f.original_filename = :filename"
params["filename"] = filename
if revision:
query += " AND f.revision = :revision"
params["revision"] = revision
if search:
query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)"
params["search"] = f"%{search}%"
if item_type:
query += " AND m.classified_category = :item_type"
params["item_type"] = item_type
if material_grade:
query += " AND m.material_grade ILIKE :material_grade"
params["material_grade"] = f"%{material_grade}%"
if size_spec:
query += " AND m.size_spec ILIKE :size_spec"
params["size_spec"] = f"%{size_spec}%"
if file_filter:
query += " AND f.original_filename ILIKE :file_filter"
params["file_filter"] = f"%{file_filter}%"
# 정렬 처리
if sort_by:
if sort_by == "quantity_desc":
query += " ORDER BY m.quantity DESC"
elif sort_by == "quantity_asc":
query += " ORDER BY m.quantity ASC"
elif sort_by == "name_asc":
query += " ORDER BY m.original_description ASC"
elif sort_by == "name_desc":
query += " ORDER BY m.original_description DESC"
elif sort_by == "created_desc":
query += " ORDER BY m.created_at DESC"
elif sort_by == "created_asc":
query += " ORDER BY m.created_at ASC"
else:
query += " ORDER BY m.line_number ASC"
else:
query += " ORDER BY m.line_number ASC"
query += " LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
result = db.execute(text(query), params)
materials = result.fetchall()
# 전체 개수 조회
count_query = """
SELECT COUNT(*) as total
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
count_params = {}
if project_id:
count_query += " AND f.project_id = :project_id"
count_params["project_id"] = project_id
if file_id:
count_query += " AND m.file_id = :file_id"
count_params["file_id"] = file_id
if search:
count_query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)"
count_params["search"] = f"%{search}%"
if item_type:
count_query += " AND m.classified_category = :item_type"
count_params["item_type"] = item_type
if material_grade:
count_query += " AND m.material_grade ILIKE :material_grade"
count_params["material_grade"] = f"%{material_grade}%"
if size_spec:
count_query += " AND m.size_spec ILIKE :size_spec"
count_params["size_spec"] = f"%{size_spec}%"
if file_filter:
count_query += " AND f.original_filename ILIKE :file_filter"
count_params["file_filter"] = f"%{file_filter}%"
count_result = db.execute(text(count_query), count_params)
total_count = count_result.fetchone()[0]
return {
"success": True,
"total_count": total_count,
"returned_count": len(materials),
"skip": skip,
"limit": limit,
"materials": [
{
"id": m.id,
"file_id": m.file_id,
"filename": m.original_filename,
"project_id": m.project_id,
"project_code": m.official_project_code,
"project_name": m.project_name,
"original_description": m.original_description,
"quantity": float(m.quantity) if m.quantity else 0,
"unit": m.unit,
"size_spec": m.size_spec,
"material_grade": m.material_grade,
"line_number": m.line_number,
"row_number": m.row_number,
"classified_category": m.classified_category,
"classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0,
"classification_details": json.loads(m.classification_details) if m.classification_details else None,
"created_at": m.created_at
}
for m in materials
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}")
@router.get("/materials/summary")
async def get_materials_summary(
project_id: Optional[int] = None,
file_id: Optional[int] = None,
db: Session = Depends(get_db)
):
"""자재 요약 통계"""
try:
query = """
SELECT
COUNT(*) as total_items,
COUNT(DISTINCT m.original_description) as unique_descriptions,
COUNT(DISTINCT m.size_spec) as unique_sizes,
COUNT(DISTINCT m.material_grade) as unique_materials,
SUM(m.quantity) as total_quantity,
AVG(m.quantity) as avg_quantity,
MIN(m.created_at) as earliest_upload,
MAX(m.created_at) as latest_upload
FROM materials m
LEFT JOIN files f ON m.file_id = f.id
WHERE 1=1
"""
params = {}
if project_id:
query += " AND f.project_id = :project_id"
params["project_id"] = project_id
if file_id:
query += " AND m.file_id = :file_id"
params["file_id"] = file_id
result = db.execute(text(query), params)
summary = result.fetchone()
return {
"success": True,
"summary": {
"total_items": summary.total_items,
"unique_descriptions": summary.unique_descriptions,
"unique_sizes": summary.unique_sizes,
"unique_materials": summary.unique_materials,
"total_quantity": float(summary.total_quantity) if summary.total_quantity else 0,
"avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0,
"earliest_upload": summary.earliest_upload,
"latest_upload": summary.latest_upload
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}")
@router.get("/materials/compare-revisions")
async def compare_revisions(
job_no: str,
filename: str,
old_revision: str,
new_revision: str,
db: Session = Depends(get_db)
):
"""
리비전 간 자재 비교
"""
try:
# 기존 리비전 자재 조회
old_materials_query = text("""
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
m.material_grade, m.classified_category, m.classification_confidence
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.job_no = :job_no
AND f.original_filename = :filename
AND f.revision = :old_revision
""")
old_result = db.execute(old_materials_query, {
"job_no": job_no,
"filename": filename,
"old_revision": old_revision
})
old_materials = old_result.fetchall()
# 새 리비전 자재 조회
new_materials_query = text("""
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
m.material_grade, m.classified_category, m.classification_confidence
FROM materials m
JOIN files f ON m.file_id = f.id
WHERE f.job_no = :job_no
AND f.original_filename = :filename
AND f.revision = :new_revision
""")
new_result = db.execute(new_materials_query, {
"job_no": job_no,
"filename": filename,
"new_revision": new_revision
})
new_materials = new_result.fetchall()
# 자재 키 생성 함수
def create_material_key(material):
return f"{material.original_description}_{material.size_spec}_{material.material_grade}"
# 기존 자재를 딕셔너리로 변환
old_materials_dict = {}
for material in old_materials:
key = create_material_key(material)
old_materials_dict[key] = {
"original_description": material.original_description,
"quantity": float(material.quantity) if material.quantity else 0,
"unit": material.unit,
"size_spec": material.size_spec,
"material_grade": material.material_grade,
"classified_category": material.classified_category,
"classification_confidence": material.classification_confidence
}
# 새 자재를 딕셔너리로 변환
new_materials_dict = {}
for material in new_materials:
key = create_material_key(material)
new_materials_dict[key] = {
"original_description": material.original_description,
"quantity": float(material.quantity) if material.quantity else 0,
"unit": material.unit,
"size_spec": material.size_spec,
"material_grade": material.material_grade,
"classified_category": material.classified_category,
"classification_confidence": material.classification_confidence
}
# 변경 사항 분석
all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys())
added_items = []
removed_items = []
changed_items = []
for key in all_keys:
old_item = old_materials_dict.get(key)
new_item = new_materials_dict.get(key)
if old_item and not new_item:
# 삭제된 항목
removed_items.append({
"key": key,
"item": old_item,
"change_type": "removed"
})
elif not old_item and new_item:
# 추가된 항목
added_items.append({
"key": key,
"item": new_item,
"change_type": "added"
})
elif old_item and new_item:
# 수량 변경 확인
if old_item["quantity"] != new_item["quantity"]:
changed_items.append({
"key": key,
"old_item": old_item,
"new_item": new_item,
"quantity_change": new_item["quantity"] - old_item["quantity"],
"change_type": "quantity_changed"
})
# 분류별 통계
def calculate_category_stats(items):
stats = {}
for item in items:
category = item.get("item", {}).get("classified_category", "OTHER")
if category not in stats:
stats[category] = {"count": 0, "total_quantity": 0}
stats[category]["count"] += 1
stats[category]["total_quantity"] += item.get("item", {}).get("quantity", 0)
return stats
added_stats = calculate_category_stats(added_items)
removed_stats = calculate_category_stats(removed_items)
changed_stats = calculate_category_stats(changed_items)
return {
"success": True,
"comparison": {
"old_revision": old_revision,
"new_revision": new_revision,
"filename": filename,
"job_no": job_no,
"summary": {
"added_count": len(added_items),
"removed_count": len(removed_items),
"changed_count": len(changed_items),
"total_changes": len(added_items) + len(removed_items) + len(changed_items)
},
"changes": {
"added": added_items,
"removed": removed_items,
"changed": changed_items
},
"category_stats": {
"added": added_stats,
"removed": removed_stats,
"changed": changed_stats
}
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}")
@router.post("/materials/update-classification-details")
async def update_classification_details(
file_id: Optional[int] = None,
db: Session = Depends(get_db)
):
"""기존 자재들의 classification_details 업데이트"""
try:
# 업데이트할 자재들 조회
query = """
SELECT id, original_description, size_spec, classified_category
FROM materials
WHERE classification_details IS NULL OR classification_details = '{}'
"""
params = {}
if file_id:
query += " AND file_id = :file_id"
params["file_id"] = file_id
query += " ORDER BY id"
result = db.execute(text(query), params)
materials = result.fetchall()
if not materials:
return {
"success": True,
"message": "업데이트할 자재가 없습니다.",
"updated_count": 0
}
updated_count = 0
for material in materials:
material_id = material.id
description = material.original_description
size_spec = material.size_spec
category = material.classified_category
print(f"자재 {material_id} 재분류 중: {description}")
# 카테고리별로 적절한 분류기 호출
classification_result = None
if category == 'PIPE':
classification_result = classify_pipe("", description, size_spec, 0.0)
elif category == 'FITTING':
classification_result = classify_fitting("", description, size_spec)
elif category == 'VALVE':
classification_result = classify_valve("", description, size_spec)
elif category == 'FLANGE':
classification_result = classify_flange("", description, size_spec)
elif category == 'BOLT':
classification_result = classify_bolt("", description, size_spec)
elif category == 'GASKET':
classification_result = classify_gasket("", description, size_spec)
elif category == 'INSTRUMENT':
classification_result = classify_instrument("", description, size_spec)
else:
# 카테고리가 없으면 모든 분류기 시도
classification_result = classify_pipe("", description, size_spec, 0.0)
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_fitting("", description, size_spec)
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_valve("", description, size_spec)
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_flange("", description, size_spec)
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_bolt("", description, size_spec)
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_gasket("", description, size_spec)
if classification_result.get("overall_confidence", 0) < 0.5:
classification_result = classify_instrument("", description, size_spec)
if classification_result:
# classification_details를 JSON으로 직렬화
classification_details = json.dumps(classification_result, ensure_ascii=False)
# DB 업데이트
update_query = text("""
UPDATE materials
SET classification_details = :classification_details,
updated_at = NOW()
WHERE id = :material_id
""")
db.execute(update_query, {
"material_id": material_id,
"classification_details": classification_details
})
updated_count += 1
print(f"자재 {material_id} 업데이트 완료")
db.commit()
return {
"success": True,
"message": f"{updated_count}개 자재의 분류 상세정보가 업데이트되었습니다.",
"updated_count": updated_count,
"total_materials": len(materials)
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"분류 상세정보 업데이트 실패: {str(e)}")