From 6b6360ecd5e7fea1b05d1c31abf8b1fc2ee97c74 Mon Sep 17 00:00:00 2001 From: hyungi Date: Fri, 17 Oct 2025 07:59:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=ED=8F=AC=ED=8A=B8=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=A0=84=EB=A9=B4=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서포트 카테고리 UI 개선: 좌우 스크롤, 헤더/본문 동기화, 가운데 정렬 - 동일 항목 합산 기능 구현 (Type + Size + Grade 기준) - 헤더 구조 변경: 압력/스케줄 제거, 구매수량 단일화, User Requirements 추가 - 우레탄 블럭슈 두께 정보(40t, 27t) Material Grade에 포함 - 서포트 수량 계산 수정: 취합된 숫자 그대로 표시 (4의 배수 계산 제거) - 서포트 분류 로직 개선: CLAMP, U-BOLT, URETHANE BLOCK SHOE 등 정확한 분류 - 백엔드 서포트 분류기에 User Requirements 추출 기능 추가 - 엑셀 내보내기에 서포트 카테고리 처리 로직 추가 --- backend/app/routers/purchase_request.py | 78 +- backend/app/services/bolt_classifier.py | 14 +- backend/app/services/flange_classifier.py | 8 + backend/app/services/integrated_classifier.py | 13 +- backend/app/services/support_classifier.py | 52 +- backend/app/services/valve_classifier.py | 27 +- backend/exports/PR-20251016-001.json | 168 ++++ backend/exports/PR-20251016-001.xlsx | Bin 0 -> 6457 bytes backend/exports/PR-20251016-002.json | 778 ++++++++++++++++++ backend/exports/PR-20251016-002.xlsx | Bin 0 -> 9740 bytes backend/exports/PR-20251016-003.json | 168 ++++ backend/exports/PR-20251016-003.xlsx | Bin 0 -> 6456 bytes backend/exports/PR-20251016-004.json | 408 +++++++++ backend/exports/PR-20251016-004.xlsx | Bin 0 -> 7750 bytes .../bom/materials/BoltMaterialsView.jsx | 264 ++++-- .../bom/materials/SupportMaterialsView.jsx | 483 +++++++---- frontend/src/utils/excelExport.js | 150 ++-- frontend/src/utils/purchaseCalculator.js | 9 + update_excel_exports.py | 110 +++ 19 files changed, 2452 insertions(+), 278 deletions(-) create mode 100644 backend/exports/PR-20251016-001.json create mode 100644 backend/exports/PR-20251016-001.xlsx create mode 100644 backend/exports/PR-20251016-002.json create mode 100644 backend/exports/PR-20251016-002.xlsx create mode 100644 backend/exports/PR-20251016-003.json create mode 100644 backend/exports/PR-20251016-003.xlsx create mode 100644 backend/exports/PR-20251016-004.json create mode 100644 backend/exports/PR-20251016-004.xlsx create mode 100644 update_excel_exports.py diff --git a/backend/app/routers/purchase_request.py b/backend/app/routers/purchase_request.py index 832d7e0..c558ce9 100644 --- a/backend/app/routers/purchase_request.py +++ b/backend/app/routers/purchase_request.py @@ -1,13 +1,14 @@ """ 구매신청 관리 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 pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session from typing import Optional, List, Dict from datetime import datetime +from pathlib import Path import os import json @@ -20,7 +21,7 @@ logger = get_logger(__name__) router = APIRouter(prefix="/purchase-request", tags=["Purchase Request"]) # 엑셀 파일 저장 경로 -EXCEL_DIR = "exports" +EXCEL_DIR = "uploads/excel_exports" os.makedirs(EXCEL_DIR, exist_ok=True) 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) + + +@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)}" + ) diff --git a/backend/app/services/bolt_classifier.py b/backend/app/services/bolt_classifier.py index 35249d8..3ec18f2 100644 --- a/backend/app/services/bolt_classifier.py +++ b/backend/app/services/bolt_classifier.py @@ -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), "length": dimensions_result.get('length', ''), "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": { @@ -966,12 +967,19 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict: except: 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 = { "nominal_size": actual_bolt_size, # 실제 볼트 사이즈 "nominal_size_fraction": nominal_size_fraction, # 분수 변환값 "length": "", "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',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용) 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 형태 (단독) ] diff --git a/backend/app/services/flange_classifier.py b/backend/app/services/flange_classifier.py index 579bde4..b0ceef6 100644 --- a/backend/app/services/flange_classifier.py +++ b/backend/app/services/flange_classifier.py @@ -182,6 +182,14 @@ def classify_flange(dat_file: str, description: str, main_nom: str, dat_upper = dat_file.upper() # 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'] has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords) diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py index 1d66cc6..dd3118f 100644 --- a/backend/app/services/integrated_classifier.py +++ b/backend/app/services/integrated_classifier.py @@ -10,7 +10,7 @@ from .fitting_classifier import classify_fitting # Level 1: 명확한 타입 키워드 (최우선) LEVEL1_TYPE_KEYWORDS = { "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"], "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"], @@ -110,6 +110,17 @@ def classify_material_integrated(description: str, main_nom: str = "", "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 카테고리보다 먼저) # U-BOLT, CLAMP, URETHANE BLOCK 등 if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or diff --git a/backend/app/services/support_classifier.py b/backend/app/services/support_classifier.py index f7f9dc3..47b53a2 100644 --- a/backend/app/services/support_classifier.py +++ b/backend/app/services/support_classifier.py @@ -108,7 +108,22 @@ def classify_support(dat_file: str, description: str, main_nom: str, # 4. 사이즈 정보 추출 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 { "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_capacity": load_result.get("capacity", ""), - # 재질 정보 (공통 모듈) + # 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함 "material": { "standard": material_result.get('standard', 'UNKNOWN'), - "grade": material_result.get('grade', 'UNKNOWN'), + "grade": enhanced_material_grade, "material_type": material_result.get('material_type', 'UNKNOWN'), "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, + # 사용자 요구사항 + "user_requirements": user_requirements, + # 전체 신뢰도 "overall_confidence": calculate_support_confidence({ "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"] } +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: """하중 등급 분류""" diff --git a/backend/app/services/valve_classifier.py b/backend/app/services/valve_classifier.py index 7b800ff..66d3f3a 100644 --- a/backend/app/services/valve_classifier.py +++ b/backend/app/services/valve_classifier.py @@ -89,6 +89,24 @@ VALVE_TYPES = { "typical_connections": ["FLANGED", "THREADED"], "pressure_range": "150LB ~ 600LB", "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() dat_upper = dat_file.upper() - # 1. 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행) - valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드'] + # 1. 사이트 글라스와 스트레이너 우선 확인 + 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) # 밸브 재질 확인 (A216, A217, A351, A352) diff --git a/backend/exports/PR-20251016-001.json b/backend/exports/PR-20251016-001.json new file mode 100644 index 0000000..ff0ed4f --- /dev/null +++ b/backend/exports/PR-20251016-001.json @@ -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": [] +} \ No newline at end of file diff --git a/backend/exports/PR-20251016-001.xlsx b/backend/exports/PR-20251016-001.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..cfb4ee0d4eca0df43ab7c08d249ea79ea7a03cca GIT binary patch literal 6457 zcmZ`-1yodRyB)e?=oFCdMv(6A8XAF-4(TrGE&)kl7(r<%0g0gzDM3;~LO@!&@2Knk z-zWFKbJjU$owa5^?{oHf>fKtZNXSG000138%Bf|dU<|_!hd;JhG-4@q%TBx@S7fBH2R)D)@rx&%we4Z3q3lPpBv#`e$-SoDX?VOr z^+9ljb*sE7GCR*R%Ym2WJj?MYDRTM)O{43@6O@khOBAiY{Ed3{foB`;=MVw_0Q|SV z)~@d0Uk*c4VH({$*ijbXA8lT9&f;=LT(Y7#7_rKuClY3`2Nnuye-ttT5vtevj!PPlGH!8c^{EgvtLGO}_Q z9VPXkzU+DUe$Zub$o(I z%VTaM^C`5(@9cesC-a+^;~}IiBQubj=tHIy#NeCUYvjL~d1;zQkB>8VR%n1_(?^loc+MYUN^$CSF))RWzd(YRujM&wni3EZYo&0Q4 zBSJb9h46M!%6x{SvKkDZf3?l5EipUggEQ8EB z?4-u@w2fUYXzSN$_|J)l^b)}k1VTe;rskOXpBGNO*K>;!;L)we)O=wsJd_~XkP+9? zH4WpKZ+cxloes8=29EvXpOr$x2ZJ(DT9i*A6U69zIHHg9K_lLQr#-uc2_NS?1)M3C zR}X!K8;3r}yHO!8Yqu73_3xd3Mjber=Z{h9`%f?X$4kGH^wb@rF}L{ znHptn(4s)k83;R345#b;T77ep)jC+h@1Gy^c*HRy#J(J%bhkBkH2vCRbFHv6eOg^+ z#O(tI#^Dvc$&+Yi7g0j@$5gg5fupCVsZd6~(ftd|Z4EJ78iULlv(eGDf&_Q^)`e40 z(5a@P3;pz)&bo)w843*Yg#(-|Ye3-!WgI?2N%Z`vYzHkW#1ahN%%Z6C`o0R_GGS0t zJNm#(U_Xd)%#%FIL zER6~J#(gzD*8{JODg@IdEC+UmQ@uu}QgMN4=8xe29f?l?c07m@R>|2deg;CmDKPI= zhHZuqJ-vA!9QB3Yi6iMuagtPJ4Uef*a*Iy@4U%Jo(H3FgQ1~`lK%(pcA12CS^8<-> ze_zZ;nSCF$s=@;h?#AaHb(NoocZ!5?1&u=Yn~%kdJ~ut053+?##G5QCxR~@@Rq@VE z3vjVm8@dk<^y^ipv(AVVcJAERNfaFJUd}Q^ROc9R(vzg9hSfR{P+Pq0nUce)^DuXxYY+^A+0jYHh@yI{KEpCVfckDaCQ19mMO2HAZiZDhCR&ff^)O1WMK!q_0(q zkH~e~H`tYX^o2X*`5Otc$Udxo?|-JD`<}qN5f72CxmDziKbY6{_0J_vYzz*Q7$bre zb+g?s8@YK^{tvw`vwUEdKX2Ry74g_|GrD6V=k$@j2`1r)f^L6a4SVSlcj36ze_$R z^WD7pq$kh!-C@CLV@L0 z?Jd=s7mRtL^#O-rw7J()^ChPX%h-XF<;GAOcA%sxS|Cjts&#_Nv4BM~kyYjE+RDB; zUTnWm&=GdgQ{km(`5X%VFYy&3iI}d^U!>M0=O=OOPH_aNQc=u#Up5Ls)-*81zTWQR z)O7VEecO%iK=`|X%ov`Zp`if)P67Y`*`EgD%hx97YfdV#WrW8{&Lhr2?H7a|#)|ek&ri)y4x+zk&3jq72^e!tA^ z&~?W%UrRkLk~}J6I>s=1CC1FWU(Ia4Xmc`&x2KLP&RwxUgVUehaQTGnfmvqB;9k)q zcjl~XdRHpet6(1Y?o9_bTJ8+mO6UrjJnKFP*Ft<;^?@Z42T~zwjE4q+r(6<_>C-Dt|c-fg_vS}xM{e2 z+#Hx&PpGQn9^vf}} zsGtyy&#F5gUPFl4TQc)mF*}L&K@Ir!&A3gsde`r2?~v!~Pfb_ft=Boq%T)85I$V9wl?=j&2?c91Vmfb@J>UyX!h?#)faO6%Z3(1qLS z`OWe8{Oo8o>z+jS&8E}o⁣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 b_#c6#EQbu74MIRb1OA18JYGkA`R)Gz02=5X literal 0 HcmV?d00001 diff --git a/backend/exports/PR-20251016-003.json b/backend/exports/PR-20251016-003.json new file mode 100644 index 0000000..e9042f3 --- /dev/null +++ b/backend/exports/PR-20251016-003.json @@ -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": [] +} \ No newline at end of file diff --git a/backend/exports/PR-20251016-003.xlsx b/backend/exports/PR-20251016-003.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ec15812b111c3bcbeb7649b3c7afa94db1a8d49b GIT binary patch literal 6456 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 literal 0 HcmV?d00001 diff --git a/backend/exports/PR-20251016-004.json b/backend/exports/PR-20251016-004.json new file mode 100644 index 0000000..5df6bde --- /dev/null +++ b/backend/exports/PR-20251016-004.json @@ -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": [] +} \ No newline at end of file diff --git a/backend/exports/PR-20251016-004.xlsx b/backend/exports/PR-20251016-004.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..b5a1fde2f37eeacf7e0a0e121e7c2b0235b62c97 GIT binary patch literal 7750 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx index f1006fb..d900e28 100644 --- a/frontend/src/components/bom/materials/BoltMaterialsView.jsx +++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx @@ -10,6 +10,8 @@ const BoltMaterialsView = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate, + jobNo, fileId, user }) => { @@ -45,11 +47,24 @@ const BoltMaterialsView = ({ const parseBoltInfo = (material) => { 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 || {}; + 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 우선, 없으면 원본 설명에서 추출) let boltLength = '-'; @@ -113,19 +128,32 @@ const BoltMaterialsView = ({ } } - // 추가요구사항 추출 (ELEC.GALV 등) - const additionalReq = extractBoltAdditionalRequirements(material.original_description || ''); + // 압력 등급 추출 (150LB 등) + 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 { type: 'BOLT', subtype: boltSubtype, size: material.size_spec || material.main_nom || '-', - pressure: '-', // 볼트는 압력 등급 없음 + pressure: boltPressure, // 압력 등급 (150LB 등) schedule: boltLength, // 길이 정보 grade: boltGrade, - additionalReq: additionalReq, // 추가요구사항 - quantity: purchaseQty, - unit: 'SETS' + userRequirements: userRequirements, // User Requirements (ELEC.GALV 등) + additionalReq: '-', // 추가요구사항 (사용자 입력) + purchaseQuantity: purchaseQuantity // 구매수량 (통합) }; }; @@ -228,29 +256,83 @@ const BoltMaterialsView = ({ })); 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, + job_no: jobNo, category: 'BOLT', - materials: dataWithRequirements, - filename: excelFileName, - user_id: user?.id + 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] || '' + })) }); - exportMaterialsToExcel(dataWithRequirements, excelFileName, { - category: 'BOLT', - filename: excelFileName, - uploadDate: new Date().toLocaleDateString() - }); + if (response.data.success) { + console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`); + + // 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('✅ 엑셀 파일 서버 업로드 완료'); - alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); + // 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) { - console.error('엑셀 저장 실패:', error); + console.error('엑셀 저장 또는 구매신청 실패:', error); + // 실패 시에도 클라이언트 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'BOLT', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); + alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } + + // 선택 해제 + setSelectedMaterials(new Set()); }; const filteredMaterials = getFilteredAndSortedMaterials(); @@ -317,21 +399,24 @@ const BoltMaterialsView = ({
- {/* 헤더 */} -
+
+ {/* 헤더 */} +
- Type - Size - Pressure - Length - Material Grade - Quantity -
Unit
-
User Requirement
-
+ + Type + + + Size + + + Pressure + + + Length + + + Material Grade + +
User Requirements
+
Additional Request
+ + Purchase Quantity + +
- {/* 데이터 행들 */} -
+ {/* 데이터 행들 */} {filteredMaterials.map((material, index) => { const info = parseBoltInfo(material); const isSelected = selectedMaterials.has(material.id); @@ -365,7 +515,7 @@ const BoltMaterialsView = ({ key={material.id} style={{ display: 'grid', - gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px', + gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px', gap: '16px', padding: '16px', borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none', @@ -411,23 +561,24 @@ const BoltMaterialsView = ({ )}
-
+
{info.size}
-
+
{info.pressure}
-
+
{info.schedule}
-
+
{info.grade}
-
- {info.quantity} -
-
- {info.unit} +
+ {info.userRequirements}
+
+ {info.purchaseQuantity} +
); })} diff --git a/frontend/src/components/bom/materials/SupportMaterialsView.jsx b/frontend/src/components/bom/materials/SupportMaterialsView.jsx index b98fd81..32352ea 100644 --- a/frontend/src/components/bom/materials/SupportMaterialsView.jsx +++ b/frontend/src/components/bom/materials/SupportMaterialsView.jsx @@ -10,6 +10,8 @@ const SupportMaterialsView = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate, + jobNo, fileId, user }) => { @@ -19,33 +21,96 @@ const SupportMaterialsView = ({ const parseSupportInfo = (material) => { const desc = material.original_description || ''; - const isUrethaneBlock = desc.includes('URETHANE') || desc.includes('BLOCK SHOE') || desc.includes('우레탄'); - const isClamp = desc.includes('CLAMP') || desc.includes('클램프'); + const descUpper = desc.toUpperCase(); - let subtypeText = ''; - if (isUrethaneBlock) { - subtypeText = '우레탄블럭슈'; - } else if (isClamp) { - subtypeText = '클램프'; - } else { - subtypeText = '유볼트'; + // 서포트 타입 분류 (백엔드 분류기와 동일한 로직) + let supportType = 'U-BOLT'; // 기본값 + + if (descUpper.includes('URETHANE') || descUpper.includes('BLOCK SHOE') || descUpper.includes('우레탄')) { + supportType = 'URETHANE BLOCK SHOE'; + } else if (descUpper.includes('CLAMP') || descUpper.includes('클램프')) { + 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 { - type: 'SUPPORT', - subtype: subtypeText, + type: supportType, size: material.main_nom || material.size_inch || material.size_spec || '-', - pressure: '-', // 서포트는 압력 등급 없음 - schedule: '-', // 서포트는 스케줄 없음 - description: material.original_description || '-', - grade: material.full_material_grade || material.material_grade || '-', + grade: materialGrade, + userRequirements: userRequirements.join(', ') || '-', additionalReq: '-', - quantity: Math.round(material.quantity || 0), - unit: '개', + purchaseQuantity: `${purchaseQty} EA`, + originalQuantity: qty, 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) => { let direction = 'asc'; @@ -57,19 +122,24 @@ const SupportMaterialsView = ({ // 필터링된 및 정렬된 자재 목록 const getFilteredAndSortedMaterials = () => { - let filtered = materials.filter(material => { + // 먼저 합산 처리 + let consolidated = consolidateSupportMaterials(materials); + + // 필터링 + let filtered = consolidated.filter(material => { return Object.entries(columnFilters).every(([key, filterValue]) => { if (!filterValue) return true; - const info = parseSupportInfo(material); + const info = material.parsedInfo; const value = info[key]?.toString().toLowerCase() || ''; return value.includes(filterValue.toLowerCase()); }); }); + // 정렬 if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { - const aInfo = parseSupportInfo(a); - const bInfo = parseSupportInfo(b); + const aInfo = a.parsedInfo; + const bInfo = b.parsedInfo; if (!aInfo || !bInfo) return 0; @@ -104,26 +174,34 @@ const SupportMaterialsView = ({ // 전체 선택/해제 (구매신청된 자재 제외) const handleSelectAll = () => { 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()); } else { - setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); + const allIds = selectableMaterials.flatMap(m => m.consolidatedIds); + setSelectedMaterials(new Set(allIds)); } }; // 개별 선택 (구매신청된 자재는 선택 불가) - const handleMaterialSelect = (materialId) => { - if (purchasedMaterials.has(materialId)) { - return; // 구매신청된 자재는 선택 불가 + const handleMaterialSelect = (consolidatedMaterial) => { + const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id)); + if (hasAnyPurchased) { + return; // 구매신청된 자재가 포함된 경우 선택 불가 } const newSelected = new Set(selectedMaterials); - if (newSelected.has(materialId)) { - newSelected.delete(materialId); + const allSelected = consolidatedMaterial.consolidatedIds.every(id => newSelected.has(id)); + + if (allSelected) { + // 모두 선택된 경우 모두 해제 + consolidatedMaterial.consolidatedIds.forEach(id => newSelected.delete(id)); } else { - newSelected.add(materialId); + // 일부 또는 전체 미선택인 경우 모두 선택 + consolidatedMaterial.consolidatedIds.forEach(id => newSelected.add(id)); } setSelectedMaterials(newSelected); }; @@ -145,29 +223,83 @@ const SupportMaterialsView = ({ })); 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, + job_no: jobNo, category: 'SUPPORT', - materials: dataWithRequirements, - filename: excelFileName, - user_id: user?.id + 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] || '' + })) }); - exportMaterialsToExcel(dataWithRequirements, excelFileName, { - category: 'SUPPORT', - filename: excelFileName, - uploadDate: new Date().toLocaleDateString() - }); + if (response.data.success) { + console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`); + + // 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('✅ 엑셀 파일 서버 업로드 완료'); - alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); + // 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) { - console.error('엑셀 저장 실패:', error); + console.error('엑셀 저장 또는 구매신청 실패:', error); + // 실패 시에도 클라이언트 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'SUPPORT', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); + alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } + + // 선택 해제 + setSelectedMaterials(new Set()); }; const filteredMaterials = getFilteredAndSortedMaterials(); @@ -234,138 +366,171 @@ const SupportMaterialsView = ({
- {/* 헤더 */} -
+
+ {/* 헤더 */} +
{ - const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); - return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; + const selectableMaterials = filteredMaterials.filter(material => + !material.consolidatedIds.some(id => purchasedMaterials.has(id)) + ); + return selectedMaterials.size === selectableMaterials.flatMap(m => m.consolidatedIds).length && selectableMaterials.length > 0; })()} onChange={handleSelectAll} style={{ cursor: 'pointer' }} />
- Type - Size - Pressure - Schedule - Material Grade - Quantity -
Unit
-
User Requirement
+ + Type + + + Size + + + Material Grade + +
User Requirements
+
Additional Request
+ + Purchase Quantity +
{/* 데이터 행들 */} -
- {filteredMaterials.map((material, index) => { - const info = parseSupportInfo(material); - const isSelected = selectedMaterials.has(material.id); - const isPurchased = purchasedMaterials.has(material.id); - - return ( -
{ - if (!isSelected && !isPurchased) { - e.target.style.background = '#f8fafc'; - } - }} - onMouseLeave={(e) => { - if (!isSelected && !isPurchased) { - e.target.style.background = 'white'; - } - }} - > -
- handleMaterialSelect(material.id)} - disabled={isPurchased} - style={{ - cursor: isPurchased ? 'not-allowed' : 'pointer', - opacity: isPurchased ? 0.5 : 1 - }} - /> -
-
- {info.subtype} - {isPurchased && ( - - PURCHASED - - )} -
-
- {info.size} -
-
- {info.pressure} -
-
- {info.schedule} -
-
- {info.grade} -
-
- {info.quantity} -
-
- {info.unit} -
-
- setUserRequirements({ - ...userRequirements, - [material.id]: e.target.value - })} - placeholder="Enter requirement..." - style={{ - width: '100%', - padding: '6px 8px', - border: '1px solid #d1d5db', - borderRadius: '4px', - fontSize: '12px' - }} - /> -
+ {filteredMaterials.map((consolidatedMaterial, index) => { + const info = consolidatedMaterial.parsedInfo; + const hasAnyPurchased = consolidatedMaterial.consolidatedIds.some(id => purchasedMaterials.has(id)); + const allSelected = consolidatedMaterial.consolidatedIds.every(id => selectedMaterials.has(id)); + + return ( +
{ + if (!allSelected && !hasAnyPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!allSelected && !hasAnyPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(consolidatedMaterial)} + disabled={hasAnyPurchased} + style={{ + cursor: hasAnyPurchased ? 'not-allowed' : 'pointer', + opacity: hasAnyPurchased ? 0.5 : 1 + }} + />
- ); +
+ {info.type} + {hasAnyPurchased && ( + + PURCHASED + + )} +
+
{info.size}
+
{info.grade}
+
{info.userRequirements}
+
+ setUserRequirements({ + ...userRequirements, + [consolidatedMaterial.id]: e.target.value + })} + placeholder="Enter additional request..." + style={{ + width: '100%', + padding: '8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px' + }} + /> +
+
{info.purchaseQuantity}
+
+ ); })}
diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index f491e5f..fd9aac5 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -457,60 +457,57 @@ const formatMaterialForExcel = (material, includeComparison = false) => { itemName = gasketTypeMap[gasketType]; } else if (extractedType && gasketTypeMap[extractedType]) { itemName = gasketTypeMap[extractedType]; - } else { - itemName = 'GASKET'; - } + } else { + itemName = 'GASKET'; + } } else if (category === 'BOLT') { // 볼트 상세 타입 표시 const boltDetails = material.bolt_details || {}; const boltType = boltDetails.bolt_type || ''; - if (boltType === 'HEX_BOLT') { - itemName = '육각 볼트'; - } else if (boltType === 'STUD_BOLT') { - itemName = '스터드 볼트'; - } else if (boltType === 'U_BOLT') { - 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 = '체크밸브용 볼트'; + // BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직 + let boltSubtype = 'BOLT_GENERAL'; + + if (boltType && boltType !== 'UNKNOWN') { + boltSubtype = boltType; } else { - // description에서 추출 + // 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일) const desc = cleanDescription.toUpperCase(); if (desc.includes('PSV')) { - itemName = 'PSV 볼트'; + boltSubtype = 'PSV_BOLT'; } else if (desc.includes('LT')) { - itemName = '저온용 볼트'; + boltSubtype = 'LT_BOLT'; } else if (desc.includes('CK')) { - itemName = '체크밸브용 볼트'; - } else if (desc.includes('STUD')) { - itemName = '스터드 볼트'; - } else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) { - itemName = '유볼트'; - } else { - itemName = '볼트'; + boltSubtype = 'CK_BOLT'; } } + + // BOM 페이지와 동일한 타입명 사용 + itemName = boltSubtype; } else if (category === 'SUPPORT' || category === 'U_BOLT') { // 서포트 상세 타입 표시 const desc = cleanDescription.toUpperCase(); 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')) { - itemName = '클램프'; + itemName = 'CLAMP'; } else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) { - itemName = '유볼트'; + itemName = 'U-BOLT'; } else if (desc.includes('HANGER')) { - itemName = '행거'; + itemName = 'HANGER'; } else if (desc.includes('SPRING')) { - itemName = '스프링 서포트'; + itemName = 'SPRING HANGER'; + } else if (desc.includes('GUIDE')) { + itemName = 'GUIDE'; + } else if (desc.includes('ANCHOR')) { + itemName = 'ANCHOR'; } else { - itemName = '서포트'; + itemName = 'SUPPORT'; } } else { itemName = category || 'UNKNOWN'; @@ -542,12 +539,22 @@ const formatMaterialForExcel = (material, includeComparison = false) => { let schedule = '-'; if (category === 'BOLT') { - // 볼트의 경우 길이 정보 추출 + // 볼트의 경우 길이 정보 추출 (백엔드와 동일한 패턴 사용) 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 - /,\s*(\d+(?:\.\d+)?)\s*LG/i, // ", 145.0000 LG" 형태 /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]}`; } else { // DB에서 가져온 정보로 구성 (fallback) - if (material.gasket_details) { - const materialType = material.gasket_details.material_type || ''; - const fillerMaterial = material.gasket_details.filler_material || ''; - - if (materialType && fillerMaterial) { - gasketMaterial = `${materialType}/${fillerMaterial}`; - } - } - + if (material.gasket_details) { + const materialType = material.gasket_details.material_type || ''; + const fillerMaterial = material.gasket_details.filler_material || ''; + + if (materialType && fillerMaterial) { + gasketMaterial = `${materialType}/${fillerMaterial}`; + } + } + // 마지막으로 간단한 패턴 - if (!gasketMaterial) { - const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i); - if (simpleMaterialMatch) { - gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`; + if (!gasketMaterial) { + const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i); + if (simpleMaterialMatch) { + gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`; } } } @@ -730,6 +737,29 @@ const formatMaterialForExcel = (material, includeComparison = false) => { } else if (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 납기일 @@ -840,14 +870,26 @@ const formatMaterialForExcel = (material, includeComparison = false) => { base['관리항목1'] = ''; // M열 base['관리항목2'] = ''; // N열 base['관리항목3'] = ''; // O열 - } else if (category === 'BOLT') { - // 볼트 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 + } else if (category === 'BOLT') { + // 볼트 전용 컬럼 (F~O) - User Requirements와 Additional Request 분리 base['크기'] = material.size_spec || '-'; // F열 base['압력등급'] = pressure; // G열 base['길이'] = schedule; // H열: 볼트는 길이 정보 base['재질'] = grade; // I열 - base['추가요구'] = detailInfo || '-'; // J열: 표면처리 등 - base['사용자요구'] = material.user_requirement || ''; // K열 + base['사용자요구'] = detailInfo || '-'; // J열: ELEC.GALV 등 (분류기 추출) + 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['관리항목2'] = ''; // M열 base['관리항목3'] = ''; // N열 @@ -1113,7 +1155,7 @@ export const createExcelBlob = async (materials, filename, options = {}) => { 'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'VALVE': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3'], - 'BOLT': ['크기', '압력등급', '길이', '재질', '추가요구', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], + 'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'SUPPORT': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], }; diff --git a/frontend/src/utils/purchaseCalculator.js b/frontend/src/utils/purchaseCalculator.js index dd2d394..61e5b26 100644 --- a/frontend/src/utils/purchaseCalculator.js +++ b/frontend/src/utils/purchaseCalculator.js @@ -126,6 +126,15 @@ export const calculatePurchaseQuantity = (material) => { unit: 'EA' }; + case 'SUPPORT': + // 서포트는 취합된 숫자 그대로 + return { + purchaseQuantity: bomQuantity, + calculation: `${bomQuantity} EA (취합된 수량 그대로)`, + category: 'SUPPORT', + unit: 'EA' + }; + case 'FITTING': case 'INSTRUMENT': case 'VALVE': diff --git a/update_excel_exports.py b/update_excel_exports.py new file mode 100644 index 0000000..e5eabf0 --- /dev/null +++ b/update_excel_exports.py @@ -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!")