feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
Hyungi Ahn
2025-08-30 14:23:01 +09:00
parent 78d90c7a8f
commit 4f8e395f87
84 changed files with 16297 additions and 2161 deletions

View File

@@ -0,0 +1,333 @@
"""
파일 관리 비즈니스 로직
API 레이어에서 분리된 핵심 비즈니스 로직
"""
from typing import List, Dict, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
from fastapi import HTTPException
from ..utils.logger import get_logger
from ..utils.cache_manager import tkmp_cache
from ..utils.transaction_manager import TransactionManager, async_transactional
from ..schemas.response_models import FileInfo
from ..config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class FileService:
"""파일 관리 서비스"""
def __init__(self, db: Session):
self.db = db
self.transaction_manager = TransactionManager(db)
async def get_files(
self,
job_no: Optional[str] = None,
show_history: bool = False,
use_cache: bool = True
) -> Tuple[List[Dict], bool]:
"""
파일 목록 조회
Args:
job_no: 작업 번호
show_history: 이력 표시 여부
use_cache: 캐시 사용 여부
Returns:
Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부)
"""
try:
logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}")
# 캐시 확인
if use_cache:
cached_files = tkmp_cache.get_file_list(job_no, show_history)
if cached_files:
logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일")
return cached_files, True
# 데이터베이스에서 조회
query, params = self._build_file_query(job_no, show_history)
result = self.db.execute(text(query), params)
files = result.fetchall()
# 결과 변환
file_list = self._convert_files_to_dict(files)
# 캐시에 저장
if use_cache:
tkmp_cache.set_file_list(file_list, job_no, show_history)
logger.debug("파일 목록 캐시 저장 완료")
logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환")
return file_list, False
except Exception as e:
logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]:
"""파일 조회 쿼리 생성"""
if show_history:
# 전체 이력 표시
query = "SELECT * FROM files"
params = {}
if job_no:
query += " WHERE job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY original_filename, revision DESC"
else:
# 최신 리비전만 표시
if job_no:
query = """
SELECT f1.* FROM files f1
INNER JOIN (
SELECT original_filename, MAX(revision) as max_revision
FROM files
WHERE job_no = :job_no
GROUP BY original_filename
) f2 ON f1.original_filename = f2.original_filename
AND f1.revision = f2.max_revision
WHERE f1.job_no = :job_no
ORDER BY f1.upload_date DESC
"""
params = {"job_no": job_no}
else:
query = "SELECT * FROM files ORDER BY upload_date DESC"
params = {}
return query, params
def _convert_files_to_dict(self, files) -> List[Dict]:
"""파일 결과를 딕셔너리로 변환"""
return [
{
"id": f.id,
"filename": f.original_filename,
"original_filename": f.original_filename,
"name": f.original_filename,
"job_no": f.job_no,
"bom_name": f.bom_name or f.original_filename,
"revision": f.revision or "Rev.0",
"parsed_count": f.parsed_count or 0,
"bom_type": f.file_type or "unknown",
"status": "active" if f.is_active else "inactive",
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"description": f"파일: {f.original_filename}"
}
for f in files
]
async def delete_file(self, file_id: int) -> Dict:
"""
파일 삭제 (트랜잭션 관리 적용)
Args:
file_id: 파일 ID
Returns:
Dict: 삭제 결과
"""
try:
logger.info(f"파일 삭제 요청 - file_id: {file_id}")
# 트랜잭션 내에서 삭제 작업 수행
with self.transaction_manager.transaction():
# 파일 정보 조회
file_info = self._get_file_info(file_id)
if not file_info:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
# 관련 데이터 삭제 (세이브포인트 사용)
with self.transaction_manager.savepoint("delete_related_data"):
self._delete_related_data(file_id)
# 파일 삭제
with self.transaction_manager.savepoint("delete_file_record"):
self._delete_file_record(file_id)
# 트랜잭션이 성공적으로 완료되면 캐시 무효화
self._invalidate_file_cache(file_id, file_info)
logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}")
return {
"success": True,
"message": "파일과 관련 데이터가 삭제되었습니다",
"deleted_file_id": file_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
def _get_file_info(self, file_id: int):
"""파일 정보 조회"""
file_query = text("SELECT * FROM files WHERE id = :file_id")
file_result = self.db.execute(file_query, {"file_id": file_id})
return file_result.fetchone()
def _delete_related_data(self, file_id: int):
"""관련 데이터 삭제"""
# 상세 테이블 목록
detail_tables = [
'pipe_details', 'fitting_details', 'valve_details',
'flange_details', 'bolt_details', 'gasket_details',
'instrument_details'
]
# 해당 파일의 materials ID 조회
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id})
material_ids = [row[0] for row in material_ids_result]
if material_ids:
logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재")
# 각 상세 테이블에서 관련 데이터 삭제
for table in detail_tables:
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
self.db.execute(delete_detail_query, {"material_ids": material_ids})
# materials 테이블 데이터 삭제
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
self.db.execute(materials_query, {"file_id": file_id})
def _delete_file_record(self, file_id: int):
"""파일 레코드 삭제"""
delete_query = text("DELETE FROM files WHERE id = :file_id")
self.db.execute(delete_query, {"file_id": file_id})
def _invalidate_file_cache(self, file_id: int, file_info):
"""파일 관련 캐시 무효화"""
tkmp_cache.invalidate_file_cache(file_id)
if hasattr(file_info, 'job_no') and file_info.job_no:
tkmp_cache.invalidate_job_cache(file_info.job_no)
async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict:
"""
파일 통계 조회
Args:
job_no: 작업 번호
Returns:
Dict: 파일 통계
"""
try:
# 캐시 확인
if job_no:
cached_stats = tkmp_cache.get_statistics(job_no, "file_stats")
if cached_stats:
return cached_stats
# 통계 쿼리 실행
stats_query = self._build_statistics_query(job_no)
result = self.db.execute(text(stats_query["query"]), stats_query["params"])
stats_data = result.fetchall()
# 통계 데이터 변환
statistics = self._convert_statistics_data(stats_data)
# 캐시에 저장
if job_no:
tkmp_cache.set_statistics(statistics, job_no, "file_stats")
return statistics
except Exception as e:
logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}")
def _build_statistics_query(self, job_no: Optional[str]) -> Dict:
"""통계 쿼리 생성"""
base_query = """
SELECT
COUNT(*) as total_files,
COUNT(DISTINCT job_no) as total_jobs,
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files,
SUM(file_size) as total_size,
AVG(file_size) as avg_size,
MAX(upload_date) as latest_upload,
MIN(upload_date) as earliest_upload
FROM files
"""
params = {}
if job_no:
base_query += " WHERE job_no = :job_no"
params["job_no"] = job_no
return {"query": base_query, "params": params}
def _convert_statistics_data(self, stats_data) -> Dict:
"""통계 데이터 변환"""
if not stats_data:
return {
"total_files": 0,
"total_jobs": 0,
"active_files": 0,
"total_size": 0,
"avg_size": 0,
"latest_upload": None,
"earliest_upload": None
}
stats = stats_data[0]
return {
"total_files": stats.total_files or 0,
"total_jobs": stats.total_jobs or 0,
"active_files": stats.active_files or 0,
"total_size": stats.total_size or 0,
"total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2),
"avg_size": stats.avg_size or 0,
"avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2),
"latest_upload": stats.latest_upload,
"earliest_upload": stats.earliest_upload
}
async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool:
"""
파일 접근 권한 검증
Args:
file_id: 파일 ID
user_id: 사용자 ID
Returns:
bool: 접근 권한 여부
"""
try:
# 파일 존재 여부 확인
file_info = self._get_file_info(file_id)
if not file_info:
return False
# 파일이 활성 상태인지 확인
if not file_info.is_active:
logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}")
return False
# 추가 권한 검증 로직 (필요시 구현)
# 예: 사용자별 프로젝트 접근 권한 등
return True
except Exception as e:
logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True)
return False
def get_file_service(db: Session) -> FileService:
"""파일 서비스 팩토리 함수"""
return FileService(db)

View File

@@ -10,26 +10,26 @@ from typing import Dict, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
# 자재별 기본 여유율
# 자재별 기본 여유율 (올바른 규칙으로 수정)
SAFETY_FACTORS = {
'PIPE': 1.15, # 15% 추가 (절단 손실)
'FITTING': 1.10, # 10% 추가 (연결 오차)
'VALVE': 1.50, # 50% 추가 (예비품)
'FLANGE': 1.10, # 10% 추가
'BOLT': 1.20, # 20% 추가 (분실율)
'GASKET': 1.25, # 25% 추가 (교체주기)
'INSTRUMENT': 1.00, # 0% 추가 (정확한 수량)
'DEFAULT': 1.10 # 기본 10% 추가
'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산)
'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로)
'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로)
'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로)
'BOLT': 1.05, # 5% 추가 (분실율)
'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리)
'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로)
'DEFAULT': 1.00 # 기본 0% 추가
}
# 최소 주문 수량 (자재별)
# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정
MINIMUM_ORDER_QTY = {
'PIPE': 6000, # 6M 단위
'FITTING': 1, # 개별 주문 가능
'VALVE': 1, # 개별 주문 가능
'FLANGE': 1, # 개별 주문 가능
'BOLT': 50, # 박스 단위 (50개)
'GASKET': 10, # 세트 단위
'BOLT': 4, # 4의 배수 단위
'GASKET': 5, # 5의 배수 단위
'INSTRUMENT': 1, # 개별 주문 가능
'DEFAULT': 1
}
@@ -37,7 +37,7 @@ MINIMUM_ORDER_QTY = {
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
"""
PIPE 구매 수량 계산
- 각 절단마다 3mm 손실
- 각 절단마다 2mm 손실 (올바른 규칙)
- 6,000mm (6M) 단위로 올림
"""
total_bom_length = 0
@@ -45,19 +45,23 @@ def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
pipe_details = []
for material in materials:
# 길이 정보 추출
# 길이 정보 추출 (Decimal 타입 처리)
length_mm = float(material.get('length_mm', 0) or 0)
quantity = float(material.get('quantity', 1) or 1)
if length_mm > 0:
total_bom_length += length_mm
cutting_count += 1
total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량
total_bom_length += total_length
cutting_count += quantity # 절단 횟수 = 수량
pipe_details.append({
'description': material.get('original_description', ''),
'length_mm': length_mm,
'quantity': material.get('quantity', 1)
'quantity': quantity,
'total_length': total_length
})
# 절단 손실 계산 (각 절단마다 3mm)
cutting_loss = cutting_count * 3
# 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙)
cutting_loss = cutting_count * 2
# 총 필요 길이 = BOM 길이 + 절단 손실
required_length = total_bom_length + cutting_loss
@@ -92,7 +96,8 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
if safety_factor is None:
safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT'])
# 1단계: 여유율 적용
# 1단계: 여유율 적용 (Decimal 타입 처리)
bom_quantity = float(bom_quantity) if bom_quantity else 0.0
safety_qty = bom_quantity * safety_factor
# 2단계: 최소 주문 수량 확인
@@ -101,9 +106,13 @@ def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
# 3단계: 최소 주문 수량과 비교하여 큰 값 선택
calculated_qty = max(safety_qty, min_order_qty)
# 4단계: 특별 처리 (BOLT는 박스 단위로 올림)
if category == 'BOLT' and calculated_qty > min_order_qty:
calculated_qty = math.ceil(calculated_qty / min_order_qty) * min_order_qty
# 4단계: 특별 처리 (올바른 규칙 적용)
if category == 'BOLT':
# BOLT: 5% 여유율 후 4의 배수로 올림
calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty
elif category == 'GASKET':
# GASKET: 5의 배수로 올림 (여유율 없음)
calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty
return {
'bom_quantity': bom_quantity,
@@ -120,16 +129,19 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
"""
자재 데이터로부터 구매 품목 생성
"""
# 1. 파일의 모든 자재 조회
# 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함)
materials_query = text("""
SELECT m.*,
pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec,
fd.fitting_type, fd.connection_method as fitting_connection,
fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size,
fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade,
vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure,
fl.flange_type, fl.pressure_rating as flange_pressure,
gd.gasket_type, gd.material_type as gasket_material,
bd.bolt_type, bd.material_standard, bd.diameter,
id.instrument_type
vd.size_inches as valve_size,
fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size,
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material,
gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length,
id.instrument_type, id.connection_size as instrument_size
FROM materials m
LEFT JOIN pipe_details pd ON m.id = pd.material_id
LEFT JOIN fitting_details fd ON m.id = fd.material_id
@@ -144,13 +156,65 @@ def generate_purchase_items_from_materials(db: Session, file_id: int,
materials = db.execute(materials_query, {"file_id": file_id}).fetchall()
# 2. 카테고리별로 그룹핑
grouped_materials = {}
for material in materials:
category = material.classified_category or 'OTHER'
if category not in grouped_materials:
grouped_materials[category] = []
grouped_materials[category].append(dict(material))
# Row 객체를 딕셔너리로 안전하게 변환
material_dict = {
'id': material.id,
'file_id': material.file_id,
'original_description': material.original_description,
'quantity': material.quantity,
'unit': material.unit,
'size_spec': material.size_spec,
'material_grade': material.material_grade,
'classified_category': material.classified_category,
'line_number': material.line_number,
# PIPE 상세 정보
'length_mm': getattr(material, 'length_mm', None),
'outer_diameter': getattr(material, 'outer_diameter', None),
'schedule': getattr(material, 'schedule', None),
'pipe_material_spec': getattr(material, 'pipe_material_spec', None),
# FITTING 상세 정보
'fitting_type': getattr(material, 'fitting_type', None),
'fitting_connection': getattr(material, 'fitting_connection', None),
'fitting_main_size': getattr(material, 'fitting_main_size', None),
'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None),
'fitting_material_grade': getattr(material, 'fitting_material_grade', None),
# VALVE 상세 정보
'valve_type': getattr(material, 'valve_type', None),
'valve_connection': getattr(material, 'valve_connection', None),
'valve_pressure': getattr(material, 'valve_pressure', None),
'valve_size': getattr(material, 'valve_size', None),
# FLANGE 상세 정보
'flange_type': getattr(material, 'flange_type', None),
'flange_pressure': getattr(material, 'flange_pressure', None),
'flange_size': getattr(material, 'flange_size', None),
# GASKET 상세 정보
'gasket_type': getattr(material, 'gasket_type', None),
'gasket_subtype': getattr(material, 'gasket_subtype', None),
'gasket_material': getattr(material, 'gasket_material', None),
'filler_material': getattr(material, 'filler_material', None),
'gasket_size': getattr(material, 'gasket_size', None),
'gasket_pressure': getattr(material, 'gasket_pressure', None),
'gasket_thickness': getattr(material, 'gasket_thickness', None),
# BOLT 상세 정보
'bolt_type': getattr(material, 'bolt_type', None),
'material_standard': getattr(material, 'material_standard', None),
'bolt_diameter': getattr(material, 'bolt_diameter', None),
'bolt_length': getattr(material, 'bolt_length', None),
# INSTRUMENT 상세 정보
'instrument_type': getattr(material, 'instrument_type', None),
'instrument_size': getattr(material, 'instrument_size', None)
}
grouped_materials[category].append(material_dict)
# 3. 각 카테고리별로 구매 품목 생성
purchase_items = []
@@ -249,13 +313,41 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
if category == 'FITTING':
fitting_type = material.get('fitting_type', 'FITTING')
connection_method = material.get('fitting_connection', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
red_nom = material.get('red_nom', '')
size_display = f"{main_nom} x {red_nom}" if red_nom else main_nom
# 상세 테이블의 재질 정보 우선 사용
material_spec = material.get('fitting_material_grade') or material.get('material_grade', '')
# 상세 테이블의 사이즈 정보 사용
main_size = material.get('fitting_main_size', '')
reduced_size = material.get('fitting_reduced_size', '')
# 사이즈 표시 생성 (축소형인 경우 main x reduced 형태)
if main_size and reduced_size and main_size != reduced_size:
size_display = f"{main_size} x {reduced_size}"
else:
size_display = main_size or material.get('size_spec', '')
# 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급
# 예: "ELBOW, SOCKET WELD, 3000LB"
fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING'
spec_parts = [fitting_display]
# 연결방식 추가
if connection_method and connection_method != 'UNKNOWN':
connection_display = connection_method.replace('_', ' ')
spec_parts.append(connection_display)
# 압력등급 추출 (description에서)
description = material.get('original_description', '').upper()
import re
pressure_match = re.search(r'(\d+)LB', description)
if pressure_match:
spec_parts.append(f"{pressure_match.group(1)}LB")
# 스케줄 정보 추출 (니플 등에 중요)
schedule_match = re.search(r'SCH\s*(\d+)', description)
if schedule_match:
spec_parts.append(f"SCH {schedule_match.group(1)}")
spec_parts = [fitting_type]
if connection_method: spec_parts.append(connection_method)
full_spec = ', '.join(spec_parts)
spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}"
@@ -272,19 +364,20 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
connection_method = material.get('valve_connection', '')
pressure_rating = material.get('valve_pressure', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('valve_size') or material.get('size_spec', '')
spec_parts = [valve_type.replace('_', ' ')]
if connection_method: spec_parts.append(connection_method.replace('_', ' '))
if pressure_rating: spec_parts.append(pressure_rating)
full_spec = ', '.join(spec_parts)
spec_key = f"VALVE|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'VALVE',
'category': 'VALVE',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
@@ -292,102 +385,198 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) -
flange_type = material.get('flange_type', 'FLANGE')
pressure_rating = material.get('flange_pressure', '')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('flange_size') or material.get('size_spec', '')
spec_parts = [flange_type]
spec_parts = [flange_type.replace('_', ' ')]
if pressure_rating: spec_parts.append(pressure_rating)
full_spec = ', '.join(spec_parts)
spec_key = f"FLANGE|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'FLANGE',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'BOLT':
bolt_type = material.get('bolt_type', 'BOLT')
material_standard = material.get('material_standard', '')
diameter = material.get('diameter', material.get('main_nom', ''))
# 상세 테이블의 사이즈 정보 우선 사용
diameter = material.get('bolt_diameter') or material.get('size_spec', '')
length = material.get('bolt_length', '')
material_spec = material_standard or material.get('material_grade', '')
# 분수 사이즈 정보 추출 (새로 추가된 분류기 정보)
size_fraction = material.get('size_fraction', diameter)
surface_treatment = material.get('surface_treatment', '')
# 기존 분류기 방식에 따른 사이즈 표시 (분수 형태)
# 소수점을 분수로 변환 (예: 0.625 -> 5/8)
size_display = diameter
if diameter and '.' in diameter:
try:
decimal_val = float(diameter)
# 일반적인 볼트 사이즈 분수 변환
fraction_map = {
0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"",
0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"",
0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"",
0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\""
}
if decimal_val in fraction_map:
size_display = fraction_map[decimal_val]
except:
pass
# 특수 용도 정보 추출 (PSV, LT, CK)
special_applications = {
'PSV': 0,
'LT': 0,
'CK': 0
}
# 설명에서 특수 용도 키워드 확인 (간단한 방법)
description = material.get('original_description', '').upper()
if 'PSV' in description or 'PRESSURE SAFETY VALVE' in description:
special_applications['PSV'] = material.get('quantity', 0)
if any(keyword in description for keyword in ['LT', 'LOW TEMP', '저온용']):
special_applications['LT'] = material.get('quantity', 0)
if 'CK' in description or 'CHECK VALVE' in description:
special_applications['CK'] = material.get('quantity', 0)
# 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L)
if length:
# 길이에서 숫자만 추출
import re
length_match = re.search(r'(\d+(?:\.\d+)?)', str(length))
if length_match:
length_num = length_match.group(1)
size_display_with_length = f"{size_display} x {length_num}L"
else:
size_display_with_length = f"{size_display} x {length}"
else:
size_display_with_length = size_display
spec_parts = [bolt_type.replace('_', ' ')]
if material_standard: spec_parts.append(material_standard)
full_spec = ', '.join(spec_parts)
# 특수 용도와 관계없이 사이즈+길이로 합산 (구매는 동일하므로)
# 길이 정보가 있으면 포함
length_info = material.get('length', '')
if length_info:
diameter_key = f"{diameter}L{length_info}"
else:
diameter_key = diameter
spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter_key}"
# 사이즈+길이로 그룹핑
spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}"
spec_data = {
'category': 'BOLT',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': diameter,
'size_fraction': size_fraction,
'surface_treatment': surface_treatment,
'size_display': size_display_with_length,
'unit': 'EA'
}
elif category == 'GASKET':
# 상세 테이블 정보 우선 사용
gasket_type = material.get('gasket_type', 'GASKET')
gasket_subtype = material.get('gasket_subtype', '')
gasket_material = material.get('gasket_material', '')
material_spec = gasket_material or material.get('material_grade', '')
main_nom = material.get('main_nom', '')
filler_material = material.get('filler_material', '')
gasket_pressure = material.get('gasket_pressure', '')
gasket_thickness = material.get('gasket_thickness', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('gasket_size') or material.get('size_spec', '')
# 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질
# 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE"
spec_parts = [gasket_type.replace('_', ' ')]
# 서브타입 추가 (있는 경우)
if gasket_subtype and gasket_subtype != gasket_type:
spec_parts.append(gasket_subtype.replace('_', ' '))
# 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출
if gasket_pressure:
spec_parts.append(gasket_pressure)
else:
description = material.get('original_description', '').upper()
import re
pressure_match = re.search(r'(\d+)LB', description)
if pressure_match:
spec_parts.append(f"{pressure_match.group(1)}LB")
# 재질 정보 구성 (상세 테이블 정보 활용)
material_spec_parts = []
# SWG의 경우 메탈 + 필러 형태로 구성
if gasket_type == 'SPIRAL_WOUND':
# 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱
description = material.get('original_description', '').upper()
# SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보)
import re
material_spec = None
# H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
if hfio_material_match:
part1 = hfio_material_match.group(1) # SS304
part2 = hfio_material_match.group(2) # GRAPHITE
part3 = hfio_material_match.group(3) # CS
part4 = hfio_material_match.group(4) # CS
material_spec = f"{part1}/{part2}/{part3}/{part4}"
else:
# 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
if simple_material_match:
part1 = simple_material_match.group(1) # SS304
part2 = simple_material_match.group(2) # GRAPHITE
part3 = simple_material_match.group(3) # CS
part4 = simple_material_match.group(4) # CS
material_spec = f"{part1}/{part2}/{part3}/{part4}"
if not material_spec:
# 상세 테이블 정보 사용
if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분
material_spec_parts.append(gasket_material)
elif gasket_material == 'GRAPHITE':
# GRAPHITE만 있는 경우 description에서 메탈 부분 찾기
metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description)
if metal_match:
material_spec_parts.append(metal_match.group(1))
if filler_material and filler_material != gasket_material: # 필러 부분
material_spec_parts.append(filler_material)
elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts:
material_spec_parts.append('GRAPHITE')
if material_spec_parts:
material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE
else:
material_spec = material.get('material_grade', '')
else:
# 일반 가스켓의 경우
if gasket_material:
material_spec_parts.append(gasket_material)
if filler_material and filler_material != gasket_material:
material_spec_parts.append(filler_material)
if material_spec_parts:
material_spec = ', '.join(material_spec_parts)
else:
material_spec = material.get('material_grade', '')
if material_spec:
spec_parts.append(material_spec)
# 두께 정보 추가 (있는 경우)
if gasket_thickness:
spec_parts.append(f"THK {gasket_thickness}")
spec_parts = [gasket_type]
if gasket_material: spec_parts.append(gasket_material)
full_spec = ', '.join(spec_parts)
spec_key = f"GASKET|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'GASKET',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'INSTRUMENT':
instrument_type = material.get('instrument_type', 'INSTRUMENT')
material_spec = material.get('material_grade', '')
main_nom = material.get('main_nom', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('instrument_size') or material.get('size_spec', '')
full_spec = instrument_type.replace('_', ' ')
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{main_nom}"
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'INSTRUMENT',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': main_nom,
'size_display': size_display,
'unit': 'EA'
}