diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py
index 6450617..d0682e5 100644
--- a/backend/app/routers/files.py
+++ b/backend/app/routers/files.py
@@ -231,6 +231,14 @@ def parse_dataframe(df):
materials = []
for index, row in df.iterrows():
description = str(row.get(mapped_columns.get('description', ''), ''))
+
+ # WELD GAP 항목은 업로드 단계에서 제외 (불필요한 계산용 항목)
+ description_upper = description.upper()
+ if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or
+ '웰드갭' in description_upper or '용접갭' in description_upper):
+ print(f"⚠️ WELD GAP 항목 제외: {description}")
+ continue
+
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
try:
@@ -547,7 +555,7 @@ async def upload_file(
materials_to_insert.append({
"file_id": file_id,
"original_description": material_data["original_description"],
- "classified_category": previous_item.get("category", "UNKNOWN"),
+ "classified_category": previous_item.get("category", "UNCLASSIFIED"),
"confidence": 1.0, # 확정된 자료이므로 신뢰도 100%
"quantity": material_data["quantity"],
"unit": material_data.get("unit", "EA"),
@@ -659,7 +667,7 @@ async def upload_file(
# 1. 통합 분류기로 자재 타입 결정
integrated_result = classify_material_integrated(description, main_nom or "", red_nom or "", length_value)
print(f"[분류] {description}")
- print(f"통합 분류 결과: {integrated_result.get('category', 'UNKNOWN')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})")
+ print(f"통합 분류 결과: {integrated_result.get('category', 'UNCLASSIFIED')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})")
# 2. 제외 대상 확인
if should_exclude_material(description):
@@ -670,7 +678,7 @@ async def upload_file(
}
else:
# 3. 타입별 상세 분류기 실행
- material_type = integrated_result.get('category', 'UNKNOWN')
+ material_type = integrated_result.get('category', 'UNCLASSIFIED')
if material_type == "PIPE":
from ..services.pipe_classifier import classify_pipe_for_purchase
@@ -714,9 +722,9 @@ async def upload_file(
}
}
else:
- # UNKNOWN 처리
+ # UNCLASSIFIED 처리
classification_result = {
- "category": "UNKNOWN",
+ "category": "UNCLASSIFIED",
"overall_confidence": integrated_result.get('confidence', 0.0),
"reason": f"분류 불가: {integrated_result.get('evidence', [])}"
}
@@ -728,7 +736,7 @@ async def upload_file(
integrated_result.get('confidence', 0.0) + 0.2
)
- print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}")
+ print(f"최종 분류 결과: {classification_result.get('category', 'UNCLASSIFIED')}")
# 전체 재질명 추출
from ..services.material_grade_extractor import extract_full_material_grade
@@ -758,7 +766,7 @@ async def upload_file(
print(f"첫 번째 자재 저장:")
print(f" size_spec: '{material_data['size_spec']}'")
print(f" original_description: {material_data['original_description']}")
- print(f" category: {classification_result.get('category', 'UNKNOWN')}")
+ print(f" category: {classification_result.get('category', 'UNCLASSIFIED')}")
print(f" drawing_name: {material_data.get('dwg_name')}")
print(f" line_no: {material_data.get('line_num')}")
@@ -774,7 +782,7 @@ async def upload_file(
"full_material_grade": full_material_grade,
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
- "classified_category": classification_result.get("category", "UNKNOWN"),
+ "classified_category": classification_result.get("category", "UNCLASSIFIED"),
"classification_confidence": classification_result.get("overall_confidence", 0.0),
"is_verified": False,
"drawing_name": material_data.get("dwg_name"),
@@ -889,7 +897,7 @@ async def upload_file(
material_grade_from_classifier = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
- if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN":
+ if material_grade_from_classifier and material_grade_from_classifier not in ["UNKNOWN", "UNCLASSIFIED"]:
material_spec = material_grade_from_classifier
# materials 테이블의 material_grade도 업데이트
@@ -958,7 +966,7 @@ async def upload_file(
material_grade = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
- if material_grade and material_grade != "UNKNOWN":
+ if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
@@ -1055,7 +1063,7 @@ async def upload_file(
material_grade = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
- if material_grade and material_grade != "UNKNOWN":
+ if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
@@ -1245,7 +1253,7 @@ async def upload_file(
material_grade = material_info.get("grade", "")
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
- if material_grade and material_grade != "UNKNOWN":
+ if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]:
db.execute(text("""
UPDATE materials
SET material_grade = :new_material_grade
diff --git a/backend/app/services/exclude_classifier.py b/backend/app/services/exclude_classifier.py
index bc7a622..737e194 100644
--- a/backend/app/services/exclude_classifier.py
+++ b/backend/app/services/exclude_classifier.py
@@ -8,11 +8,6 @@ from typing import Dict, List, Optional
# ========== 제외 대상 타입 ==========
EXCLUDE_TYPES = {
- "WELD_GAP": {
- "description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"],
- "characteristics": "용접 시 수축 고려용 계산 항목",
- "reason": "실제 자재 아님 - 용접 갭 계산용"
- },
"CUTTING_LOSS": {
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
"characteristics": "절단 시 손실 고려용 계산 항목",
diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py
index dd3118f..f3e7628 100644
--- a/backend/app/services/integrated_classifier.py
+++ b/backend/app/services/integrated_classifier.py
@@ -110,6 +110,7 @@ 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):
@@ -295,7 +296,7 @@ def classify_material_integrated(description: str, main_nom: str = "",
# 분류 실패
return {
- "category": "UNKNOWN",
+ "category": "UNCLASSIFIED",
"confidence": 0.0,
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
"classification_level": "NONE"
diff --git a/frontend/PAGES_GUIDE.md b/frontend/PAGES_GUIDE.md
index 9720989..d292939 100644
--- a/frontend/PAGES_GUIDE.md
+++ b/frontend/PAGES_GUIDE.md
@@ -91,10 +91,10 @@
### `BOMManagementPage.jsx`
- **역할**: BOM(Bill of Materials) 통합 관리 페이지
- **기능**:
- - 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT)
+ - 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL)
- 자재 선택 및 구매신청 (엑셀 내보내기)
- 구매신청된 자재 비활성화 표시
- - 사용자 요구사항 입력
+ - 사용자 요구사항 입력 및 저장
- 리비전 관리
- **라우팅**: `/bom-management`
- **접근 권한**: 인증된 사용자
@@ -232,6 +232,25 @@
- `GasketMaterialsView.jsx`: 가스켓 자재 관리
- `BoltMaterialsView.jsx`: 볼트 자재 관리
- `SupportMaterialsView.jsx`: 서포트 자재 관리
+ - `SpecialMaterialsView.jsx`: 특수 제작 자재 관리
+
+#### SPECIAL 카테고리 상세 기능
+`SpecialMaterialsView.jsx`는 특수 제작이 필요한 자재들을 관리하는 컴포넌트입니다:
+
+**주요 기능:**
+- **자동 타입 분류**: FLANGE, OIL PUMP, COMPRESSOR, VALVE, FITTING, PIPE 등 큰 범주 자동 인식
+- **정보 파싱**: 자재 설명을 도면, 항목1-4로 체계적 분리
+- **테이블 구조**: `Type | Drawing | Item 1 | Item 2 | Item 3 | Item 4 | Additional Request | Purchase Quantity`
+- **엑셀 내보내기**: P열 납기일 규칙 준수, 관리항목 자동 채움
+- **저장 기능**: 추가요청사항 저장/편집 (다른 카테고리와 동일)
+
+**처리 예시:**
+- `SAE SPECIAL FF, OIL PUMP, ASTM A105` → Type: OIL PUMP, Item1: SAE SPECIAL FF, Item2: OIL PUMP, Item3: ASTM A105
+- `FLG SPECIAL FF, COMPRESSOR(N11), ASTM A105` → Type: FLANGE, Item1: FLG SPECIAL FF, Item2: COMPRESSOR(N11), Item3: ASTM A105
+
+**분류 조건:**
+- `SPECIAL` 키워드 포함 (단, `SPECIFICATION` 제외)
+- 한글 `스페셜` 또는 `SPL` 키워드 포함
### 기타 컴포넌트
- **NavigationMenu.jsx**: 사이드바 네비게이션
@@ -258,6 +277,16 @@
- **Background**: 글래스 효과 (backdrop-filter: blur)
- **Cards**: 20px 둥근 모서리, 그림자 효과
+### BOM 카테고리 색상
+- **PIPE**: #3b82f6 (파란색)
+- **FITTING**: #10b981 (초록색)
+- **FLANGE**: #f59e0b (주황색)
+- **VALVE**: #ef4444 (빨간색)
+- **GASKET**: #8b5cf6 (보라색)
+- **BOLT**: #6b7280 (회색)
+- **SUPPORT**: #f97316 (주황색)
+- **SPECIAL**: #ec4899 (핑크색)
+
### 반응형 디자인
- **Desktop**: 3-4열 그리드
- **Tablet**: 2열 그리드
@@ -269,5 +298,14 @@
---
-*마지막 업데이트: 2024-10-16*
+*마지막 업데이트: 2024-10-17*
*다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.*
+
+## 최근 업데이트 내역
+
+### 2024-10-17: SPECIAL 카테고리 추가
+- `SpecialMaterialsView.jsx` 컴포넌트 추가
+- 특수 제작 자재 관리 기능 구현
+- 자동 타입 분류 및 정보 파싱 시스템
+- 엑셀 내보내기 규칙 적용 (P열 납기일, 관리항목 자동 채움)
+- BOM 카테고리 색상 팔레트에 SPECIAL (#ec4899) 추가
diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx
index 1953a8a..ff3987a 100644
--- a/frontend/src/components/bom/materials/BoltMaterialsView.jsx
+++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx
@@ -154,7 +154,7 @@ const BoltMaterialsView = ({
let boltGrade = '-';
if (boltDetails.material_standard && boltDetails.material_grade) {
// bolt_details에서 완전한 재질 정보 구성
- if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) {
+ if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) {
boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`;
} else {
boltGrade = boltDetails.material_standard;
@@ -167,7 +167,7 @@ const BoltMaterialsView = ({
// 볼트 타입 (PSV_BOLT, LT_BOLT 등)
let boltSubtype = 'BOLT_GENERAL';
- if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') {
+ if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') {
boltSubtype = boltDetails.bolt_type;
} else {
// 원본 설명에서 특수 볼트 타입 추출
diff --git a/frontend/src/components/bom/materials/FittingMaterialsView.jsx b/frontend/src/components/bom/materials/FittingMaterialsView.jsx
index 660be0c..d36bff3 100644
--- a/frontend/src/components/bom/materials/FittingMaterialsView.jsx
+++ b/frontend/src/components/bom/materials/FittingMaterialsView.jsx
@@ -308,10 +308,10 @@ const FittingMaterialsView = ({
if (hasDifferentSchedules && mainSchedule && redSchedule) {
schedule = `${mainSchedule}×${redSchedule}`;
- } else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') {
+ } else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `${mainSchedule}×${mainSchedule}`;
- } else if (mainSchedule && mainSchedule !== 'UNKNOWN') {
+ } else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') {
schedule = mainSchedule;
} else {
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
diff --git a/frontend/src/components/bom/materials/SpecialMaterialsView.jsx b/frontend/src/components/bom/materials/SpecialMaterialsView.jsx
new file mode 100644
index 0000000..911a78a
--- /dev/null
+++ b/frontend/src/components/bom/materials/SpecialMaterialsView.jsx
@@ -0,0 +1,628 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader } from '../shared';
+
+const SpecialMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ onPurchasedMaterialsUpdate,
+ updateMaterial, // 자재 업데이트 함수
+ jobNo,
+ fileId,
+ user,
+ onNavigate
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+ const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
+ const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
+ const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
+
+ // 컴포넌트 마운트 시 저장된 데이터 로드
+ React.useEffect(() => {
+ const loadSavedData = () => {
+ const savedRequestsData = {};
+
+ materials.forEach(material => {
+ if (material.user_requirement && material.user_requirement.trim()) {
+ savedRequestsData[material.id] = material.user_requirement.trim();
+ }
+ });
+
+ setSavedRequests(savedRequestsData);
+ };
+
+ if (materials && materials.length > 0) {
+ loadSavedData();
+ } else {
+ setSavedRequests({});
+ }
+ }, [materials]);
+
+ // 추가요구사항 저장 함수
+ const handleSaveRequest = async (materialId, request) => {
+ setSavingRequest(prev => ({ ...prev, [materialId]: true }));
+ try {
+ await api.patch(`/materials/${materialId}/user-requirement`, {
+ user_requirement: request.trim()
+ });
+ setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
+ setEditingRequest(prev => ({ ...prev, [materialId]: false }));
+ setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
+
+ if (updateMaterial) {
+ updateMaterial(materialId, { user_requirement: request.trim() });
+ }
+ } catch (error) {
+ console.error('추가요구사항 저장 실패:', error);
+ alert('추가요구사항 저장에 실패했습니다.');
+ } finally {
+ setSavingRequest(prev => ({ ...prev, [materialId]: false }));
+ }
+ };
+
+ // 추가요구사항 편집 시작
+ const handleEditRequest = (materialId, currentRequest) => {
+ setEditingRequest(prev => ({ ...prev, [materialId]: true }));
+ setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
+ };
+
+ // SPECIAL 자재 정보 파싱
+ const parseSpecialInfo = (material) => {
+ const description = material.original_description || '';
+ const qty = Math.round(material.quantity || 0);
+
+ // Type 추출 (큰 범주: 우선순위 기반 분류)
+ let type = 'SPECIAL';
+ const descUpper = description.toUpperCase();
+
+ // 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선)
+ if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) {
+ type = 'OIL PUMP';
+ } else if (descUpper.includes('COMPRESSOR')) {
+ type = 'COMPRESSOR';
+ } else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) {
+ type = 'VALVE';
+ }
+ // 우선순위 2: 구조물/부품 타입
+ else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) {
+ // FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선
+ if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) {
+ if (descUpper.includes('OIL PUMP')) {
+ type = 'OIL PUMP';
+ } else if (descUpper.includes('COMPRESSOR')) {
+ type = 'COMPRESSOR';
+ }
+ } else {
+ type = 'FLANGE';
+ }
+ } else if (descUpper.includes('FITTING')) {
+ type = 'FITTING';
+ } else if (descUpper.includes('PIPE')) {
+ type = 'PIPE';
+ }
+
+ // 도면 정보 (drawing_name 또는 line_no에서 추출)
+ const drawing = material.drawing_name || material.line_no || '-';
+
+ // 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분)
+ const parts = description
+ .split(/[,;|\/]/)
+ .map(part => part.trim())
+ .filter(part => part.length > 0);
+
+ // 최대 4개 항목으로 제한
+ const detail1 = parts[0] || '-';
+ const detail2 = parts[1] || '-';
+ const detail3 = parts[2] || '-';
+ const detail4 = parts[3] || '-';
+
+ return {
+ type,
+ drawing,
+ detail1,
+ detail2,
+ detail3,
+ detail4,
+ quantity: qty,
+ originalQuantity: qty,
+ purchaseQuantity: qty,
+ isSpecial: true
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링 및 정렬된 자료
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ const info = parseSpecialInfo(material);
+
+ // 컬럼 필터 적용
+ for (const [key, filterValue] of Object.entries(columnFilters)) {
+ if (filterValue && filterValue.trim()) {
+ const materialValue = String(info[key] || '').toLowerCase();
+ const filter = filterValue.toLowerCase();
+ if (!materialValue.includes(filter)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ });
+
+ // 정렬 적용
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseSpecialInfo(a);
+ const bInfo = parseSpecialInfo(b);
+
+ const aValue = aInfo[sortConfig.key];
+ const bValue = bInfo[sortConfig.key];
+
+ if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
+ if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
+ return 0;
+ });
+ }
+
+ return filtered;
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ // 전체 선택/해제
+ const handleSelectAll = () => {
+ const selectableMaterials = filteredMaterials.filter(material =>
+ !purchasedMaterials.has(material.id)
+ );
+
+ const allSelected = selectableMaterials.every(material =>
+ selectedMaterials.has(material.id)
+ );
+
+ if (allSelected) {
+ // 전체 해제
+ const newSelected = new Set(selectedMaterials);
+ selectableMaterials.forEach(material => {
+ newSelected.delete(material.id);
+ });
+ setSelectedMaterials(newSelected);
+ } else {
+ // 전체 선택
+ const newSelected = new Set(selectedMaterials);
+ selectableMaterials.forEach(material => {
+ newSelected.add(material.id);
+ });
+ setSelectedMaterials(newSelected);
+ }
+ };
+
+ // 개별 선택/해제
+ const handleMaterialSelect = (materialId) => {
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `SPECIAL_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
+ }));
+
+ try {
+ console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식');
+
+ // 1. 먼저 클라이언트에서 엑셀 파일 생성
+ console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
+ const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
+ category: 'SPECIAL',
+ filename: excelFileName,
+ user: user?.username || 'unknown'
+ });
+
+ // 2. 구매신청 생성
+ console.log('📝 구매신청 생성 중...');
+ const purchaseResponse = await api.post('/purchase-request/create', {
+ materials_data: dataWithRequirements,
+ file_id: fileId,
+ job_no: jobNo
+ });
+
+ console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
+
+ // 3. 엑셀 파일을 서버에 업로드
+ console.log('📤 엑셀 파일 서버 업로드 중...');
+ const formData = new FormData();
+ formData.append('excel_file', excelBlob, excelFileName);
+ formData.append('request_id', purchaseResponse.data.request_id);
+ formData.append('filename', excelFileName);
+
+ const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+
+ console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
+
+ // 4. 구매신청된 자재들을 비활성화
+ const purchasedIds = selectedMaterialsData.map(m => m.id);
+ onPurchasedMaterialsUpdate(purchasedIds);
+
+ // 5. 선택 해제
+ setSelectedMaterials(new Set());
+
+ // 6. 클라이언트에서도 다운로드
+ 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);
+
+ console.log('✅ 스페셜 엑셀 내보내기 완료');
+ alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
+
+ // 구매신청 관리 페이지로 이동
+ if (onNavigate) {
+ onNavigate('purchase-requests');
+ }
+
+ } catch (error) {
+ console.error('❌ 스페셜 엑셀 내보내기 실패:', error);
+ alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
+ }
+ };
+
+ const allSelected = filteredMaterials.length > 0 &&
+ filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
+ .every(material => selectedMaterials.has(material.id));
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Special Items
+
+
+ 특수 제작 품목 관리 ({filteredMaterials.length}개)
+
+
+
+
+
+ {/* 테이블 */}
+
+
+ {/* 헤더 */}
+
+
+
+
+
+ Type
+
+
+ Drawing
+
+
+ Detail 1
+
+
+ Detail 2
+
+
+ Detail 3
+
+
+ Detail 4
+
+
Additional Request
+
Purchase Quantity
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseSpecialInfo(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.type}
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
{info.drawing}
+
{info.detail1}
+
{info.detail2}
+
{info.detail3}
+
{info.detail4}
+
+ {!editingRequest[material.id] && savedRequests[material.id] ? (
+ // 저장된 상태 - 요구사항 표시 + 수정 버튼
+ <>
+
+ {savedRequests[material.id]}
+
+
+ >
+ ) : (
+ // 편집 상태 - 입력 필드 + 저장 버튼
+ <>
+
setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter additional request..."
+ disabled={isPurchased}
+ style={{
+ flex: 1,
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px',
+ opacity: isPurchased ? 0.5 : 1,
+ cursor: isPurchased ? 'not-allowed' : 'text'
+ }}
+ />
+
+ >
+ )}
+
+
+ {info.purchaseQuantity}
+
+
+ );
+ })}
+
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
📋
+
스페셜 자재가 없습니다
+
특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.
+
+ )}
+
+ );
+};
+
+export default SpecialMaterialsView;
diff --git a/frontend/src/components/bom/materials/UnclassifiedMaterialsView.jsx b/frontend/src/components/bom/materials/UnclassifiedMaterialsView.jsx
new file mode 100644
index 0000000..40565d5
--- /dev/null
+++ b/frontend/src/components/bom/materials/UnclassifiedMaterialsView.jsx
@@ -0,0 +1,561 @@
+import React, { useState } from 'react';
+import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
+import api from '../../../api';
+import { FilterableHeader } from '../shared';
+
+const UnclassifiedMaterialsView = ({
+ materials,
+ selectedMaterials,
+ setSelectedMaterials,
+ userRequirements,
+ setUserRequirements,
+ purchasedMaterials,
+ onPurchasedMaterialsUpdate,
+ updateMaterial, // 자재 업데이트 함수
+ jobNo,
+ fileId,
+ user,
+ onNavigate
+}) => {
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
+ const [columnFilters, setColumnFilters] = useState({});
+ const [showFilterDropdown, setShowFilterDropdown] = useState(null);
+ const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태
+ const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드
+ const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태
+
+ // 컴포넌트 마운트 시 저장된 데이터 로드
+ React.useEffect(() => {
+ const loadSavedData = () => {
+ const savedRequestsData = {};
+
+ materials.forEach(material => {
+ if (material.user_requirement && material.user_requirement.trim()) {
+ savedRequestsData[material.id] = material.user_requirement.trim();
+ }
+ });
+
+ setSavedRequests(savedRequestsData);
+ };
+
+ if (materials && materials.length > 0) {
+ loadSavedData();
+ } else {
+ setSavedRequests({});
+ }
+ }, [materials]);
+
+ // 추가요구사항 저장 함수
+ const handleSaveRequest = async (materialId, request) => {
+ setSavingRequest(prev => ({ ...prev, [materialId]: true }));
+ try {
+ await api.patch(`/materials/${materialId}/user-requirement`, {
+ user_requirement: request.trim()
+ });
+ setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() }));
+ setEditingRequest(prev => ({ ...prev, [materialId]: false }));
+ setUserRequirements(prev => ({ ...prev, [materialId]: '' }));
+
+ if (updateMaterial) {
+ updateMaterial(materialId, { user_requirement: request.trim() });
+ }
+ } catch (error) {
+ console.error('추가요구사항 저장 실패:', error);
+ alert('추가요구사항 저장에 실패했습니다.');
+ } finally {
+ setSavingRequest(prev => ({ ...prev, [materialId]: false }));
+ }
+ };
+
+ // 추가요구사항 편집 시작
+ const handleEditRequest = (materialId, currentRequest) => {
+ setEditingRequest(prev => ({ ...prev, [materialId]: true }));
+ setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' }));
+ };
+
+ // 미분류 자재 정보 파싱 (원본 그대로 표시)
+ const parseUnclassifiedInfo = (material) => {
+ const description = material.original_description || material.description || '';
+ const qty = Math.round(material.quantity || 0);
+
+ return {
+ description: description || '-',
+ size: material.main_nom || material.size_spec || '-',
+ drawing: material.drawing_name || material.line_no || '-',
+ lineNo: material.line_no || '-',
+ quantity: qty,
+ originalQuantity: qty,
+ purchaseQuantity: qty,
+ isUnclassified: true
+ };
+ };
+
+ // 정렬 처리
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ // 필터링 및 정렬된 자료
+ const getFilteredAndSortedMaterials = () => {
+ let filtered = materials.filter(material => {
+ const info = parseUnclassifiedInfo(material);
+
+ // 컬럼 필터 적용
+ for (const [key, filterValue] of Object.entries(columnFilters)) {
+ if (filterValue && filterValue.trim()) {
+ const materialValue = String(info[key] || '').toLowerCase();
+ const filter = filterValue.toLowerCase();
+ if (!materialValue.includes(filter)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ });
+
+ // 정렬 적용
+ if (sortConfig.key) {
+ filtered.sort((a, b) => {
+ const aInfo = parseUnclassifiedInfo(a);
+ const bInfo = parseUnclassifiedInfo(b);
+
+ const aValue = aInfo[sortConfig.key];
+ const bValue = bInfo[sortConfig.key];
+
+ if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
+ if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
+ return 0;
+ });
+ }
+
+ return filtered;
+ };
+
+ const filteredMaterials = getFilteredAndSortedMaterials();
+
+ // 전체 선택/해제
+ const handleSelectAll = () => {
+ const selectableMaterials = filteredMaterials.filter(material =>
+ !purchasedMaterials.has(material.id)
+ );
+
+ const allSelected = selectableMaterials.every(material =>
+ selectedMaterials.has(material.id)
+ );
+
+ if (allSelected) {
+ // 전체 해제
+ const newSelected = new Set(selectedMaterials);
+ selectableMaterials.forEach(material => {
+ newSelected.delete(material.id);
+ });
+ setSelectedMaterials(newSelected);
+ } else {
+ // 전체 선택
+ const newSelected = new Set(selectedMaterials);
+ selectableMaterials.forEach(material => {
+ newSelected.add(material.id);
+ });
+ setSelectedMaterials(newSelected);
+ }
+ };
+
+ // 개별 선택/해제
+ const handleMaterialSelect = (materialId) => {
+ const newSelected = new Set(selectedMaterials);
+ if (newSelected.has(materialId)) {
+ newSelected.delete(materialId);
+ } else {
+ newSelected.add(materialId);
+ }
+ setSelectedMaterials(newSelected);
+ };
+
+ // 엑셀 내보내기
+ const handleExportToExcel = async () => {
+ const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id));
+ if (selectedMaterialsData.length === 0) {
+ alert('내보낼 자재를 선택해주세요.');
+ return;
+ }
+
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
+ const excelFileName = `UNCLASSIFIED_Materials_${timestamp}.xlsx`;
+
+ const dataWithRequirements = selectedMaterialsData.map(material => ({
+ ...material,
+ user_requirement: savedRequests[material.id] || userRequirements[material.id] || ''
+ }));
+
+ try {
+ console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식');
+
+ // 1. 먼저 클라이언트에서 엑셀 파일 생성
+ console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
+ const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
+ category: 'UNCLASSIFIED',
+ filename: excelFileName,
+ user: user?.username || 'unknown'
+ });
+
+ // 2. 구매신청 생성
+ console.log('📝 구매신청 생성 중...');
+ const purchaseResponse = await api.post('/purchase-request/create', {
+ materials_data: dataWithRequirements,
+ file_id: fileId,
+ job_no: jobNo
+ });
+
+ console.log('✅ 구매신청 생성 완료:', purchaseResponse.data);
+
+ // 3. 엑셀 파일을 서버에 업로드
+ console.log('📤 엑셀 파일 서버 업로드 중...');
+ const formData = new FormData();
+ formData.append('excel_file', excelBlob, excelFileName);
+ formData.append('request_id', purchaseResponse.data.request_id);
+ formData.append('filename', excelFileName);
+
+ const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+
+ console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
+
+ // 4. 구매신청된 자재들을 비활성화
+ const purchasedIds = selectedMaterialsData.map(m => m.id);
+ onPurchasedMaterialsUpdate(purchasedIds);
+
+ // 5. 선택 해제
+ setSelectedMaterials(new Set());
+
+ // 6. 클라이언트에서도 다운로드
+ 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);
+
+ console.log('✅ 미분류 엑셀 내보내기 완료');
+ alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`);
+
+ // 구매신청 관리 페이지로 이동
+ if (onNavigate) {
+ onNavigate('purchase-requests');
+ }
+
+ } catch (error) {
+ console.error('❌ 미분류 엑셀 내보내기 실패:', error);
+ alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
+ }
+ };
+
+ const allSelected = filteredMaterials.length > 0 &&
+ filteredMaterials.filter(material => !purchasedMaterials.has(material.id))
+ .every(material => selectedMaterials.has(material.id));
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+ Unclassified Materials
+
+
+ 분류되지 않은 자재 관리 ({filteredMaterials.length}개)
+
+
+
+
+
+ {/* 테이블 */}
+
+
+ {/* 헤더 */}
+
+
+
+
+
+ Description
+
+
+ Size
+
+
+ Drawing
+
+
+ Line No
+
+
Additional Request
+
Purchase Quantity
+
+
+ {/* 데이터 행들 */}
+
+ {filteredMaterials.map((material, index) => {
+ const info = parseUnclassifiedInfo(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.description}
+ {isPurchased && (
+
+ PURCHASED
+
+ )}
+
+
{info.size}
+
{info.drawing}
+
{info.lineNo}
+
+ {!editingRequest[material.id] && savedRequests[material.id] ? (
+ // 저장된 상태 - 요구사항 표시 + 수정 버튼
+ <>
+
+ {savedRequests[material.id]}
+
+
+ >
+ ) : (
+ // 편집 상태 - 입력 필드 + 저장 버튼
+ <>
+
setUserRequirements({
+ ...userRequirements,
+ [material.id]: e.target.value
+ })}
+ placeholder="Enter additional request..."
+ disabled={isPurchased}
+ style={{
+ flex: 1,
+ padding: '6px 8px',
+ border: '1px solid #d1d5db',
+ borderRadius: '4px',
+ fontSize: '12px',
+ opacity: isPurchased ? 0.5 : 1,
+ cursor: isPurchased ? 'not-allowed' : 'text'
+ }}
+ />
+
+ >
+ )}
+
+
+ {info.purchaseQuantity}
+
+
+ );
+ })}
+
+
+
+
+ {filteredMaterials.length === 0 && (
+
+
❓
+
미분류 자재가 없습니다
+
분류되지 않은 자재가 발견되면 여기에 표시됩니다.
+
+ )}
+
+ );
+};
+
+export default UnclassifiedMaterialsView;
diff --git a/frontend/src/components/bom/materials/index.js b/frontend/src/components/bom/materials/index.js
index bec7cda..c8a1b00 100644
--- a/frontend/src/components/bom/materials/index.js
+++ b/frontend/src/components/bom/materials/index.js
@@ -6,3 +6,5 @@ export { default as ValveMaterialsView } from './ValveMaterialsView';
export { default as GasketMaterialsView } from './GasketMaterialsView';
export { default as BoltMaterialsView } from './BoltMaterialsView';
export { default as SupportMaterialsView } from './SupportMaterialsView';
+export { default as SpecialMaterialsView } from './SpecialMaterialsView';
+export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView';
diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx
index 3e30a15..2aed5d5 100644
--- a/frontend/src/pages/BOMManagementPage.jsx
+++ b/frontend/src/pages/BOMManagementPage.jsx
@@ -8,7 +8,9 @@ import {
ValveMaterialsView,
GasketMaterialsView,
BoltMaterialsView,
- SupportMaterialsView
+ SupportMaterialsView,
+ SpecialMaterialsView,
+ UnclassifiedMaterialsView
} from '../components/bom';
import './BOMManagementPage.css';
@@ -52,7 +54,9 @@ const BOMManagementPage = ({
{ key: 'VALVE', label: 'Valves', color: '#ef4444' },
{ key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' },
{ key: 'BOLT', label: 'Bolts', color: '#6b7280' },
- { key: 'SUPPORT', label: 'Supports', color: '#f97316' }
+ { key: 'SUPPORT', label: 'Supports', color: '#f97316' },
+ { key: 'SPECIAL', label: 'Special Items', color: '#ec4899' },
+ { key: 'UNCLASSIFIED', label: 'Unclassified', color: '#64748b' }
];
// 자료 로드 함수들
@@ -208,6 +212,10 @@ const BOMManagementPage = ({
return ;
case 'SUPPORT':
return ;
+ case 'SPECIAL':
+ return ;
+ case 'UNCLASSIFIED':
+ return ;
default:
return 카테고리를 선택해주세요.
;
}
diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css
index b36e157..28644b2 100644
--- a/frontend/src/pages/NewMaterialsPage.css
+++ b/frontend/src/pages/NewMaterialsPage.css
@@ -874,7 +874,7 @@
background: #d97706;
}
-/* UNKNOWN 전용 헤더 - 5개 컬럼 */
+/* UNCLASSIFIED 전용 헤더 - 5개 컬럼 */
.detailed-grid-header.unknown-header {
grid-template-columns: 5% 10% 1fr 20% 10%;
@@ -891,7 +891,7 @@
border-right: none;
}
-/* UNKNOWN 전용 행 - 5개 컬럼 */
+/* UNCLASSIFIED 전용 행 - 5개 컬럼 */
.detailed-material-row.unknown-row {
grid-template-columns: 5% 10% 1fr 20% 10%;
@@ -906,7 +906,7 @@
border-right: none;
}
-/* UNKNOWN 설명 셀 스타일 */
+/* UNCLASSIFIED 설명 셀 스타일 */
.description-cell {
overflow: visible;
text-overflow: initial;
diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx
index 11f54a8..436512d 100644
--- a/frontend/src/pages/NewMaterialsPage.jsx
+++ b/frontend/src/pages/NewMaterialsPage.jsx
@@ -433,7 +433,7 @@ const NewMaterialsPage = ({
const getCategoryCounts = () => {
const counts = {};
materials.forEach(material => {
- const category = material.classified_category || 'UNKNOWN';
+ const category = material.classified_category || 'UNCLASSIFIED';
counts[category] = (counts[category] || 0) + 1;
});
return counts;
@@ -476,7 +476,7 @@ const NewMaterialsPage = ({
'BOLT': 'BOLT',
'GASKET': 'GASKET',
'INSTRUMENT': 'INSTRUMENT',
- 'UNKNOWN': 'UNKNOWN'
+ 'UNCLASSIFIED': 'UNCLASSIFIED'
};
return categoryMap[category] || category;
};
@@ -1090,17 +1090,17 @@ const NewMaterialsPage = ({
unit: '개',
isSpecial: true
};
- } else if (category === 'UNKNOWN') {
+ } else if (category === 'UNCLASSIFIED') {
return {
- type: 'UNKNOWN',
- description: material.original_description || 'Unknown Item',
+ type: 'UNCLASSIFIED',
+ description: material.original_description || 'Unclassified Item',
quantity: Math.round(material.quantity || 0),
unit: '개',
isUnknown: true
};
} else {
return {
- type: category || 'UNKNOWN',
+ type: category || 'UNCLASSIFIED',
subtype: '-',
size: material.size_spec || '-',
schedule: '-',
@@ -1939,7 +1939,7 @@ const NewMaterialsPage = ({
사용자요구
수량
- ) : selectedCategory === 'UNKNOWN' ? (
+ ) : selectedCategory === 'UNCLASSIFIED' ? (
선택
종류
@@ -2464,7 +2464,7 @@ const NewMaterialsPage = ({
);
}
- if (material.classified_category === 'UNKNOWN') {
+ if (material.classified_category === 'UNCLASSIFIED') {
// 구매신청 여부 확인
const isPurchased = material.grouped_ids ?
material.grouped_ids.some(id => purchasedMaterials.has(id)) :
diff --git a/frontend/src/pages/PurchaseRequestPage.jsx b/frontend/src/pages/PurchaseRequestPage.jsx
index 7ba92f5..7bf60d4 100644
--- a/frontend/src/pages/PurchaseRequestPage.jsx
+++ b/frontend/src/pages/PurchaseRequestPage.jsx
@@ -339,7 +339,7 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
{(() => {
// 카테고리별로 자재 그룹화
const groupedByCategory = requestMaterials.reduce((acc, material) => {
- const category = material.category || material.classified_category || 'UNKNOWN';
+ const category = material.category || material.classified_category || 'UNCLASSIFIED';
if (!acc[category]) acc[category] = [];
acc[category].push(material);
return acc;
diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js
index 30b4de3..8ebdde4 100644
--- a/frontend/src/utils/excelExport.js
+++ b/frontend/src/utils/excelExport.js
@@ -522,7 +522,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
// BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직
let boltSubtype = 'BOLT_GENERAL';
- if (boltType && boltType !== 'UNKNOWN') {
+ if (boltType && boltType !== 'UNKNOWN' && boltType !== 'UNCLASSIFIED') {
boltSubtype = boltType;
} else {
// 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일)
@@ -565,8 +565,29 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
} else {
itemName = 'SUPPORT';
}
+ } else if (category === 'SPECIAL') {
+ // 스페셜 자재 타입 추출 (큰 범주)
+ const desc = cleanDescription.toUpperCase();
+ if (desc.includes('FLG') || desc.includes('FLANGE')) {
+ itemName = 'SPECIAL FLANGE';
+ } else if (desc.includes('OIL PUMP') || desc.includes('PUMP')) {
+ itemName = 'SPECIAL OIL PUMP';
+ } else if (desc.includes('COMPRESSOR')) {
+ itemName = 'SPECIAL COMPRESSOR';
+ } else if (desc.includes('VALVE')) {
+ itemName = 'SPECIAL VALVE';
+ } else if (desc.includes('FITTING')) {
+ itemName = 'SPECIAL FITTING';
+ } else if (desc.includes('PIPE')) {
+ itemName = 'SPECIAL PIPE';
+ } else {
+ itemName = 'SPECIAL ITEM';
+ }
+ } else if (category === 'UNCLASSIFIED') {
+ // 미분류 자재는 원본 설명을 품목명으로 사용
+ itemName = cleanDescription || 'UNCLASSIFIED ITEM';
} else {
- itemName = category || 'UNKNOWN';
+ itemName = category || 'UNCLASSIFIED';
}
// 압력 등급 추출 (카테고리별 처리)
@@ -950,6 +971,36 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
base['관리항목4'] = ''; // M열
base['관리항목5'] = ''; // N열
base['관리항목6'] = ''; // O열
+ } else if (category === 'SPECIAL') {
+ // 스페셜 전용 컬럼 (F~O) - Type은 품목명에 포함, 나머지는 항목별로 분리
+ const description = cleanDescription || '';
+ const parts = description
+ .split(/[,;|\/]/)
+ .map(part => part.trim())
+ .filter(part => part.length > 0);
+
+ base['도면'] = material.drawing_name || material.line_no || '-'; // F열
+ base['상세1'] = parts[0] || '-'; // G열
+ base['상세2'] = parts[1] || '-'; // H열
+ base['상세3'] = parts[2] || '-'; // I열
+ base['상세4'] = parts[3] || '-'; // J열
+ base['추가요청사항'] = material.user_requirement || ''; // K열
+ base['관리항목1'] = ''; // L열
+ base['관리항목2'] = ''; // M열
+ base['관리항목3'] = ''; // N열
+ base['관리항목4'] = ''; // O열
+ } else if (category === 'UNCLASSIFIED') {
+ // 미분류 전용 컬럼 (F~O) - 원본 정보 그대로 표시
+ base['크기'] = material.main_nom || material.size_spec || '-'; // F열
+ base['도면'] = material.drawing_name || '-'; // G열
+ base['라인번호'] = material.line_no || '-'; // H열
+ base['추가요청사항'] = material.user_requirement || ''; // I열
+ base['관리항목1'] = ''; // J열
+ base['관리항목2'] = ''; // K열
+ base['관리항목3'] = ''; // L열
+ base['관리항목4'] = ''; // M열
+ base['관리항목5'] = ''; // N열
+ base['관리항목6'] = ''; // O열
} else {
// 기타 카테고리 기본 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
base['크기'] = material.size_spec || '-'; // F열
@@ -1213,6 +1264,8 @@ export const createExcelBlob = async (materials, filename, options = {}) => {
'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '추가요청사항', '관리항목1', '관리항목2'],
'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
'SUPPORT': ['크기', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'],
+ 'SPECIAL': ['도면', '상세1', '상세2', '상세3', '상세4', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'],
+ 'UNCLASSIFIED': ['크기', '도면', '라인번호', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'],
};
const deliveryDateHeader = '납기일(YYYY-MM-DD)';