feat: 서포트 카테고리 전면 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 서포트 카테고리 UI 개선: 좌우 스크롤, 헤더/본문 동기화, 가운데 정렬
- 동일 항목 합산 기능 구현 (Type + Size + Grade 기준)
- 헤더 구조 변경: 압력/스케줄 제거, 구매수량 단일화, User Requirements 추가
- 우레탄 블럭슈 두께 정보(40t, 27t) Material Grade에 포함
- 서포트 수량 계산 수정: 취합된 숫자 그대로 표시 (4의 배수 계산 제거)
- 서포트 분류 로직 개선: CLAMP, U-BOLT, URETHANE BLOCK SHOE 등 정확한 분류
- 백엔드 서포트 분류기에 User Requirements 추출 기능 추가
- 엑셀 내보내기에 서포트 카테고리 처리 로직 추가
This commit is contained in:
hyungi
2025-10-17 07:59:35 +09:00
parent a27213e0e5
commit 6b6360ecd5
19 changed files with 2452 additions and 278 deletions

View File

@@ -1,13 +1,14 @@
""" """
구매신청 관리 API 구매신청 관리 API
""" """
from fastapi import APIRouter, Depends, HTTPException, status, Body from fastapi import APIRouter, Depends, HTTPException, status, Body, File, UploadFile, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional, List, Dict from typing import Optional, List, Dict
from datetime import datetime from datetime import datetime
from pathlib import Path
import os import os
import json import json
@@ -20,7 +21,7 @@ logger = get_logger(__name__)
router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"]) router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"])
# 엑셀 파일 저장 경로 # 엑셀 파일 저장 경로
EXCEL_DIR = "exports" EXCEL_DIR = "uploads/excel_exports"
os.makedirs(EXCEL_DIR, exist_ok=True) os.makedirs(EXCEL_DIR, exist_ok=True)
class PurchaseRequestCreate(BaseModel): class PurchaseRequestCreate(BaseModel):
@@ -705,3 +706,76 @@ def create_excel_file(materials_data: List[Dict], file_path: str, request_no: st
# 파일 저장 # 파일 저장
wb.save(file_path) wb.save(file_path)
@router.post("/upload-excel")
async def upload_request_excel(
excel_file: UploadFile = File(...),
request_id: int = Form(...),
category: str = Form(...),
# current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
구매신청에 대한 엑셀 파일 업로드 (BOM에서 생성한 원본 파일)
"""
try:
# 구매신청 정보 조회
query = text("""
SELECT request_no, job_no
FROM purchase_requests
WHERE request_id = :request_id
""")
result = db.execute(query, {"request_id": request_id}).fetchone()
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="구매신청을 찾을 수 없습니다"
)
# 엑셀 저장 디렉토리 생성
excel_dir = Path("uploads/excel_exports")
excel_dir.mkdir(parents=True, exist_ok=True)
# 파일명 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_filename = f"{result.job_no}_{result.request_no}_{timestamp}.xlsx"
file_path = excel_dir / safe_filename
# 파일 저장
content = await excel_file.read()
with open(file_path, "wb") as f:
f.write(content)
# 구매신청 테이블에 엑셀 파일 경로 업데이트
update_query = text("""
UPDATE purchase_requests
SET excel_file_path = :excel_file_path
WHERE request_id = :request_id
""")
db.execute(update_query, {
"excel_file_path": safe_filename,
"request_id": request_id
})
db.commit()
logger.info(f"엑셀 파일 업로드 완료: {safe_filename}")
return {
"success": True,
"message": "엑셀 파일이 성공적으로 업로드되었습니다",
"file_path": safe_filename
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to upload excel file: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"엑셀 파일 업로드 실패: {str(e)}"
)

View File

@@ -706,7 +706,8 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option
"nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom), "nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom),
"length": dimensions_result.get('length', ''), "length": dimensions_result.get('length', ''),
"diameter": dimensions_result.get('diameter', ''), "diameter": dimensions_result.get('diameter', ''),
"dimension_description": dimensions_result.get('dimension_description', '') "dimension_description": dimensions_result.get('dimension_description', ''),
"bolts_per_flange": dimensions_result.get('bolts_per_flange', 1)
}, },
"grade_strength": { "grade_strength": {
@@ -966,12 +967,19 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
except: except:
nominal_size_fraction = actual_bolt_size nominal_size_fraction = actual_bolt_size
# 플랜지당 볼트 세트 수 추출 (예: (8), (4))
bolts_per_flange = 1 # 기본값
flange_bolt_pattern = re.search(r'\((\d+)\)', description)
if flange_bolt_pattern:
bolts_per_flange = int(flange_bolt_pattern.group(1))
dimensions = { dimensions = {
"nominal_size": actual_bolt_size, # 실제 볼트 사이즈 "nominal_size": actual_bolt_size, # 실제 볼트 사이즈
"nominal_size_fraction": nominal_size_fraction, # 분수 변환값 "nominal_size_fraction": nominal_size_fraction, # 분수 변환값
"length": "", "length": "",
"diameter": "", "diameter": "",
"dimension_description": nominal_size_fraction # 분수로 표시 "dimension_description": nominal_size_fraction, # 분수로 표시
"bolts_per_flange": bolts_per_flange # 플랜지당 볼트 세트 수
} }
# 길이 정보 추출 (개선된 패턴) # 길이 정보 추출 (개선된 패턴)
@@ -984,6 +992,8 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태 r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태
r',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용) r',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용)
r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용) r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용)
r'PSV\s+(\d+(?:\.\d+)?)', # PSV 140 형태 (PSV 볼트 전용)
r'(\d+(?:\.\d+)?)\s+PSV', # 140 PSV 형태 (PSV 볼트 전용)
r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독) r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독)
r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독) r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독)
] ]

View File

@@ -182,6 +182,14 @@ def classify_flange(dat_file: str, description: str, main_nom: str,
dat_upper = dat_file.upper() dat_upper = dat_file.upper()
# 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행) # 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행)
# 사이트 글라스와 스트레이너는 밸브로 분류되어야 함
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
return {
"category": "VALVE",
"overall_confidence": 1.0,
"reason": "SIGHT GLASS 또는 STRAINER는 밸브로 분류"
}
flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER'] flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords) has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)

View File

@@ -10,7 +10,7 @@ from .fitting_classifier import classify_fitting
# Level 1: 명확한 타입 키워드 (최우선) # Level 1: 명확한 타입 키워드 (최우선)
LEVEL1_TYPE_KEYWORDS = { LEVEL1_TYPE_KEYWORDS = {
"BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"], "BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"],
"VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "밸브", "게이트", "", "글로브", "체크", "버터플라이", "니들", "릴리프"], "VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "", "글로브", "체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너"],
"FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"], "FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"],
"PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"], "PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"],
"FITTING": ["SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET", "SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET", "OLET", "ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG", "엘보", "", "리듀서", "", "니플", "커플링", "플러그", "CONC", "ECC"], "FITTING": ["SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET", "SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET", "OLET", "ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG", "엘보", "", "리듀서", "", "니플", "커플링", "플러그", "CONC", "ECC"],
@@ -110,6 +110,17 @@ def classify_material_integrated(description: str, main_nom: str = "",
"reason": "스페셜 키워드 발견" "reason": "스페셜 키워드 발견"
} }
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
return {
"category": "VALVE",
"confidence": 1.0,
"evidence": ["VALVE_SPECIAL_KEYWORD"],
"classification_level": "LEVEL0_VALVE",
"reason": "SIGHT GLASS 또는 STRAINER 키워드 발견"
}
# SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저) # SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저)
# U-BOLT, CLAMP, URETHANE BLOCK 등 # U-BOLT, CLAMP, URETHANE BLOCK 등
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or

View File

@@ -108,7 +108,22 @@ def classify_support(dat_file: str, description: str, main_nom: str,
# 4. 사이즈 정보 추출 # 4. 사이즈 정보 추출
size_result = extract_support_size(description, main_nom) size_result = extract_support_size(description, main_nom)
# 5. 최종 결과 조합 # 5. 사용자 요구사항 추출
user_requirements = extract_support_user_requirements(description)
# 6. 우레탄 블럭슈 두께 정보 추출 및 Material Grade 보강
enhanced_material_grade = material_result.get('grade', 'UNKNOWN')
if support_type_result.get("support_type") == "URETHANE_BLOCK":
# 두께 정보 추출 (40t, 27t 등)
thickness_match = re.search(r'(\d+)\s*[tT](?![oO])', description.upper())
if thickness_match:
thickness = f"{thickness_match.group(1)}t"
if enhanced_material_grade == 'UNKNOWN' or not enhanced_material_grade:
enhanced_material_grade = thickness
elif thickness not in enhanced_material_grade:
enhanced_material_grade = f"{enhanced_material_grade} {thickness}"
# 7. 최종 결과 조합
return { return {
"category": "SUPPORT", "category": "SUPPORT",
@@ -118,10 +133,10 @@ def classify_support(dat_file: str, description: str, main_nom: str,
"load_rating": load_result.get("load_rating", ""), "load_rating": load_result.get("load_rating", ""),
"load_capacity": load_result.get("capacity", ""), "load_capacity": load_result.get("capacity", ""),
# 재질 정보 (공통 모듈) # 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함
"material": { "material": {
"standard": material_result.get('standard', 'UNKNOWN'), "standard": material_result.get('standard', 'UNKNOWN'),
"grade": material_result.get('grade', 'UNKNOWN'), "grade": enhanced_material_grade,
"material_type": material_result.get('material_type', 'UNKNOWN'), "material_type": material_result.get('material_type', 'UNKNOWN'),
"confidence": material_result.get('confidence', 0.0) "confidence": material_result.get('confidence', 0.0)
}, },
@@ -129,6 +144,9 @@ def classify_support(dat_file: str, description: str, main_nom: str,
# 사이즈 정보 # 사이즈 정보
"size_info": size_result, "size_info": size_result,
# 사용자 요구사항
"user_requirements": user_requirements,
# 전체 신뢰도 # 전체 신뢰도
"overall_confidence": calculate_support_confidence({ "overall_confidence": calculate_support_confidence({
"type": support_type_result.get('confidence', 0), "type": support_type_result.get('confidence', 0),
@@ -183,6 +201,34 @@ def classify_support_type(dat_file: str, description: str) -> Dict:
"evidence": ["NO_SUPPORT_TYPE_FOUND"] "evidence": ["NO_SUPPORT_TYPE_FOUND"]
} }
def extract_support_user_requirements(description: str) -> List[str]:
"""서포트 사용자 요구사항 추출"""
desc_upper = description.upper()
requirements = []
# 표면처리 관련
if 'GALV' in desc_upper or 'GALVANIZED' in desc_upper:
requirements.append('GALVANIZED')
if 'HDG' in desc_upper or 'HOT DIP' in desc_upper:
requirements.append('HOT DIP GALVANIZED')
if 'PAINT' in desc_upper or 'PAINTED' in desc_upper:
requirements.append('PAINTED')
# 재질 관련
if 'SS' in desc_upper or 'STAINLESS' in desc_upper:
requirements.append('STAINLESS STEEL')
if 'CARBON' in desc_upper:
requirements.append('CARBON STEEL')
# 특수 요구사항
if 'FIRE SAFE' in desc_upper:
requirements.append('FIRE SAFE')
if 'SEISMIC' in desc_upper or '내진' in desc_upper:
requirements.append('SEISMIC')
return requirements
def classify_load_rating(description: str) -> Dict: def classify_load_rating(description: str) -> Dict:
"""하중 등급 분류""" """하중 등급 분류"""

View File

@@ -89,6 +89,24 @@ VALVE_TYPES = {
"typical_connections": ["FLANGED", "THREADED"], "typical_connections": ["FLANGED", "THREADED"],
"pressure_range": "150LB ~ 600LB", "pressure_range": "150LB ~ 600LB",
"special_features": ["LUBRICATED", "NON_LUBRICATED"] "special_features": ["LUBRICATED", "NON_LUBRICATED"]
},
"SIGHT_GLASS": {
"dat_file_patterns": ["SIGHT_", "SG_"],
"description_keywords": ["SIGHT GLASS", "SIGHT", "사이트글라스", "사이트 글라스"],
"characteristics": "유체 확인용 관찰창",
"typical_connections": ["FLANGED", "THREADED"],
"pressure_range": "150LB ~ 600LB",
"special_features": ["TRANSPARENT", "VISUAL_INSPECTION"]
},
"STRAINER": {
"dat_file_patterns": ["STRAINER_", "STR_"],
"description_keywords": ["STRAINER", "스트레이너", "여과기"],
"characteristics": "이물질 여과용",
"typical_connections": ["FLANGED", "THREADED"],
"pressure_range": "150LB ~ 600LB",
"special_features": ["MESH_FILTER", "Y_TYPE", "BASKET_TYPE"]
} }
} }
@@ -212,8 +230,13 @@ def classify_valve(dat_file: str, description: str, main_nom: str, length: float
desc_upper = description.upper() desc_upper = description.upper()
dat_upper = dat_file.upper() dat_upper = dat_file.upper()
# 1. 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행) # 1. 사이트 글라스와 스트레이너 우선 확인
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', '밸브', '이트', '', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드'] if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '이트글라스' in desc_upper or '스트레이너' in desc_upper:
# 사이트 글라스와 스트레이너는 항상 밸브로 분류
pass
# 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'SIGHT GLASS', 'STRAINER', '밸브', '게이트', '', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '사이트글라스', '스트레이너']
has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords) has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
# 밸브 재질 확인 (A216, A217, A351, A352) # 밸브 재질 확인 (A216, A217, A351, A352)

View File

@@ -0,0 +1,168 @@
{
"request_no": "PR-20251016-001",
"job_no": "1",
"created_at": "2025-10-16T05:40:46.947440",
"materials": [
{
"material_id": 3543,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3551,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3555,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 23,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3565,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 139,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3574,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3588,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 98,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3844,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 82,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3926,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3930,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "12\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3931,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 50,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3981,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3990,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 25,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3998,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4023,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4126,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4138,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 13,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

View File

@@ -0,0 +1,778 @@
{
"request_no": "PR-20251016-002",
"job_no": "1",
"created_at": "2025-10-16T05:44:08.264221",
"materials": [
{
"material_id": 3540,
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3542,
"description": "HALF NIPPLE, SMLS, SCH 80S, ASTM A312 TP304 SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3682,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW * NPT",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3831,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3835,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3838,
"description": "HALF NIPPLE, SMLS, SCH 160, ASTM A106 B SW X NPT",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 3840,
"description": "NIPPLE, SMLS, SCH 160, ASTM A106 B",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4151,
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "10\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4152,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 25,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4177,
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4183,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A234 WPB",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4195,
"description": "90 LR ELL, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A403 WP304",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4199,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4206,
"description": "90 SR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4207,
"description": "90 LR ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "6\"",
"material_grade": "ASTM A234 WPB",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4214,
"description": "45 ELL, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "6\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4216,
"description": "TEE, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4220,
"description": "TEE, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4221,
"description": "TEE, SMLS, SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4222,
"description": "TEE, SMLS, SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4223,
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4226,
"description": "TEE RED, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4228,
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "3\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4231,
"description": "TEE RED, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "4\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4233,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "1 1/2\" x 1\"",
"material_grade": "ASTM A234 WPB",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4238,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4240,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A234 WPB",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4245,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "12\" x 10\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4246,
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4252,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "2\" x 1 1/2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4253,
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "2\" x 1\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4254,
"description": "RED CONC, SMLS, SCH 40 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "3\" x 1 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4256,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\" x 1\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4257,
"description": "RED CONC, SMLS, SCH 40 X SCH 40, ASTM A234 WPB",
"category": "FITTING",
"size": "3\" x 2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4258,
"description": "RED CONC, SMLS, SCH 40S X SCH 40S, ASTM A403 WP304",
"category": "FITTING",
"size": "3\" x 2\"",
"material_grade": "ASTM A403 WP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 4259,
"description": "RED CONC, SMLS, SCH 80 X SCH 80, ASTM A234 WPB",
"category": "FITTING",
"size": "3/4\" x 1/2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5136,
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5138,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 57,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5142,
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5146,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 32,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5178,
"description": "90 ELL, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5245,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 32,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5277,
"description": "90 ELL, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 24,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5301,
"description": "TEE, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5308,
"description": "TEE, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5323,
"description": "TEE, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5324,
"description": "TEE, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5326,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\" x 1\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5331,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5333,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5339,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5346,
"description": "TEE RED, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\" x 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5349,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5355,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "2\" x 1\"",
"material_grade": "ASTM A105",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5359,
"description": "TEE RED, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5364,
"description": "CAP, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5365,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
"category": "FITTING",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5366,
"description": "CAP, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5367,
"description": "CAP, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5368,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5370,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5371,
"description": "CAP, SMLS, SCH 40, BW, ASTM A234 WPB",
"category": "FITTING",
"size": "2\"",
"material_grade": "ASTM A234 WPB",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5372,
"description": "CAP, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5373,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A105",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 36,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5409,
"description": "SOLID HEX. PLUG, NPT(M), 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5426,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "10\" x 1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5427,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "10\" x 1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5428,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "10\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5429,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "2\" x 1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5431,
"description": "ELL O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5432,
"description": "ELL O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5433,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5442,
"description": "SOCK O LET, SW, 3000LB, ASTM A182 F304",
"category": "FITTING",
"size": "3\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5445,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "3\" x 1\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5446,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "4\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5447,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "6\" x 1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 5449,
"description": "SOCK O LET, SW, 3000LB, ASTM A105",
"category": "FITTING",
"size": "6\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

View File

@@ -0,0 +1,168 @@
{
"request_no": "PR-20251016-003",
"job_no": "2",
"created_at": "2025-10-16T06:01:25.896639",
"materials": [
{
"material_id": 7082,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 11,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7090,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A106 B",
"quantity": 92,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7094,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A312 TP304",
"quantity": 23,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7104,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1\"",
"material_grade": "ASTM A106 B",
"quantity": 139,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7113,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7127,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1 1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 98,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7383,
"description": "PIPE, SMLS, SCH 80, ASTM A106 B",
"category": "PIPE",
"size": "1/2\"",
"material_grade": "ASTM A106 B",
"quantity": 82,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7465,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "10\"",
"material_grade": "ASTM A312 TP304",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7469,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "12\"",
"material_grade": "ASTM A312 TP304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7470,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A106 B",
"quantity": 50,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7520,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "2\"",
"material_grade": "ASTM A312 TP304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7529,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A106 B",
"quantity": 25,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7537,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3\"",
"material_grade": "ASTM A312 TP304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7562,
"description": "PIPE, SMLS, SCH 40S, ASTM A312 TP304",
"category": "PIPE",
"size": "3/4\"",
"material_grade": "ASTM A312 TP304",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7665,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "4\"",
"material_grade": "ASTM A106 B",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 7677,
"description": "PIPE, SMLS, SCH 40, ASTM A106 B",
"category": "PIPE",
"size": "6\"",
"material_grade": "ASTM A106 B",
"quantity": 13,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

View File

@@ -0,0 +1,408 @@
{
"request_no": "PR-20251016-004",
"job_no": "5",
"created_at": "2025-10-16T05:24:45.921468",
"materials": [
{
"material_id": 118834,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "10\"",
"material_grade": "ASTM A182 F304",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118839,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "12\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118840,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 36,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118876,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118882,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "2\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118890,
"description": "FLG WELD NECK RF SCH 40, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118895,
"description": "FLG WELD NECK RF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118897,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 12,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118909,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118914,
"description": "FLG WELD NECK RF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118923,
"description": "FLG WELD NECK RF SCH 40, 300LB, ASTM A105",
"category": "FLANGE",
"size": "4\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118926,
"description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105",
"category": "FLANGE",
"size": "6\"",
"material_grade": "ASTM A105",
"quantity": 10,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118936,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 14,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118948,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 15,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118952,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 9,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118953,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 66,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118957,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A105",
"quantity": 6,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118959,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A182 F304",
"quantity": 8,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 118967,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 40,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119003,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119008,
"description": "RED. FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119010,
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 4,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119014,
"description": "RED. FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119024,
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "1\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119092,
"description": "RED. FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1\" x 3/4\"",
"material_grade": "ASTM A105",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119097,
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "1 1/2\"",
"material_grade": "ASTM A105",
"quantity": 3,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119102,
"description": "FLG SWRF SCH 80, 150LB, ASTM A105",
"category": "FLANGE",
"size": "1/2\"",
"material_grade": "ASTM A105",
"quantity": 57,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119159,
"description": "FLG SWRF SCH 40S, 150LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 7,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119166,
"description": "FLG SWRF SCH 40S, 300LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119167,
"description": "FLG SWRF SCH 40S, 600LB, ASTM A182 F304",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A182 F304",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119179,
"description": "FLG SWRF SCH 80, 300LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119181,
"description": "FLG SWRF SCH 80, 600LB, ASTM A105",
"category": "FLANGE",
"size": "3/4\"",
"material_grade": "ASTM A105",
"quantity": 5,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119985,
"description": "ORIFICE, 150LB",
"category": "FLANGE",
"size": "10\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119987,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "10\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119989,
"description": "WOOD ORIFICE, 600LB",
"category": "FLANGE",
"size": "3\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119990,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "4\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119992,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "5\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119993,
"description": "WOOD ORIFICE, 600LB",
"category": "FLANGE",
"size": "5\"",
"material_grade": "-",
"quantity": 1,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119994,
"description": "WOOD ORIFICE, 150LB",
"category": "FLANGE",
"size": "6\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
},
{
"material_id": 119996,
"description": "WOOD ORIFICE, 300LB",
"category": "FLANGE",
"size": "8\"",
"material_grade": "-",
"quantity": 2,
"unit": "EA",
"user_requirement": ""
}
],
"grouped_materials": []
}

Binary file not shown.

View File

@@ -10,6 +10,8 @@ const BoltMaterialsView = ({
userRequirements, userRequirements,
setUserRequirements, setUserRequirements,
purchasedMaterials, purchasedMaterials,
onPurchasedMaterialsUpdate,
jobNo,
fileId, fileId,
user user
}) => { }) => {
@@ -45,11 +47,24 @@ const BoltMaterialsView = ({
const parseBoltInfo = (material) => { const parseBoltInfo = (material) => {
const qty = Math.round(material.quantity || 0); const qty = Math.round(material.quantity || 0);
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
// 볼트 상세 정보 우선 사용 // 플랜지당 볼트 세트 수 추출 (예: (8), (4))
const boltDetails = material.bolt_details || {}; const boltDetails = material.bolt_details || {};
let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1;
// 백엔드에서 정보가 없으면 원본 설명에서 직접 추출
if (boltsPerFlange === 1) {
const description = material.original_description || '';
const flangePattern = description.match(/\((\d+)\)/);
if (flangePattern) {
boltsPerFlange = parseInt(flangePattern[1]);
}
}
// 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수
const totalBoltsNeeded = qty * boltsPerFlange;
const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
// 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출) // 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출)
let boltLength = '-'; let boltLength = '-';
@@ -113,19 +128,32 @@ const BoltMaterialsView = ({
} }
} }
// 추가요구사항 추출 (ELEC.GALV 등) // 압력 등급 추출 (150LB 등)
const additionalReq = extractBoltAdditionalRequirements(material.original_description || ''); let boltPressure = '-';
const description = material.original_description || '';
const pressureMatch = description.match(/(\d+)\s*LB/i);
if (pressureMatch) {
boltPressure = `${pressureMatch[1]}LB`;
}
// User Requirements 추출 (ELEC.GALV 등)
const userRequirements = extractBoltAdditionalRequirements(material.original_description || '');
// Purchase Quantity (Quantity + Unit 통합) - 플랜지당 볼트 세트 수 정보 포함
const purchaseQuantity = boltsPerFlange > 1
? `${purchaseQty} SETS (${boltsPerFlange}/flange)`
: `${purchaseQty} SETS`;
return { return {
type: 'BOLT', type: 'BOLT',
subtype: boltSubtype, subtype: boltSubtype,
size: material.size_spec || material.main_nom || '-', size: material.size_spec || material.main_nom || '-',
pressure: '-', // 볼트는 압력 등급 없음 pressure: boltPressure, // 압력 등급 (150LB 등)
schedule: boltLength, // 길이 정보 schedule: boltLength, // 길이 정보
grade: boltGrade, grade: boltGrade,
additionalReq: additionalReq, // 추가요구사항 userRequirements: userRequirements, // User Requirements (ELEC.GALV 등)
quantity: purchaseQty, additionalReq: '-', // 추가요구사항 (사용자 입력)
unit: 'SETS' purchaseQuantity: purchaseQuantity // 구매수량 (통합)
}; };
}; };
@@ -228,29 +256,83 @@ const BoltMaterialsView = ({
})); }));
try { try {
await api.post('/files/save-excel', { console.log('🔄 볼트 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'BOLT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId, file_id: fileId,
job_no: jobNo,
category: 'BOLT', category: 'BOLT',
materials: dataWithRequirements, material_ids: allMaterialIds,
filename: excelFileName, materials_data: dataWithRequirements.map(m => ({
user_id: user?.id material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
}); });
exportMaterialsToExcel(dataWithRequirements, excelFileName, { if (response.data.success) {
category: 'BOLT', console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); // 3. 엑셀 파일을 서버에 업로드
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'BOLT');
console.log('📤 엑셀 파일 서버 업로드 중...');
await api.post('/purchase-request/upload-excel', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log('✅ 엑셀 파일 서버 업로드 완료');
// 4. 구매된 자재 목록 업데이트 (비활성화)
onPurchasedMaterialsUpdate(allMaterialIds);
console.log('✅ 구매된 자재 목록 업데이트 완료');
// 5. 클라이언트에 파일 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} else {
throw new Error(response.data?.message || '구매신청 생성 실패');
}
} catch (error) { } catch (error) {
console.error('엑셀 저장 실패:', error); console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, { exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'BOLT', category: 'BOLT',
filename: excelFileName, filename: excelFileName,
uploadDate: new Date().toLocaleDateString() uploadDate: new Date().toLocaleDateString()
}); });
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
} }
// 선택 해제
setSelectedMaterials(new Set());
}; };
const filteredMaterials = getFilteredAndSortedMaterials(); const filteredMaterials = getFilteredAndSortedMaterials();
@@ -317,21 +399,24 @@ const BoltMaterialsView = ({
<div style={{ <div style={{
background: 'white', background: 'white',
borderRadius: '12px', borderRadius: '12px',
overflow: 'hidden', overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)' boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}> }}>
{/* 헤더 */} <div style={{ minWidth: '1500px' }}>
<div style={{ {/* 헤더 */}
display: 'grid', <div style={{
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px', display: 'grid',
gap: '16px', gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px',
padding: '16px', gap: '16px',
background: '#f8fafc', padding: '16px',
borderBottom: '1px solid #e2e8f0', background: '#f8fafc',
fontSize: '14px', borderBottom: '1px solid #e2e8f0',
fontWeight: '600', fontSize: '14px',
color: '#374151' fontWeight: '600',
}}> color: '#374151',
textAlign: 'center'
}}>
<div> <div>
<input <input
type="checkbox" type="checkbox"
@@ -343,18 +428,83 @@ const BoltMaterialsView = ({
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
</div> </div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader> <FilterableHeader
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader> sortKey="subtype"
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader> filterKey="subtype"
<FilterableHeader sortKey="schedule" filterKey="schedule">Length</FilterableHeader> sortConfig={sortConfig}
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader> onSort={handleSort}
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader> columnFilters={columnFilters}
<div>Unit</div> onFilterChange={setColumnFilters}
<div>User Requirement</div> showFilterDropdown={showFilterDropdown}
</div> setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="pressure"
filterKey="pressure"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Pressure
</FilterableHeader>
<FilterableHeader
sortKey="schedule"
filterKey="schedule"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Length
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</FilterableHeader>
<div>User Requirements</div>
<div>Additional Request</div>
<FilterableHeader
sortKey="purchaseQuantity"
filterKey="purchaseQuantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Purchase Quantity
</FilterableHeader>
</div>
{/* 데이터 행들 */} {/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{filteredMaterials.map((material, index) => { {filteredMaterials.map((material, index) => {
const info = parseBoltInfo(material); const info = parseBoltInfo(material);
const isSelected = selectedMaterials.has(material.id); const isSelected = selectedMaterials.has(material.id);
@@ -365,7 +515,7 @@ const BoltMaterialsView = ({
key={material.id} key={material.id}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px', gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px',
gap: '16px', gap: '16px',
padding: '16px', padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none', borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
@@ -411,23 +561,24 @@ const BoltMaterialsView = ({
</span> </span>
)} )}
</div> </div>
<div style={{ fontSize: '14px', color: '#1f2937' }}> <div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.size} {info.size}
</div> </div>
<div style={{ fontSize: '14px', color: '#1f2937' }}> <div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.pressure} {info.pressure}
</div> </div>
<div style={{ fontSize: '14px', color: '#1f2937' }}> <div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.schedule} {info.schedule}
</div> </div>
<div style={{ fontSize: '14px', color: '#1f2937' }}> <div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.grade} {info.grade}
</div> </div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}> <div style={{
{info.quantity} fontSize: '14px',
</div> color: '#1f2937',
<div style={{ fontSize: '14px', color: '#6b7280' }}> textAlign: 'center'
{info.unit} }}>
{info.userRequirements}
</div> </div>
<div> <div>
<input <input
@@ -437,7 +588,7 @@ const BoltMaterialsView = ({
...userRequirements, ...userRequirements,
[material.id]: e.target.value [material.id]: e.target.value
})} })}
placeholder="Enter requirement..." placeholder="Enter additional request..."
style={{ style={{
width: '100%', width: '100%',
padding: '6px 8px', padding: '6px 8px',
@@ -447,6 +598,9 @@ const BoltMaterialsView = ({
}} }}
/> />
</div> </div>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.purchaseQuantity}
</div>
</div> </div>
); );
})} })}

View File

@@ -10,6 +10,8 @@ const SupportMaterialsView = ({
userRequirements, userRequirements,
setUserRequirements, setUserRequirements,
purchasedMaterials, purchasedMaterials,
onPurchasedMaterialsUpdate,
jobNo,
fileId, fileId,
user user
}) => { }) => {
@@ -19,33 +21,96 @@ const SupportMaterialsView = ({
const parseSupportInfo = (material) => { const parseSupportInfo = (material) => {
const desc = material.original_description || ''; const desc = material.original_description || '';
const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('우레탄'); const descUpper = desc.toUpperCase();
const isClamp = desc.includes('CLAMP') || desc.includes('클램프');
let subtypeText = ''; // 서포트 타입 분류 (백엔드 분류기와 동일한 로직)
if (isUrethaneBlock) { let supportType = 'U-BOLT'; // 기본값
subtypeText = '우레탄블럭슈';
} else if (isClamp) { if (descUpper.includes('URETHANE') || descUpper.includes('BLOCK SHOE') || descUpper.includes('우레탄')) {
subtypeText = '클램프'; supportType = 'URETHANE BLOCK SHOE';
} else { } else if (descUpper.includes('CLAMP') || descUpper.includes('클램프')) {
subtypeText = '유볼트'; supportType = 'CLAMP';
} else if (descUpper.includes('HANGER') || descUpper.includes('행거')) {
supportType = 'HANGER';
} else if (descUpper.includes('SPRING') || descUpper.includes('스프링')) {
supportType = 'SPRING HANGER';
} else if (descUpper.includes('GUIDE') || descUpper.includes('가이드')) {
supportType = 'GUIDE';
} else if (descUpper.includes('ANCHOR') || descUpper.includes('앵커')) {
supportType = 'ANCHOR';
}
// User Requirements 추출 (분류기에서 제공된 것 우선)
const userRequirements = material.user_requirements || [];
// 구매 수량 계산 (서포트는 취합된 숫자 그대로)
const qty = Math.round(material.quantity || 0);
const purchaseQty = qty;
// Material Grade 처리 - 우레탄 블럭슈의 경우 두께 정보 포함
let materialGrade = material.full_material_grade || material.material_grade || '-';
if (supportType === 'URETHANE BLOCK SHOE') {
// 두께 정보 추출 (40t, 27t 등 - 여기서 t는 thickness를 의미)
const thicknessMatch = desc.match(/(\d+)\s*[tT]/);
if (thicknessMatch) {
const thickness = `${thicknessMatch[1]}t`;
if (materialGrade === '-' || !materialGrade) {
materialGrade = thickness;
} else if (!materialGrade.includes(thickness)) {
materialGrade = `${materialGrade} ${thickness}`;
}
}
} }
return { return {
type: 'SUPPORT', type: supportType,
subtype: subtypeText,
size: material.main_nom || material.size_inch || material.size_spec || '-', size: material.main_nom || material.size_inch || material.size_spec || '-',
pressure: '-', // 서포트는 압력 등급 없음 grade: materialGrade,
schedule: '-', // 서포트는 스케줄 없음 userRequirements: userRequirements.join(', ') || '-',
description: material.original_description || '-',
grade: material.full_material_grade || material.material_grade || '-',
additionalReq: '-', additionalReq: '-',
quantity: Math.round(material.quantity || 0), purchaseQuantity: `${purchaseQty} EA`,
unit: '개', originalQuantity: qty,
isSupport: true isSupport: true
}; };
}; };
// 동일한 서포트 항목 합산
const consolidateSupportMaterials = (materials) => {
const consolidated = {};
materials.forEach(material => {
const info = parseSupportInfo(material);
const key = `${info.type}|${info.size}|${info.grade}`;
if (!consolidated[key]) {
consolidated[key] = {
...material,
consolidatedQuantity: info.originalQuantity,
consolidatedIds: [material.id],
parsedInfo: info
};
} else {
consolidated[key].consolidatedQuantity += info.originalQuantity;
consolidated[key].consolidatedIds.push(material.id);
}
});
// 합산된 수량으로 구매 수량 재계산 (서포트는 취합된 숫자 그대로)
return Object.values(consolidated).map(item => {
const purchaseQty = item.consolidatedQuantity;
return {
...item,
parsedInfo: {
...item.parsedInfo,
originalQuantity: item.consolidatedQuantity,
purchaseQuantity: `${purchaseQty} EA`
}
};
});
};
// 정렬 처리 // 정렬 처리
const handleSort = (key) => { const handleSort = (key) => {
let direction = 'asc'; let direction = 'asc';
@@ -57,19 +122,24 @@ const SupportMaterialsView = ({
// 필터링된 및 정렬된 자재 목록 // 필터링된 및 정렬된 자재 목록
const getFilteredAndSortedMaterials = () => { const getFilteredAndSortedMaterials = () => {
let filtered = materials.filter(material => { // 먼저 합산 처리
let consolidated = consolidateSupportMaterials(materials);
// 필터링
let filtered = consolidated.filter(material => {
return Object.entries(columnFilters).every(([key, filterValue]) => { return Object.entries(columnFilters).every(([key, filterValue]) => {
if (!filterValue) return true; if (!filterValue) return true;
const info = parseSupportInfo(material); const info = material.parsedInfo;
const value = info[key]?.toString().toLowerCase() || ''; const value = info[key]?.toString().toLowerCase() || '';
return value.includes(filterValue.toLowerCase()); return value.includes(filterValue.toLowerCase());
}); });
}); });
// 정렬
if (sortConfig && sortConfig.key) { if (sortConfig && sortConfig.key) {
filtered.sort((a, b) => { filtered.sort((a, b) => {
const aInfo = parseSupportInfo(a); const aInfo = a.parsedInfo;
const bInfo = parseSupportInfo(b); const bInfo = b.parsedInfo;
if (!aInfo || !bInfo) return 0; if (!aInfo || !bInfo) return 0;
@@ -104,26 +174,34 @@ const SupportMaterialsView = ({
// 전체 선택/해제 (구매신청된 자재 제외) // 전체 선택/해제 (구매신청된 자재 제외)
const handleSelectAll = () => { const handleSelectAll = () => {
const filteredMaterials = getFilteredAndSortedMaterials(); const filteredMaterials = getFilteredAndSortedMaterials();
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); const selectableMaterials = filteredMaterials.filter(material =>
!material.consolidatedIds.some(id => purchasedMaterials.has(id))
);
if (selectedMaterials.size === selectableMaterials.length) { if (selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length) {
setSelectedMaterials(new Set()); setSelectedMaterials(new Set());
} else { } else {
setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); const allIds = selectableMaterials.flatMap(m => m.consolidatedIds);
setSelectedMaterials(new Set(allIds));
} }
}; };
// 개별 선택 (구매신청된 자재는 선택 불가) // 개별 선택 (구매신청된 자재는 선택 불가)
const handleMaterialSelect = (materialId) => { const handleMaterialSelect = (consolidatedMaterial) => {
if (purchasedMaterials.has(materialId)) { const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
return; // 구매신청된 자재는 선택 불가 if (hasAnyPurchased) {
return; // 구매신청된 자재가 포함된 경우 선택 불가
} }
const newSelected = new Set(selectedMaterials); const newSelected = new Set(selectedMaterials);
if (newSelected.has(materialId)) { const allSelected = consolidatedMaterial.consolidatedIds.every(id => newSelected.has(id));
newSelected.delete(materialId);
if (allSelected) {
// 모두 선택된 경우 모두 해제
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.delete(id));
} else { } else {
newSelected.add(materialId); // 일부 또는 전체 미선택인 경우 모두 선택
consolidatedMaterial.consolidatedIds.forEach(id => newSelected.add(id));
} }
setSelectedMaterials(newSelected); setSelectedMaterials(newSelected);
}; };
@@ -145,29 +223,83 @@ const SupportMaterialsView = ({
})); }));
try { try {
await api.post('/files/save-excel', { console.log('🔄 서포트 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'SUPPORT',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId, file_id: fileId,
job_no: jobNo,
category: 'SUPPORT', category: 'SUPPORT',
materials: dataWithRequirements, material_ids: allMaterialIds,
filename: excelFileName, materials_data: dataWithRequirements.map(m => ({
user_id: user?.id material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
}); });
exportMaterialsToExcel(dataWithRequirements, excelFileName, { if (response.data.success) {
category: 'SUPPORT', console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); // 3. 엑셀 파일을 서버에 업로드
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'SUPPORT');
console.log('📤 엑셀 파일 서버 업로드 중...');
await api.post('/purchase-request/upload-excel', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
console.log('✅ 엑셀 파일 서버 업로드 완료');
// 4. 구매된 자재 목록 업데이트 (비활성화)
onPurchasedMaterialsUpdate(allMaterialIds);
console.log('✅ 구매된 자재 목록 업데이트 완료');
// 5. 클라이언트에 파일 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} else {
throw new Error(response.data?.message || '구매신청 생성 실패');
}
} catch (error) { } catch (error) {
console.error('엑셀 저장 실패:', error); console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, { exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'SUPPORT', category: 'SUPPORT',
filename: excelFileName, filename: excelFileName,
uploadDate: new Date().toLocaleDateString() uploadDate: new Date().toLocaleDateString()
}); });
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
} }
// 선택 해제
setSelectedMaterials(new Set());
}; };
const filteredMaterials = getFilteredAndSortedMaterials(); const filteredMaterials = getFilteredAndSortedMaterials();
@@ -234,138 +366,171 @@ const SupportMaterialsView = ({
<div style={{ <div style={{
background: 'white', background: 'white',
borderRadius: '12px', borderRadius: '12px',
overflow: 'hidden', overflow: 'auto',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)' boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
maxHeight: '600px'
}}> }}>
{/* 헤더 */} <div style={{ minWidth: '1200px' }}>
<div style={{ {/* 헤더 */}
display: 'grid', <div style={{
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px', display: 'grid',
gap: '16px', gridTemplateColumns: '50px 200px 120px 150px 180px 150px 200px',
padding: '16px', gap: '16px',
background: '#f8fafc', padding: '16px',
borderBottom: '1px solid #e2e8f0', background: '#f8fafc',
fontSize: '14px', borderBottom: '1px solid #e2e8f0',
fontWeight: '600', fontSize: '14px',
color: '#374151' fontWeight: '600',
}}> color: '#374151',
textAlign: 'center'
}}>
<div> <div>
<input <input
type="checkbox" type="checkbox"
checked={(() => { checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); const selectableMaterials = filteredMaterials.filter(material =>
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; !material.consolidatedIds.some(id => purchasedMaterials.has(id))
);
return selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length && selectableMaterials.length > 0;
})()} })()}
onChange={handleSelectAll} onChange={handleSelectAll}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
</div> </div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader> <FilterableHeader
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader> sortKey="type"
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader> filterKey="type"
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader> sortConfig={sortConfig}
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader> onSort={handleSort}
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader> columnFilters={columnFilters}
<div>Unit</div> onFilterChange={setColumnFilters}
<div>User Requirement</div> showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Type
</FilterableHeader>
<FilterableHeader
sortKey="size"
filterKey="size"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Size
</FilterableHeader>
<FilterableHeader
sortKey="grade"
filterKey="grade"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Material Grade
</FilterableHeader>
<div>User Requirements</div>
<div>Additional Request</div>
<FilterableHeader
sortKey="purchaseQuantity"
filterKey="purchaseQuantity"
sortConfig={sortConfig}
onSort={handleSort}
columnFilters={columnFilters}
onFilterChange={setColumnFilters}
showFilterDropdown={showFilterDropdown}
setShowFilterDropdown={setShowFilterDropdown}
>
Purchase Quantity
</FilterableHeader>
</div> </div>
{/* 데이터 행들 */} {/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}> {filteredMaterials.map((consolidatedMaterial, index) => {
{filteredMaterials.map((material, index) => { const info = consolidatedMaterial.parsedInfo;
const info = parseSupportInfo(material); const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id));
const isSelected = selectedMaterials.has(material.id); const allSelected = consolidatedMaterial.consolidatedIds.every(id => selectedMaterials.has(id));
const isPurchased = purchasedMaterials.has(material.id);
return ( return (
<div <div
key={material.id} key={`consolidated-${index}`}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px', gridTemplateColumns: '50px 200px 120px 150px 180px 150px 200px',
gap: '16px', gap: '16px',
padding: '16px', padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none', borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'), background: allSelected ? '#eff6ff' : (hasAnyPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease' transition: 'background 0.15s ease',
}} textAlign: 'center'
onMouseEnter={(e) => { }}
if (!isSelected && !isPurchased) { onMouseEnter={(e) => {
e.target.style.background = '#f8fafc'; if (!allSelected && !hasAnyPurchased) {
} e.target.style.background = '#f8fafc';
}} }
onMouseLeave={(e) => { }}
if (!isSelected && !isPurchased) { onMouseLeave={(e) => {
e.target.style.background = 'white'; if (!allSelected && !hasAnyPurchased) {
} e.target.style.background = 'white';
}} }
> }}
<div> >
<input <div>
type="checkbox" <input
checked={isSelected} type="checkbox"
onChange={() => handleMaterialSelect(material.id)} checked={allSelected}
disabled={isPurchased} onChange={() => handleMaterialSelect(consolidatedMaterial)}
style={{ disabled={hasAnyPurchased}
cursor: isPurchased ? 'not-allowed' : 'pointer', style={{
opacity: isPurchased ? 0.5 : 1 cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
}} opacity: hasAnyPurchased ? 0.5 : 1
/> }}
</div> />
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
</div>
<div>
<input
type="text"
value={userRequirements[material.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
style={{
width: '100%',
padding: '6px 8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
</div> </div>
); <div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.type}
{hasAnyPurchased && (
<span style={{
marginLeft: '8px',
padding: '2px 6px',
background: '#fbbf24',
color: '#92400e',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '500'
}}>
PURCHASED
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.grade}</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.userRequirements}</div>
<div>
<input
type="text"
value={userRequirements[consolidatedMaterial.id] || ''}
onChange={(e) => setUserRequirements({
...userRequirements,
[consolidatedMaterial.id]: e.target.value
})}
placeholder="Enter additional request..."
style={{
width: '100%',
padding: '8px',
border: '1px solid #d1d5db',
borderRadius: '4px',
fontSize: '12px'
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
</div>
);
})} })}
</div> </div>
</div> </div>

View File

@@ -457,60 +457,57 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
itemName = gasketTypeMap[gasketType]; itemName = gasketTypeMap[gasketType];
} else if (extractedType && gasketTypeMap[extractedType]) { } else if (extractedType && gasketTypeMap[extractedType]) {
itemName = gasketTypeMap[extractedType]; itemName = gasketTypeMap[extractedType];
} else { } else {
itemName = 'GASKET'; itemName = 'GASKET';
} }
} else if (category === 'BOLT') { } else if (category === 'BOLT') {
// 볼트 상세 타입 표시 // 볼트 상세 타입 표시
const boltDetails = material.bolt_details || {}; const boltDetails = material.bolt_details || {};
const boltType = boltDetails.bolt_type || ''; const boltType = boltDetails.bolt_type || '';
if (boltType === 'HEX_BOLT') { // BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직
itemName = '육각 볼트'; let boltSubtype = 'BOLT_GENERAL';
} else if (boltType === 'STUD_BOLT') {
itemName = '스터드 볼트'; if (boltType && boltType !== 'UNKNOWN') {
} else if (boltType === 'U_BOLT') { boltSubtype = boltType;
itemName = '유볼트';
} else if (boltType === 'FLANGE_BOLT') {
itemName = '플랜지 볼트';
} else if (boltType === 'PSV_BOLT') {
itemName = 'PSV 볼트';
} else if (boltType === 'LT_BOLT') {
itemName = '저온용 볼트';
} else if (boltType === 'CK_BOLT') {
itemName = '체크밸브용 볼트';
} else { } else {
// description에서 추출 // 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일)
const desc = cleanDescription.toUpperCase(); const desc = cleanDescription.toUpperCase();
if (desc.includes('PSV')) { if (desc.includes('PSV')) {
itemName = 'PSV 볼트'; boltSubtype = 'PSV_BOLT';
} else if (desc.includes('LT')) { } else if (desc.includes('LT')) {
itemName = '저온용 볼트'; boltSubtype = 'LT_BOLT';
} else if (desc.includes('CK')) { } else if (desc.includes('CK')) {
itemName = '체크밸브용 볼트'; boltSubtype = 'CK_BOLT';
} else if (desc.includes('STUD')) {
itemName = '스터드 볼트';
} else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) {
itemName = '유볼트';
} else {
itemName = '볼트';
} }
} }
// BOM 페이지와 동일한 타입명 사용
itemName = boltSubtype;
} else if (category === 'SUPPORT' || category === 'U_BOLT') { } else if (category === 'SUPPORT' || category === 'U_BOLT') {
// 서포트 상세 타입 표시 // 서포트 상세 타입 표시
const desc = cleanDescription.toUpperCase(); const desc = cleanDescription.toUpperCase();
if (desc.includes('URETHANE') || desc.includes('BLOCK SHOE')) { if (desc.includes('URETHANE') || desc.includes('BLOCK SHOE')) {
itemName = '우레탄 블록 슈'; itemName = 'URETHANE BLOCK SHOE';
// 우레탄 블럭슈의 경우 두께 정보 추가
const thicknessMatch = desc.match(/(\d+)\s*[tT](?![oO])/);
if (thicknessMatch) {
itemName += ` ${thicknessMatch[1]}t`;
}
} else if (desc.includes('CLAMP')) { } else if (desc.includes('CLAMP')) {
itemName = '클램프'; itemName = 'CLAMP';
} else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) { } else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) {
itemName = '유볼트'; itemName = 'U-BOLT';
} else if (desc.includes('HANGER')) { } else if (desc.includes('HANGER')) {
itemName = '행거'; itemName = 'HANGER';
} else if (desc.includes('SPRING')) { } else if (desc.includes('SPRING')) {
itemName = '스프링 서포트'; itemName = 'SPRING HANGER';
} else if (desc.includes('GUIDE')) {
itemName = 'GUIDE';
} else if (desc.includes('ANCHOR')) {
itemName = 'ANCHOR';
} else { } else {
itemName = '서포트'; itemName = 'SUPPORT';
} }
} else { } else {
itemName = category || 'UNKNOWN'; itemName = category || 'UNKNOWN';
@@ -542,12 +539,22 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
let schedule = '-'; let schedule = '-';
if (category === 'BOLT') { if (category === 'BOLT') {
// 볼트의 경우 길이 정보 추출 // 볼트의 경우 길이 정보 추출 (백엔드와 동일한 패턴 사용)
const lengthPatterns = [ const lengthPatterns = [
/(\d+(?:\.\d+)?)\s*LG/i, // 145.0000 LG, 75 LG /(\d+(?:\.\d+)?)\s*LG/i, // 145.0000 LG, 75 LG (최우선)
/(\d+(?:\.\d+)?)\s*MM\s*LG/i, // 70MM LG 형태
/L\s*(\d+(?:\.\d+)?)\s*MM/i,
/LENGTH\s*(\d+(?:\.\d+)?)\s*MM/i,
/(\d+(?:\.\d+)?)\s*MM\s*LONG/i,
/X\s*(\d+(?:\.\d+)?)\s*MM/i, // M8 X 20MM 형태
/,\s*(\d+(?:\.\d+)?)\s*LG/i, // ", 145.0000 LG" 형태 (PSV, LT 볼트용)
/,\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)/i, // ", 140 CK" 형태 (PSV 볼트용)
/PSV\s+(\d+(?:\.\d+)?)/i, // PSV 140 형태 (PSV 볼트 전용)
/(\d+(?:\.\d+)?)\s+PSV/i, // 140 PSV 형태 (PSV 볼트 전용)
/(\d+(?:\.\d+)?)\s*CK/i, // 140CK 형태 (체크밸브용 볼트)
/(\d+(?:\.\d+)?)\s*LT/i, // 140LT 형태 (저온용 볼트)
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm /(\d+(?:\.\d+)?)\s*mm/i, // 50mm
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM /(\d+(?:\.\d+)?)\s*MM/i, // 50MM
/,\s*(\d+(?:\.\d+)?)\s*LG/i, // ", 145.0000 LG" 형태
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태 /LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
]; ];
@@ -678,20 +685,20 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
gasketMaterial = `${fourMaterialMatch[1]}/${fourMaterialMatch[2]}/${fourMaterialMatch[3]}/${fourMaterialMatch[4]}`; gasketMaterial = `${fourMaterialMatch[1]}/${fourMaterialMatch[2]}/${fourMaterialMatch[3]}/${fourMaterialMatch[4]}`;
} else { } else {
// DB에서 가져온 정보로 구성 (fallback) // DB에서 가져온 정보로 구성 (fallback)
if (material.gasket_details) { if (material.gasket_details) {
const materialType = material.gasket_details.material_type || ''; const materialType = material.gasket_details.material_type || '';
const fillerMaterial = material.gasket_details.filler_material || ''; const fillerMaterial = material.gasket_details.filler_material || '';
if (materialType && fillerMaterial) { if (materialType && fillerMaterial) {
gasketMaterial = `${materialType}/${fillerMaterial}`; gasketMaterial = `${materialType}/${fillerMaterial}`;
} }
} }
// 마지막으로 간단한 패턴 // 마지막으로 간단한 패턴
if (!gasketMaterial) { if (!gasketMaterial) {
const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i); const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i);
if (simpleMaterialMatch) { if (simpleMaterialMatch) {
gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`; gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`;
} }
} }
} }
@@ -730,6 +737,29 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
} else if (material.pipe_count) { } else if (material.pipe_count) {
quantity = material.pipe_count; quantity = material.pipe_count;
} }
} else if (category === 'BOLT') {
// BOLT의 경우 플랜지당 볼트 세트 수를 고려한 수량 계산 (BOM 페이지와 동일)
const qty = Math.round(material.quantity || 0);
// 플랜지당 볼트 세트 수 추출 (예: (8), (4))
const boltDetails = material.bolt_details || {};
let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1;
// 백엔드에서 정보가 없으면 원본 설명에서 직접 추출
if (boltsPerFlange === 1) {
const description = material.original_description || '';
const flangePattern = description.match(/\((\d+)\)/);
if (flangePattern) {
boltsPerFlange = parseInt(flangePattern[1]);
}
}
// 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수
const totalBoltsNeeded = qty * boltsPerFlange;
const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
quantity = purchaseQty;
} }
// 새로운 엑셀 양식: A~E 고정, F~O 카테고리별, P 납기일 // 새로운 엑셀 양식: A~E 고정, F~O 카테고리별, P 납기일
@@ -840,14 +870,26 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
base['관리항목1'] = ''; // M열 base['관리항목1'] = ''; // M열
base['관리항목2'] = ''; // N열 base['관리항목2'] = ''; // N열
base['관리항목3'] = ''; // O열 base['관리항목3'] = ''; // O열
} else if (category === 'BOLT') { } else if (category === 'BOLT') {
// 볼트 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 // 볼트 전용 컬럼 (F~O) - User Requirements와 Additional Request 분리
base['크기'] = material.size_spec || '-'; // F열 base['크기'] = material.size_spec || '-'; // F열
base['압력등급'] = pressure; // G열 base['압력등급'] = pressure; // G열
base['길이'] = schedule; // H열: 볼트는 길이 정보 base['길이'] = schedule; // H열: 볼트는 길이 정보
base['재질'] = grade; // I열 base['재질'] = grade; // I열
base['추가요구'] = detailInfo || '-'; // J열: 표면처리 등 base['사용자요구'] = detailInfo || '-'; // J열: ELEC.GALV 등 (분류기 추출)
base['사용자요구'] = material.user_requirement || ''; // K열 base['추가요청사항'] = material.user_requirement || ''; // K열: 사용자 입력
base['관리항목1'] = ''; // L열
base['관리항목2'] = ''; // M열
base['관리항목3'] = ''; // N열
base['관리항목4'] = ''; // O열
} else if (category === 'SUPPORT') {
// 서포트 전용 컬럼 (F~O)
base['크기'] = material.size_spec || material.main_nom || '-'; // F열
base['압력등급'] = pressure; // G열
base['재질'] = grade; // H열
base['상세내역'] = material.original_description || '-'; // I열
base['사용자요구'] = material.user_requirements?.join(', ') || ''; // J열 (분류기에서 추출)
base['추가요청사항'] = material.user_requirement || ''; // K열 (사용자 입력)
base['관리항목1'] = ''; // L열 base['관리항목1'] = ''; // L열
base['관리항목2'] = ''; // M열 base['관리항목2'] = ''; // M열
base['관리항목3'] = ''; // N열 base['관리항목3'] = ''; // N열
@@ -1113,7 +1155,7 @@ export const createExcelBlob = async (materials, filename, options = {}) => {
'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
'VALVE': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'VALVE': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3'], 'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3'],
'BOLT': ['크기', '압력등급', '길이', '재질', '추가요구', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
'SUPPORT': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'SUPPORT': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
}; };

View File

@@ -126,6 +126,15 @@ export const calculatePurchaseQuantity = (material) => {
unit: 'EA' unit: 'EA'
}; };
case 'SUPPORT':
// 서포트는 취합된 숫자 그대로
return {
purchaseQuantity: bomQuantity,
calculation: `${bomQuantity} EA (취합된 수량 그대로)`,
category: 'SUPPORT',
unit: 'EA'
};
case 'FITTING': case 'FITTING':
case 'INSTRUMENT': case 'INSTRUMENT':
case 'VALVE': case 'VALVE':

110
update_excel_exports.py Normal file
View File

@@ -0,0 +1,110 @@
import os
import re
# 수정할 파일들과 카테고리
files_categories = [
('frontend/src/components/bom/materials/ValveMaterialsView.jsx', 'VALVE'),
('frontend/src/components/bom/materials/GasketMaterialsView.jsx', 'GASKET'),
('frontend/src/components/bom/materials/BoltMaterialsView.jsx', 'BOLT'),
('frontend/src/components/bom/materials/SupportMaterialsView.jsx', 'SUPPORT')
]
for file_path, category in files_categories:
if os.path.exists(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 기존 try 블록을 새로운 방식으로 교체
old_pattern = r'try\s*\{\s*//\s*1\.\s*구매신청\s*생성.*?alert\(\'엑셀\s*파일은\s*다운로드되었지만\s*구매신청\s*생성에\s*실패했습니다\.\'\);\s*\}'
new_code = f'''try {{
console.log('🔄 {category.lower()} 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {{
category: '{category}',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
}});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {{
file_id: fileId,
job_no: jobNo,
category: '{category}',
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({{
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}}))
}});
if (response.data.success) {{
console.log(`✅ 구매신청 완료: ${{response.data.request_no}}, request_id: ${{response.data.request_id}}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', '{category}');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {{
headers: {{
'Content-Type': 'multipart/form-data',
}},
}});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {{
onPurchasedMaterialsUpdate(allMaterialIds);
}}
}}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${{response.data?.request_no || ''}}이 생성되고 엑셀 파일이 저장되었습니다.`);
}} catch (error) {{
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {{
category: '{category}',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
}});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}}'''
# 간단한 패턴으로 기존 try 블록 찾기
try_pattern = r'try\s*\{[^}]*?구매신청\s*생성[^}]*?\}\s*catch[^}]*?\}'
if re.search(try_pattern, content, re.DOTALL):
content = re.sub(try_pattern, new_code, content, flags=re.DOTALL)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✅ Updated {file_path}")
else:
print(f"❌ Pattern not found in {file_path}")
else:
print(f"❌ File not found: {file_path}")
print("✅ All files updated!")