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열