Files
TK-BOM-Project/backend/app/services/purchase_calculator.py
Hyungi Ahn 4f8e395f87
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: SWG 가스켓 전체 구성 정보 표시 개선
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
2025-08-30 14:23:01 +09:00

754 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
구매 수량 계산 서비스
- 자재별 여유율 적용
- PIPE: 절단 손실 + 6M 단위 계산
- 기타: 최소 주문 수량 적용
"""
import math
from typing import Dict, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
# 자재별 기본 여유율 (올바른 규칙으로 수정)
SAFETY_FACTORS = {
'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': 4, # 4의 배수 단위
'GASKET': 5, # 5의 배수 단위
'INSTRUMENT': 1, # 개별 주문 가능
'DEFAULT': 1
}
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
"""
PIPE 구매 수량 계산
- 각 절단마다 2mm 손실 (올바른 규칙)
- 6,000mm (6M) 단위로 올림
"""
total_bom_length = 0
cutting_count = 0
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_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': quantity,
'total_length': total_length
})
# 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙)
cutting_loss = cutting_count * 2
# 총 필요 길이 = BOM 길이 + 절단 손실
required_length = total_bom_length + cutting_loss
# 6M 단위로 올림 계산
standard_length = 6000 # 6M = 6,000mm
pipes_needed = math.ceil(required_length / standard_length) if required_length > 0 else 0
total_purchase_length = pipes_needed * standard_length
waste_length = total_purchase_length - required_length if pipes_needed > 0 else 0
return {
'bom_quantity': total_bom_length,
'cutting_count': cutting_count,
'cutting_loss': cutting_loss,
'required_length': required_length,
'pipes_count': pipes_needed,
'calculated_qty': total_purchase_length,
'waste_length': waste_length,
'utilization_rate': (required_length / total_purchase_length * 100) if total_purchase_length > 0 else 0,
'unit': 'mm',
'pipe_details': pipe_details
}
def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
safety_factor: float = None) -> Dict:
"""
일반 자재 구매 수량 계산
- 여유율 적용
- 최소 주문 수량 적용
"""
# 여유율 결정
if safety_factor is None:
safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT'])
# 1단계: 여유율 적용 (Decimal 타입 처리)
bom_quantity = float(bom_quantity) if bom_quantity else 0.0
safety_qty = bom_quantity * safety_factor
# 2단계: 최소 주문 수량 확인
min_order_qty = MINIMUM_ORDER_QTY.get(category, MINIMUM_ORDER_QTY['DEFAULT'])
# 3단계: 최소 주문 수량과 비교하여 큰 값 선택
calculated_qty = max(safety_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,
'safety_factor': safety_factor,
'safety_qty': safety_qty,
'min_order_qty': min_order_qty,
'calculated_qty': calculated_qty,
'waste_quantity': calculated_qty - bom_quantity,
'utilization_rate': (bom_quantity / calculated_qty * 100) if calculated_qty > 0 else 0
}
def generate_purchase_items_from_materials(db: Session, file_id: int,
job_no: str, revision: str) -> List[Dict]:
"""
자재 데이터로부터 구매 품목 생성
"""
# 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.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,
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
LEFT JOIN valve_details vd ON m.id = vd.material_id
LEFT JOIN flange_details fl ON m.id = fl.material_id
LEFT JOIN gasket_details gd ON m.id = gd.material_id
LEFT JOIN bolt_details bd ON m.id = bd.material_id
LEFT JOIN instrument_details id ON m.id = id.material_id
WHERE m.file_id = :file_id
ORDER BY m.classified_category, m.original_description
""")
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] = []
# 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 = []
for category, category_materials in grouped_materials.items():
if category == 'PIPE':
# PIPE는 재질+사이즈+스케줄별로 그룹핑
pipe_groups = {}
for material in category_materials:
# 그룹핑 키 생성
material_spec = material.get('pipe_material_spec') or material.get('material_grade', '')
outer_diameter = material.get('outer_diameter') or material.get('main_nom', '')
schedule = material.get('schedule', '')
group_key = f"{material_spec}|{outer_diameter}|{schedule}"
if group_key not in pipe_groups:
pipe_groups[group_key] = []
pipe_groups[group_key].append(material)
# 각 PIPE 그룹별로 구매 수량 계산
for group_key, group_materials in pipe_groups.items():
pipe_calc = calculate_pipe_purchase_quantity(group_materials)
if pipe_calc['calculated_qty'] > 0:
material_spec, outer_diameter, schedule = group_key.split('|')
# 품목 코드 생성
item_code = generate_item_code('PIPE', material_spec, outer_diameter, schedule)
# 사양 생성
spec_parts = [f"PIPE {outer_diameter}"]
if schedule: spec_parts.append(schedule)
if material_spec: spec_parts.append(material_spec)
specification = ', '.join(spec_parts)
purchase_item = {
'item_code': item_code,
'category': 'PIPE',
'specification': specification,
'material_spec': material_spec,
'size_spec': outer_diameter,
'unit': 'mm',
**pipe_calc,
'job_no': job_no,
'revision': revision,
'file_id': file_id,
'materials': group_materials
}
purchase_items.append(purchase_item)
else:
# 기타 자재들은 사양별로 그룹핑
spec_groups = generate_material_specs_for_category(category_materials, category)
for spec_key, spec_data in spec_groups.items():
if spec_data['totalQuantity'] > 0:
# 구매 수량 계산
calc_result = calculate_standard_purchase_quantity(
category,
spec_data['totalQuantity']
)
# 품목 코드 생성
item_code = generate_item_code(category, spec_data.get('material_spec', ''),
spec_data.get('size_display', ''))
purchase_item = {
'item_code': item_code,
'category': category,
'specification': spec_data.get('full_spec', spec_key),
'material_spec': spec_data.get('material_spec', ''),
'size_spec': spec_data.get('size_display', ''),
'size_fraction': spec_data.get('size_fraction', ''),
'surface_treatment': spec_data.get('surface_treatment', ''),
'special_applications': spec_data.get('special_applications', {}),
'unit': spec_data.get('unit', 'EA'),
**calc_result,
'job_no': job_no,
'revision': revision,
'file_id': file_id,
'materials': spec_data['items']
}
purchase_items.append(purchase_item)
return purchase_items
def generate_material_specs_for_category(materials: List[Dict], category: str) -> Dict:
"""카테고리별 자재 사양 그룹핑 (MaterialsPage.jsx 로직과 동일)"""
specs = {}
for material in materials:
spec_key = ''
spec_data = {}
if category == 'FITTING':
fitting_type = material.get('fitting_type', 'FITTING')
connection_method = material.get('fitting_connection', '')
# 상세 테이블의 재질 정보 우선 사용
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)}")
full_spec = ', '.join(spec_parts)
spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'FITTING',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'VALVE':
valve_type = material.get('valve_type', 'VALVE')
connection_method = material.get('valve_connection', '')
pressure_rating = material.get('valve_pressure', '')
material_spec = material.get('material_grade', '')
# 상세 테이블의 사이즈 정보 우선 사용
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}|{size_display}"
spec_data = {
'category': 'VALVE',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'FLANGE':
flange_type = material.get('flange_type', 'FLANGE')
pressure_rating = material.get('flange_pressure', '')
material_spec = material.get('material_grade', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('flange_size') or material.get('size_spec', '')
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}|{size_display}"
spec_data = {
'category': 'FLANGE',
'full_spec': full_spec,
'material_spec': material_spec,
'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('bolt_diameter') or material.get('size_spec', '')
length = material.get('bolt_length', '')
material_spec = material_standard or material.get('material_grade', '')
# 기존 분류기 방식에 따른 사이즈 표시 (분수 형태)
# 소수점을 분수로 변환 (예: 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
# 길이 정보 포함한 사이즈 표시 (예: 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)
# 사이즈+길이로 그룹핑
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': 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', '')
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}")
full_spec = ', '.join(spec_parts)
spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'GASKET',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': size_display,
'unit': 'EA'
}
elif category == 'INSTRUMENT':
instrument_type = material.get('instrument_type', 'INSTRUMENT')
material_spec = material.get('material_grade', '')
# 상세 테이블의 사이즈 정보 우선 사용
size_display = material.get('instrument_size') or material.get('size_spec', '')
full_spec = instrument_type.replace('_', ' ')
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}"
spec_data = {
'category': 'INSTRUMENT',
'full_spec': full_spec,
'material_spec': material_spec,
'size_display': size_display,
'unit': 'EA'
}
else:
# 기타 자재
material_spec = material.get('material_grade', '')
size_display = material.get('main_nom') or material.get('size_spec', '')
spec_key = f"{category}|{material_spec}|{size_display}"
spec_data = {
'category': category,
'full_spec': material_spec,
'material_spec': material_spec,
'size_display': size_display,
'unit': 'EA'
}
# 스펙별 수량 집계
if spec_key not in specs:
specs[spec_key] = {
**spec_data,
'totalQuantity': 0,
'count': 0,
'items': [],
'special_applications': {'PSV': 0, 'LT': 0, 'CK': 0} if category == 'BOLT' else None
}
specs[spec_key]['totalQuantity'] += material.get('quantity', 0)
specs[spec_key]['count'] += 1
specs[spec_key]['items'].append(material)
# 볼트의 경우 특수 용도 정보 누적
if category == 'BOLT' and 'special_applications' in locals():
for app_type, count in special_applications.items():
specs[spec_key]['special_applications'][app_type] += count
return specs
def generate_item_code(category: str, material_spec: str = '', size_spec: str = '',
schedule: str = '') -> str:
"""구매 품목 코드 생성"""
import hashlib
# 기본 접두사
prefix = f"PI-{category}"
# 재질 약어 생성
material_abbr = ''
if 'A106' in material_spec:
material_abbr = 'A106'
elif 'A333' in material_spec:
material_abbr = 'A333'
elif 'SS316' in material_spec or '316' in material_spec:
material_abbr = 'SS316'
elif 'A105' in material_spec:
material_abbr = 'A105'
elif material_spec:
material_abbr = material_spec.replace(' ', '')[:6]
# 사이즈 약어
size_abbr = size_spec.replace('"', 'IN').replace(' ', '').replace('x', 'X')[:10]
# 스케줄 (PIPE용)
schedule_abbr = schedule.replace(' ', '')[:6]
# 유니크 해시 생성 (중복 방지)
unique_str = f"{category}|{material_spec}|{size_spec}|{schedule}"
hash_suffix = hashlib.md5(unique_str.encode()).hexdigest()[:4].upper()
# 최종 코드 조합
code_parts = [prefix]
if material_abbr: code_parts.append(material_abbr)
if size_abbr: code_parts.append(size_abbr)
if schedule_abbr: code_parts.append(schedule_abbr)
code_parts.append(hash_suffix)
return '-'.join(code_parts)
def save_purchase_items_to_db(db: Session, purchase_items: List[Dict]) -> List[int]:
"""구매 품목을 데이터베이스에 저장"""
saved_ids = []
for item in purchase_items:
# 기존 품목 확인 (동일 사양이 있는지)
existing_query = text("""
SELECT id FROM purchase_items
WHERE job_no = :job_no AND revision = :revision AND item_code = :item_code
""")
existing = db.execute(existing_query, {
'job_no': item['job_no'],
'revision': item['revision'],
'item_code': item['item_code']
}).fetchone()
if existing:
# 기존 품목 업데이트
update_query = text("""
UPDATE purchase_items SET
bom_quantity = :bom_quantity,
calculated_qty = :calculated_qty,
safety_factor = :safety_factor,
cutting_loss = :cutting_loss,
pipes_count = :pipes_count,
waste_length = :waste_length,
detailed_spec = :detailed_spec,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
db.execute(update_query, {
'id': existing.id,
'bom_quantity': item['bom_quantity'],
'calculated_qty': item['calculated_qty'],
'safety_factor': item.get('safety_factor', 1.0),
'cutting_loss': item.get('cutting_loss', 0),
'pipes_count': item.get('pipes_count'),
'waste_length': item.get('waste_length'),
'detailed_spec': item.get('detailed_spec', '{}')
})
saved_ids.append(existing.id)
else:
# 새 품목 생성
insert_query = text("""
INSERT INTO purchase_items (
item_code, category, specification, material_spec, size_spec, unit,
bom_quantity, safety_factor, minimum_order_qty, calculated_qty,
cutting_loss, standard_length, pipes_count, waste_length,
job_no, revision, file_id, is_active, created_by
) VALUES (
:item_code, :category, :specification, :material_spec, :size_spec, :unit,
:bom_quantity, :safety_factor, :minimum_order_qty, :calculated_qty,
:cutting_loss, :standard_length, :pipes_count, :waste_length,
:job_no, :revision, :file_id, :is_active, :created_by
) RETURNING id
""")
result = db.execute(insert_query, {
'item_code': item['item_code'],
'category': item['category'],
'specification': item['specification'],
'material_spec': item['material_spec'],
'size_spec': item['size_spec'],
'unit': item['unit'],
'bom_quantity': item['bom_quantity'],
'safety_factor': item.get('safety_factor', 1.0),
'minimum_order_qty': item.get('min_order_qty', 0),
'calculated_qty': item['calculated_qty'],
'cutting_loss': item.get('cutting_loss', 0),
'standard_length': item.get('standard_length', 6000 if item['category'] == 'PIPE' else None),
'pipes_count': item.get('pipes_count'),
'waste_length': item.get('waste_length'),
'job_no': item['job_no'],
'revision': item['revision'],
'file_id': item['file_id'],
'is_active': True,
'created_by': 'system'
})
result_row = result.fetchone()
new_id = result_row[0] if result_row else None
saved_ids.append(new_id)
# 개별 자재와 구매 품목 연결
for material in item['materials']:
mapping_query = text("""
INSERT INTO material_purchase_mapping (material_id, purchase_item_id)
VALUES (:material_id, :purchase_item_id)
ON CONFLICT (material_id, purchase_item_id) DO NOTHING
""")
db.execute(mapping_query, {
'material_id': material['id'],
'purchase_item_id': new_id
})
db.commit()
return saved_ids