feat: 서포트 카테고리 전면 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 서포트 카테고리 UI 개선: 좌우 스크롤, 헤더/본문 동기화, 가운데 정렬 - 동일 항목 합산 기능 구현 (Type + Size + Grade 기준) - 헤더 구조 변경: 압력/스케줄 제거, 구매수량 단일화, User Requirements 추가 - 우레탄 블럭슈 두께 정보(40t, 27t) Material Grade에 포함 - 서포트 수량 계산 수정: 취합된 숫자 그대로 표시 (4의 배수 계산 제거) - 서포트 분류 로직 개선: CLAMP, U-BOLT, URETHANE BLOCK SHOE 등 정확한 분류 - 백엔드 서포트 분류기에 User Requirements 추출 기능 추가 - 엑셀 내보내기에 서포트 카테고리 처리 로직 추가
This commit is contained in:
@@ -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 = ({
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
overflow: 'auto',
|
||||
maxHeight: '600px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
<div style={{ minWidth: '1500px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -343,18 +428,83 @@ const BoltMaterialsView = ({
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
|
||||
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
|
||||
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
|
||||
<FilterableHeader sortKey="schedule" filterKey="schedule">Length</FilterableHeader>
|
||||
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
|
||||
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
|
||||
<div>Unit</div>
|
||||
<div>User Requirement</div>
|
||||
</div>
|
||||
<FilterableHeader
|
||||
sortKey="subtype"
|
||||
filterKey="subtype"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="pressure"
|
||||
filterKey="pressure"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Pressure
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="schedule"
|
||||
filterKey="schedule"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Length
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
||||
{/* 데이터 행들 */}
|
||||
{filteredMaterials.map((material, index) => {
|
||||
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 = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
||||
{info.unit}
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{info.userRequirements}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -437,7 +588,7 @@ const BoltMaterialsView = ({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter requirement..."
|
||||
placeholder="Enter additional request..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
@@ -447,6 +598,9 @@ const BoltMaterialsView = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
|
||||
{info.purchaseQuantity}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 = ({
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
maxHeight: '600px'
|
||||
}}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
<div style={{ minWidth: '1200px' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(() => {
|
||||
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' }}
|
||||
/>
|
||||
</div>
|
||||
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
|
||||
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
|
||||
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
|
||||
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
|
||||
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
|
||||
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
|
||||
<div>Unit</div>
|
||||
<div>User Requirement</div>
|
||||
<FilterableHeader
|
||||
sortKey="type"
|
||||
filterKey="type"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Type
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="size"
|
||||
filterKey="size"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Size
|
||||
</FilterableHeader>
|
||||
<FilterableHeader
|
||||
sortKey="grade"
|
||||
filterKey="grade"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Material Grade
|
||||
</FilterableHeader>
|
||||
<div>User Requirements</div>
|
||||
<div>Additional Request</div>
|
||||
<FilterableHeader
|
||||
sortKey="purchaseQuantity"
|
||||
filterKey="purchaseQuantity"
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
columnFilters={columnFilters}
|
||||
onFilterChange={setColumnFilters}
|
||||
showFilterDropdown={showFilterDropdown}
|
||||
setShowFilterDropdown={setShowFilterDropdown}
|
||||
>
|
||||
Purchase Quantity
|
||||
</FilterableHeader>
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
||||
{filteredMaterials.map((material, index) => {
|
||||
const info = parseSupportInfo(material);
|
||||
const isSelected = selectedMaterials.has(material.id);
|
||||
const isPurchased = purchasedMaterials.has(material.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected && !isPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
disabled={isPurchased}
|
||||
style={{
|
||||
cursor: isPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: isPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.subtype}
|
||||
{isPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.pressure}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.schedule}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>
|
||||
{info.grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
|
||||
{info.quantity}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280' }}>
|
||||
{info.unit}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[material.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter requirement..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{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 (
|
||||
<div
|
||||
key={`consolidated-${index}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '50px 200px 120px 150px 180px 150px 200px',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
background: allSelected ? '#eff6ff' : (hasAnyPurchased ? '#fef3c7' : 'white'),
|
||||
transition: 'background 0.15s ease',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!allSelected && !hasAnyPurchased) {
|
||||
e.target.style.background = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!allSelected && !hasAnyPurchased) {
|
||||
e.target.style.background = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => handleMaterialSelect(consolidatedMaterial)}
|
||||
disabled={hasAnyPurchased}
|
||||
style={{
|
||||
cursor: hasAnyPurchased ? 'not-allowed' : 'pointer',
|
||||
opacity: hasAnyPurchased ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
|
||||
{info.type}
|
||||
{hasAnyPurchased && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
background: '#fbbf24',
|
||||
color: '#92400e',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
PURCHASED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.size}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.grade}</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.userRequirements}</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userRequirements[consolidatedMaterial.id] || ''}
|
||||
onChange={(e) => setUserRequirements({
|
||||
...userRequirements,
|
||||
[consolidatedMaterial.id]: e.target.value
|
||||
})}
|
||||
placeholder="Enter additional request..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#1f2937' }}>{info.purchaseQuantity}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user