diff --git a/backend/app/services/pipe_classifier.py b/backend/app/services/pipe_classifier.py index dfe456e..3a6430b 100644 --- a/backend/app/services/pipe_classifier.py +++ b/backend/app/services/pipe_classifier.py @@ -7,6 +7,60 @@ import re from typing import Dict, List, Optional from .material_classifier import classify_material, get_manufacturing_method_from_material +# ========== PIPE USER 요구사항 키워드 ========== +PIPE_USER_REQUIREMENTS = { + "IMPACT_TEST": { + "keywords": ["IMPACT", "충격시험", "CHARPY", "CVN", "IMPACT TEST", "충격", "NOTCH"], + "description": "충격시험 요구", + "confidence": 0.95 + }, + "ASME_CODE": { + "keywords": ["ASME", "ASME CODE", "CODE", "B31.1", "B31.3", "B31.4", "B31.8", "VIII"], + "description": "ASME 코드 준수", + "confidence": 0.95 + }, + "STRESS_RELIEF": { + "keywords": ["STRESS RELIEF", "SR", "응력제거", "열처리", "HEAT TREATMENT"], + "description": "응력제거 열처리", + "confidence": 0.90 + }, + "RADIOGRAPHIC_TEST": { + "keywords": ["RT", "RADIOGRAPHIC", "방사선시험", "X-RAY", "엑스레이"], + "description": "방사선 시험", + "confidence": 0.90 + }, + "ULTRASONIC_TEST": { + "keywords": ["UT", "ULTRASONIC", "초음파시험", "초음파"], + "description": "초음파 시험", + "confidence": 0.90 + }, + "MAGNETIC_PARTICLE": { + "keywords": ["MT", "MAGNETIC PARTICLE", "자분탐상", "자분"], + "description": "자분탐상 시험", + "confidence": 0.90 + }, + "LIQUID_PENETRANT": { + "keywords": ["PT", "LIQUID PENETRANT", "침투탐상", "침투"], + "description": "침투탐상 시험", + "confidence": 0.90 + }, + "HYDROSTATIC_TEST": { + "keywords": ["HYDROSTATIC", "수압시험", "PRESSURE TEST", "압력시험"], + "description": "수압 시험", + "confidence": 0.90 + }, + "LOW_TEMPERATURE": { + "keywords": ["LOW TEMP", "저온", "LTCS", "LOW TEMPERATURE", "CRYOGENIC"], + "description": "저온용", + "confidence": 0.85 + }, + "HIGH_TEMPERATURE": { + "keywords": ["HIGH TEMP", "고온", "HTCS", "HIGH TEMPERATURE"], + "description": "고온용", + "confidence": 0.85 + } +} + # ========== PIPE 제조 방법별 분류 ========== PIPE_MANUFACTURING = { "SEAMLESS": { @@ -138,6 +192,27 @@ PIPE_SCHEDULE = { ] } +def extract_pipe_user_requirements(description: str) -> List[str]: + """ + 파이프 설명에서 User 요구사항 추출 + + Args: + description: 파이프 설명 + + Returns: + 발견된 요구사항 리스트 + """ + desc_upper = description.upper() + found_requirements = [] + + for req_type, req_data in PIPE_USER_REQUIREMENTS.items(): + for keyword in req_data["keywords"]: + if keyword in desc_upper: + found_requirements.append(req_data["description"]) + break # 같은 타입에서 중복 방지 + + return found_requirements + def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict: """구매용 파이프 분류 - 끝단 가공 정보 제외""" @@ -215,13 +290,16 @@ def classify_pipe(dat_file: str, description: str, main_nom: str, # 3. 끝 가공 분류 end_prep_result = classify_pipe_end_preparation(description) - # 4. 스케줄 분류 - schedule_result = classify_pipe_schedule(description) + # 4. 스케줄 분류 (재질 정보 전달) + schedule_result = classify_pipe_schedule(description, material_result) # 5. 길이(절단 치수) 처리 length_info = extract_pipe_length_info(length, description) - # 6. 최종 결과 조합 + # 6. User 요구사항 추출 + user_requirements = extract_pipe_user_requirements(description) + + # 7. 최종 결과 조합 return { "category": "PIPE", @@ -260,6 +338,9 @@ def classify_pipe(dat_file: str, description: str, main_nom: str, "length_mm": length_info.get('length_mm') }, + # User 요구사항 + "user_requirements": user_requirements, + # 전체 신뢰도 "overall_confidence": calculate_pipe_confidence({ "material": material_result.get('confidence', 0), @@ -328,19 +409,43 @@ def classify_pipe_end_preparation(description: str) -> Dict: "matched_code": "DEFAULT" } -def classify_pipe_schedule(description: str) -> Dict: - """파이프 스케줄 분류""" +def classify_pipe_schedule(description: str, material_result: Dict = None) -> Dict: + """파이프 스케줄 분류 - 재질별 표현 개선""" desc_upper = description.upper() + # 재질 정보 확인 + material_type = "CARBON" # 기본값 + if material_result: + material_grade = material_result.get('grade', '').upper() + material_standard = material_result.get('standard', '').upper() + + # 스테인리스 스틸 판단 + if any(sus_indicator in material_grade or sus_indicator in material_standard + for sus_indicator in ['SUS', 'SS', 'A312', 'A358', 'A376', '304', '316', '321', '347']): + material_type = "STAINLESS" + # 1. 스케줄 패턴 확인 for pattern in PIPE_SCHEDULE["patterns"]: match = re.search(pattern, desc_upper) if match: schedule_num = match.group(1) + + # 재질별 스케줄 표현 + if material_type == "STAINLESS": + # 스테인리스 스틸: SCH 40S, SCH 80S + if schedule_num in ["10", "20", "40", "80", "120", "160"]: + schedule_display = f"SCH {schedule_num}S" + else: + schedule_display = f"SCH {schedule_num}" + else: + # 카본 스틸: SCH 40, SCH 80 + schedule_display = f"SCH {schedule_num}" + return { - "schedule": f"SCH {schedule_num}", + "schedule": schedule_display, "schedule_number": schedule_num, + "material_type": material_type, "confidence": 0.95, "matched_pattern": pattern } @@ -353,6 +458,7 @@ def classify_pipe_schedule(description: str) -> Dict: return { "schedule": f"{thickness}mm THK", "wall_thickness": f"{thickness}mm", + "material_type": material_type, "confidence": 0.9, "matched_pattern": pattern } @@ -360,6 +466,7 @@ def classify_pipe_schedule(description: str) -> Dict: # 3. 기본값 return { "schedule": "UNKNOWN", + "material_type": material_type, "confidence": 0.0 } diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx index 8c3d1f3..df83a54 100644 --- a/frontend/src/components/bom/materials/BoltMaterialsView.jsx +++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx @@ -442,7 +442,6 @@ const BoltMaterialsView = ({ padding: '60px 20px', color: '#64748b' }}> -
🔩
No Bolt Materials Found
diff --git a/frontend/src/components/bom/materials/FittingMaterialsView.jsx b/frontend/src/components/bom/materials/FittingMaterialsView.jsx index 5c3fd5b..cc0cc83 100644 --- a/frontend/src/components/bom/materials/FittingMaterialsView.jsx +++ b/frontend/src/components/bom/materials/FittingMaterialsView.jsx @@ -648,7 +648,6 @@ const FittingMaterialsView = ({ padding: '60px 20px', color: '#64748b' }}> -
⚙️
No Fitting Materials Found
diff --git a/frontend/src/components/bom/materials/FlangeMaterialsView.jsx b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx index e9f550f..24a84d1 100644 --- a/frontend/src/components/bom/materials/FlangeMaterialsView.jsx +++ b/frontend/src/components/bom/materials/FlangeMaterialsView.jsx @@ -494,7 +494,6 @@ const FlangeMaterialsView = ({ padding: '60px 20px', color: '#64748b' }}> -
🔩
No Flange Materials Found
diff --git a/frontend/src/components/bom/materials/GasketMaterialsView.jsx b/frontend/src/components/bom/materials/GasketMaterialsView.jsx index 13725ec..a442b90 100644 --- a/frontend/src/components/bom/materials/GasketMaterialsView.jsx +++ b/frontend/src/components/bom/materials/GasketMaterialsView.jsx @@ -378,7 +378,6 @@ const GasketMaterialsView = ({ padding: '60px 20px', color: '#64748b' }}> -
No Gasket Materials Found
diff --git a/frontend/src/components/bom/materials/PipeMaterialsView.jsx b/frontend/src/components/bom/materials/PipeMaterialsView.jsx index 742524a..6400a43 100644 --- a/frontend/src/components/bom/materials/PipeMaterialsView.jsx +++ b/frontend/src/components/bom/materials/PipeMaterialsView.jsx @@ -18,41 +18,77 @@ const PipeMaterialsView = ({ const [columnFilters, setColumnFilters] = useState({}); const [showFilterDropdown, setShowFilterDropdown] = useState(null); - // 파이프 구매 수량 계산 (기존 로직 복원) + // 파이프 구매 수량 계산 (백엔드 그룹화 데이터 활용) const calculatePipePurchase = (material) => { const pipeDetails = material.pipe_details || {}; - const totalLength = pipeDetails.length || material.length || 0; - const standardLength = 6; // 표준 6M - const purchaseCount = Math.ceil(totalLength / standardLength); - const totalPurchaseLength = purchaseCount * standardLength; - const wasteLength = totalPurchaseLength - totalLength; - const wastePercentage = totalLength > 0 ? (wasteLength / totalLength * 100) : 0; + // 백엔드에서 이미 그룹화된 데이터 사용 + let pipeCount = 1; // 기본값 + let totalBomLengthMm = 0; + + if (pipeDetails.pipe_count && pipeDetails.total_length_mm) { + // 백엔드에서 그룹화된 데이터 사용 + pipeCount = pipeDetails.pipe_count; // 실제 단관 개수 + totalBomLengthMm = pipeDetails.total_length_mm; // 이미 합산된 총 길이 + } else { + // 개별 파이프 데이터인 경우 + pipeCount = material.quantity || 1; + + // 길이 정보 우선순위: length_mm > length > pipe_details.length_mm + let singlePipeLengthMm = 0; + if (material.length_mm) { + singlePipeLengthMm = material.length_mm; + } else if (material.length) { + singlePipeLengthMm = material.length * 1000; // m를 mm로 변환 + } else if (pipeDetails.length_mm) { + singlePipeLengthMm = pipeDetails.length_mm; + } + + totalBomLengthMm = singlePipeLengthMm * pipeCount; + } + + // 여유분 포함 계산: 각 단관당 2mm 여유분 추가 + const allowancePerPipe = 2; // mm + const totalAllowanceMm = allowancePerPipe * pipeCount; + const totalLengthWithAllowance = totalBomLengthMm + totalAllowanceMm; // mm + + // 6,000mm(6m) 표준 길이로 필요한 본수 계산 (올림) + const standardLengthMm = 6000; // mm + const requiredStandardPipes = Math.ceil(totalLengthWithAllowance / standardLengthMm); return { - totalLength, - standardLength, - purchaseCount, - totalPurchaseLength, - wasteLength, - wastePercentage + pipeCount, // 단관 개수 + totalBomLengthMm, // 총 BOM 길이 (mm) + totalLengthWithAllowance, // 여유분 포함 총 길이 (mm) + totalLengthM: totalLengthWithAllowance / 1000, // 총 길이 (m) + requiredStandardPipes, // 필요한 표준 파이프 본수 + standardLengthMm, + allowancePerPipe, + totalAllowanceMm, + // 디버깅용 정보 + isGrouped: !!(pipeDetails.pipe_count && pipeDetails.total_length_mm) }; }; - // 파이프 정보 파싱 (기존 상세 로직 복원) + // 파이프 정보 파싱 (개선된 로직) const parsePipeInfo = (material) => { const calc = calculatePipePurchase(material); const pipeDetails = material.pipe_details || {}; + // User 요구사항 추출 (분류기에서 제공된 정보) + const userRequirements = material.user_requirements || []; + const userReqText = userRequirements.length > 0 ? userRequirements.join(', ') : '-'; + return { - type: 'PIPE', - subtype: pipeDetails.manufacturing_method || 'SMLS', + // Type 컬럼 제거 (모두 PIPE로 동일) + type: pipeDetails.manufacturing_method || 'SMLS', // Subtype을 Type으로 변경 size: material.size_spec || '-', schedule: pipeDetails.schedule || material.schedule || '-', grade: material.full_material_grade || material.material_grade || '-', - length: calc.totalLength, - quantity: calc.purchaseCount, - unit: '본', + userRequirements: userReqText, // User 요구사항 + length: calc.totalLengthWithAllowance, // 여유분 포함 총 길이 (mm) + quantity: calc.pipeCount, // 단관 개수 + unit: `${calc.requiredStandardPipes}본`, // 6m 표준 파이프 필요 본수 details: calc, isPipe: true }; @@ -96,18 +132,24 @@ const PipeMaterialsView = ({ return filtered; }; - // 전체 선택/해제 + // 전체 선택/해제 (구매된 자재 제외) const handleSelectAll = () => { const filteredMaterials = getFilteredAndSortedMaterials(); - if (selectedMaterials.size === filteredMaterials.length) { + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + + if (selectedMaterials.size === selectableMaterials.length) { setSelectedMaterials(new Set()); } else { - setSelectedMaterials(new Set(filteredMaterials.map(m => m.id))); + setSelectedMaterials(new Set(selectableMaterials.map(m => m.id))); } }; - // 개별 선택 + // 개별 선택 (구매된 자재는 선택 불가) const handleMaterialSelect = (materialId) => { + if (purchasedMaterials.has(materialId)) { + return; // 구매된 자재는 선택 불가 + } + const newSelected = new Set(selectedMaterials); if (newSelected.has(materialId)) { newSelected.delete(materialId); @@ -291,25 +333,35 @@ const PipeMaterialsView = ({
- {/* 헤더 */} + {/* 테이블 내용 - 헤더와 본문이 함께 스크롤 */}
+ {/* 헤더 */} +
0} + checked={(() => { + const filteredMaterials = getFilteredAndSortedMaterials(); + const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id)); + return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0; + })()} onChange={handleSelectAll} style={{ cursor: 'pointer' }} /> @@ -326,18 +378,6 @@ const PipeMaterialsView = ({ > Type - - Subtype - Material Grade + + User Requirements + - Length (M) + Length (MM) - Quantity + Quantity (EA) -
Unit
-
User Requirement
+
Purchase Unit
+
Additional Request
- {/* 데이터 행들 */} -
+ {/* 데이터 행들 */} +
{filteredMaterials.map((material, index) => { const info = parsePipeInfo(material); const isSelected = selectedMaterials.has(material.id); @@ -414,12 +466,13 @@ const PipeMaterialsView = ({ key={material.id} style={{ display: 'grid', - gridTemplateColumns: '50px 1fr 120px 120px 120px 150px 100px 80px 80px 200px', + gridTemplateColumns: '50px 120px 120px 120px 150px 200px 120px 100px 120px 250px', gap: '16px', padding: '16px', borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none', background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'), - transition: 'background 0.15s ease' + transition: 'background 0.15s ease', + textAlign: 'center' }} onMouseEnter={(e) => { if (!isSelected && !isPurchased) { @@ -437,11 +490,15 @@ const PipeMaterialsView = ({ type="checkbox" checked={isSelected} onChange={() => handleMaterialSelect(material.id)} - style={{ cursor: 'pointer' }} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} />
-
- PIPE +
+ {info.type} {isPurchased && ( )}
-
- {info.subtype} -
{info.size}
@@ -468,13 +522,22 @@ const PipeMaterialsView = ({
{info.grade}
-
- {info.length.toFixed(2)} +
+ {info.userRequirements} +
+
+ {Math.round(info.length).toLocaleString()}
{info.quantity}
-
+
{info.unit}
@@ -485,7 +548,7 @@ const PipeMaterialsView = ({ ...userRequirements, [material.id]: e.target.value })} - placeholder="Enter requirement..." + placeholder="Enter additional request..." style={{ width: '100%', padding: '6px 8px', @@ -498,6 +561,7 @@ const PipeMaterialsView = ({
); })} +
@@ -507,7 +571,6 @@ const PipeMaterialsView = ({ padding: '60px 20px', color: '#64748b' }}> -
🔧
No Pipe Materials Found
diff --git a/frontend/src/components/bom/materials/SupportMaterialsView.jsx b/frontend/src/components/bom/materials/SupportMaterialsView.jsx index 7e4fd51..fcbed20 100644 --- a/frontend/src/components/bom/materials/SupportMaterialsView.jsx +++ b/frontend/src/components/bom/materials/SupportMaterialsView.jsx @@ -359,7 +359,6 @@ const SupportMaterialsView = ({ padding: '60px 20px', color: '#64748b' }}> -
🏗️
No Support Materials Found
diff --git a/frontend/src/components/bom/materials/ValveMaterialsView.jsx b/frontend/src/components/bom/materials/ValveMaterialsView.jsx index f9a0ee8..4d38b31 100644 --- a/frontend/src/components/bom/materials/ValveMaterialsView.jsx +++ b/frontend/src/components/bom/materials/ValveMaterialsView.jsx @@ -385,7 +385,6 @@ const ValveMaterialsView = ({ padding: '60px 20px', color: '#64748b' }}> -
🚰
No Valve Materials Found
diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx index 49bd179..012ba93 100644 --- a/frontend/src/pages/BOMManagementPage.jsx +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -35,13 +35,13 @@ const BOMManagementPage = ({ // 카테고리 정의 const categories = [ - { key: 'PIPE', label: 'Pipes', icon: '🔧', color: '#3b82f6' }, - { key: 'FITTING', label: 'Fittings', icon: '⚙️', color: '#10b981' }, - { key: 'FLANGE', label: 'Flanges', icon: '🔩', color: '#f59e0b' }, - { key: 'VALVE', label: 'Valves', icon: '🚰', color: '#ef4444' }, - { key: 'GASKET', label: 'Gaskets', icon: '⭕', color: '#8b5cf6' }, - { key: 'BOLT', label: 'Bolts', icon: '🔩', color: '#6b7280' }, - { key: 'SUPPORT', label: 'Supports', icon: '🏗️', color: '#f97316' } + { key: 'PIPE', label: 'Pipes', color: '#3b82f6' }, + { key: 'FITTING', label: 'Fittings', color: '#10b981' }, + { key: 'FLANGE', label: 'Flanges', color: '#f59e0b' }, + { key: 'VALVE', label: 'Valves', color: '#ef4444' }, + { key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' }, + { key: 'BOLT', label: 'Bolts', color: '#6b7280' }, + { key: 'SUPPORT', label: 'Supports', color: '#f97316' } ]; // 자료 로드 함수들 @@ -398,9 +398,6 @@ const BOMManagementPage = ({ } }} > -
- {category.icon} -
{category.label}
diff --git a/frontend/src/pages/PurchaseRequestPage.jsx b/frontend/src/pages/PurchaseRequestPage.jsx index 7267af3..7ba92f5 100644 --- a/frontend/src/pages/PurchaseRequestPage.jsx +++ b/frontend/src/pages/PurchaseRequestPage.jsx @@ -13,15 +13,23 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => useEffect(() => { loadRequests(); - }, [fileId, jobNo]); + }, [fileId, jobNo, selectedProject]); const loadRequests = async () => { setIsLoading(true); try { const params = {}; - if (fileId) params.file_id = fileId; - if (jobNo) params.job_no = jobNo; + // 선택된 프로젝트가 있으면 해당 프로젝트만 조회 + if (selectedProject) { + params.job_no = selectedProject.job_no || selectedProject.official_project_code; + } else if (jobNo) { + params.job_no = jobNo; + } + + if (fileId) params.file_id = fileId; + + console.log('🔍 구매신청 목록 조회:', params); const response = await api.get('/purchase-request/list', { params }); setRequests(response.data.requests || []); } catch (error) { diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index f0a0e41..5853956 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -161,13 +161,9 @@ const formatMaterialForExcel = (material, includeComparison = false) => { const manufacturingMethod = pipeDetails.manufacturing_method || ''; const endPreparation = pipeDetails.end_preparation || ''; - // 제조방법과 끝단처리 조합으로 상세 타입 생성 - if (manufacturingMethod && endPreparation) { - itemName = `${manufacturingMethod} PIPE (${endPreparation})`; - } else if (manufacturingMethod) { + // 제조방법만으로 상세 타입 생성 (끝단처리 정보 제거) + if (manufacturingMethod) { itemName = `${manufacturingMethod} PIPE`; - } else if (endPreparation) { - itemName = `PIPE (${endPreparation})`; } else { // description에서 제조방법 추출 시도 const desc = cleanDescription.toUpperCase(); @@ -666,17 +662,17 @@ const formatMaterialForExcel = (material, includeComparison = false) => { // F~O열: 카테고리별 전용 컬럼 구성 (10개 컬럼) if (category === 'PIPE') { - // 파이프 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 + // 파이프 전용 컬럼 (F~O) - 끝단처리, 압력등급 제거 base['크기'] = material.size_spec || '-'; // F열 - base['압력등급'] = pressure; // G열 - base['스케줄'] = schedule; // H열 - base['재질'] = grade; // I열 - base['제조방법'] = material.pipe_details?.manufacturing_method || '-'; // J열 - base['끝단처리'] = material.pipe_details?.end_preparation || '-'; // K열 - base['사용자요구'] = material.user_requirement || ''; // L열 - base['관리항목1'] = ''; // M열 - base['관리항목2'] = ''; // N열 - base['관리항목3'] = ''; // O열 + base['스케줄'] = schedule; // G열 + base['재질'] = grade; // H열 + base['제조방법'] = material.pipe_details?.manufacturing_method || '-'; // I열 + base['사용자요구'] = material.user_requirements?.join(', ') || ''; // J열 (분류기에서 추출) + base['추가요청사항'] = material.user_requirement || ''; // K열 (사용자 입력) + base['관리항목1'] = ''; // L열 + base['관리항목2'] = ''; // M열 + base['관리항목3'] = ''; // N열 + base['관리항목4'] = ''; // O열 } else if (category === 'FITTING') { // 피팅 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 base['크기'] = material.size_spec || '-'; // F열