파이프 길이 계산 및 엑셀 내보내기 버그 수정

- 자재 비교에서 파이프 길이 합산 로직 수정
- 프론트엔드에서 혼란스러운 '평균단위' 표시 제거
- 파이프 변경사항에 실제 이전/현재 총길이 표시
- 엑셀 내보내기에서 '초기화되지 않은 변수' 오류 수정
- 리비전 비교에서 파이프 길이 변화 계산 개선
This commit is contained in:
Hyungi Ahn
2025-07-23 08:12:19 +09:00
parent bef0d8bf7c
commit 2d178f8161
6 changed files with 705 additions and 60 deletions

View File

@@ -386,7 +386,7 @@ async def perform_material_comparison(
if material_hash not in previous_materials:
# 완전히 새로운 항목
new_items.append({
new_item = {
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
@@ -394,7 +394,11 @@ async def perform_material_comparison(
"quantity": current_qty,
"category": current_item["category"],
"unit": current_item["unit"]
})
}
# 파이프인 경우 pipe_details 정보 포함
if current_item.get("pipe_details"):
new_item["pipe_details"] = current_item["pipe_details"]
new_items.append(new_item)
else:
# 기존 항목 - 수량 변경 체크
@@ -402,7 +406,7 @@ async def perform_material_comparison(
qty_change = current_qty - previous_qty
if qty_change != 0:
modified_items.append({
modified_item = {
"material_hash": material_hash,
"description": current_item["description"],
"size_spec": current_item["size_spec"],
@@ -412,12 +416,30 @@ async def perform_material_comparison(
"quantity_change": qty_change,
"category": current_item["category"],
"unit": current_item["unit"]
})
}
# 파이프인 경우 이전/현재 pipe_details 모두 포함
if current_item.get("pipe_details"):
modified_item["pipe_details"] = current_item["pipe_details"]
# 이전 리비전 pipe_details도 포함
previous_item = previous_materials[material_hash]
if previous_item.get("pipe_details"):
modified_item["previous_pipe_details"] = previous_item["pipe_details"]
# 실제 길이 변화 계산 (현재 총길이 - 이전 총길이)
if current_item.get("pipe_details"):
current_total = current_item["pipe_details"]["total_length_mm"]
previous_total = previous_item["pipe_details"]["total_length_mm"]
length_change = current_total - previous_total
modified_item["length_change"] = length_change
print(f"🔢 실제 길이 변화: {current_item['description'][:50]} - 이전:{previous_total:.0f}mm → 현재:{current_total:.0f}mm (변화:{length_change:+.0f}mm)")
modified_items.append(modified_item)
# 삭제된 항목 찾기
for material_hash, previous_item in previous_materials.items():
if material_hash not in current_materials:
removed_items.append({
removed_item = {
"material_hash": material_hash,
"description": previous_item["description"],
"size_spec": previous_item["size_spec"],
@@ -425,7 +447,11 @@ async def perform_material_comparison(
"quantity": previous_item["quantity"],
"category": previous_item["category"],
"unit": previous_item["unit"]
})
}
# 파이프인 경우 pipe_details 정보 포함
if previous_item.get("pipe_details"):
removed_item["pipe_details"] = previous_item["pipe_details"]
removed_items.append(removed_item)
return {
"summary": {
@@ -444,37 +470,110 @@ async def get_materials_by_hash(db: Session, file_id: int) -> Dict[str, Dict]:
"""파일의 자재를 해시별로 그룹화하여 조회"""
import hashlib
print(f"🚨🚨🚨 get_materials_by_hash 호출됨! file_id={file_id} 🚨🚨🚨")
query = text("""
SELECT
original_description,
size_spec,
material_grade,
SUM(quantity) as quantity,
classified_category,
unit
FROM materials
WHERE file_id = :file_id
GROUP BY original_description, size_spec, material_grade, classified_category, unit
m.id,
m.original_description,
m.size_spec,
m.material_grade,
m.quantity,
m.classified_category,
m.unit,
pd.length_mm,
m.line_number
FROM materials m
LEFT JOIN pipe_details pd ON m.id = pd.material_id
WHERE m.file_id = :file_id
ORDER BY m.line_number
""")
result = db.execute(query, {"file_id": file_id})
materials = result.fetchall()
print(f"🔍 쿼리 결과 개수: {len(materials)}")
if len(materials) > 0:
print(f"🔍 첫 번째 자료 샘플: {materials[0]}")
else:
print(f"❌ 자료가 없음! file_id={file_id}")
# 🔄 같은 파이프들을 Python에서 올바르게 그룹핑
materials_dict = {}
for mat in materials:
# 자재 해시 생성 (description + size_spec + material_grade)
hash_source = f"{mat[0] or ''}|{mat[1] or ''}|{mat[2] or ''}"
hash_source = f"{mat[1] or ''}|{mat[2] or ''}|{mat[3] or ''}"
material_hash = hashlib.md5(hash_source.encode()).hexdigest()
materials_dict[material_hash] = {
"material_hash": material_hash,
"description": mat[0], # original_description -> description
"size_spec": mat[1],
"material_grade": mat[2],
"quantity": float(mat[3]) if mat[3] else 0.0,
"category": mat[4], # classified_category -> category
"unit": mat[5] or 'EA'
}
print(f"📝 개별 자재: {mat[1][:50]}... ({mat[2]}) - 수량: {mat[4]}, 길이: {mat[7]}mm")
if material_hash in materials_dict:
# 🔄 기존 항목에 수량 합계
existing = materials_dict[material_hash]
existing["quantity"] += float(mat[4]) if mat[4] else 0.0
existing["line_number"] += f", {mat[8]}" if mat[8] else ""
# 파이프인 경우 길이 정보 합산
if mat[5] == 'PIPE' and mat[7] is not None:
if "pipe_details" in existing:
# 총길이 합산: 기존 총길이 + (현재 수량 × 현재 길이)
current_total = existing["pipe_details"]["total_length_mm"]
current_count = existing["pipe_details"]["pipe_count"]
new_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
existing["pipe_details"]["total_length_mm"] = current_total + new_length
existing["pipe_details"]["pipe_count"] = current_count + float(mat[4])
# 평균 단위 길이 재계산
total_length = existing["pipe_details"]["total_length_mm"]
total_count = existing["pipe_details"]["pipe_count"]
existing["pipe_details"]["length_mm"] = total_length / total_count
print(f"🔄 파이프 합산: {mat[1]} ({mat[2]}) - 총길이: {total_length}mm, 총개수: {total_count}개, 평균: {total_length/total_count:.1f}mm")
else:
# 첫 파이프 정보 설정
pipe_length = float(mat[4]) * float(mat[7])
existing["pipe_details"] = {
"length_mm": float(mat[7]),
"total_length_mm": pipe_length,
"pipe_count": float(mat[4])
}
else:
# 🆕 새 항목 생성
material_data = {
"material_hash": material_hash,
"description": mat[1], # original_description
"size_spec": mat[2],
"material_grade": mat[3],
"quantity": float(mat[4]) if mat[4] else 0.0,
"category": mat[5], # classified_category
"unit": mat[6] or 'EA',
"line_number": str(mat[8]) if mat[8] else ''
}
# 파이프인 경우 pipe_details 정보 추가
if mat[5] == 'PIPE' and mat[7] is not None:
pipe_length = float(mat[4]) * float(mat[7]) # 수량 × 단위길이
material_data["pipe_details"] = {
"length_mm": float(mat[7]), # 단위 길이
"total_length_mm": pipe_length, # 총 길이
"pipe_count": float(mat[4]) # 파이프 개수
}
print(f"🆕 파이프 신규: {mat[1]} ({mat[2]}) - 단위: {mat[7]}mm, 총길이: {pipe_length}mm")
materials_dict[material_hash] = material_data
# 파이프 데이터가 포함되었는지 확인
pipe_count = sum(1 for data in materials_dict.values() if data.get('category') == 'PIPE')
pipe_with_details = sum(1 for data in materials_dict.values()
if data.get('category') == 'PIPE' and 'pipe_details' in data)
print(f"🔍 반환 결과: 총 {len(materials_dict)}개 자재, 파이프 {pipe_count}개, pipe_details 있는 파이프 {pipe_with_details}")
# 첫 번째 파이프 데이터 샘플 출력
for hash_key, data in materials_dict.items():
if data.get('category') == 'PIPE':
print(f"🔍 파이프 샘플: {data}")
break
return materials_dict

View File

@@ -14,12 +14,14 @@
"@mui/material": "^5.14.20",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"file-saver": "^2.0.5",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-router-dom": "^6.20.1"
"react-router-dom": "^6.20.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.2.37",
@@ -1422,6 +1424,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -1807,6 +1818,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1845,6 +1869,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1915,6 +1948,18 @@
"node": ">= 6"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2627,6 +2672,12 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
@@ -2736,6 +2787,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -4666,6 +4726,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -5173,6 +5245,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5190,6 +5280,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -16,12 +16,14 @@
"@mui/material": "^5.14.20",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"file-saver": "^2.0.5",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-router-dom": "^6.20.1"
"react-router-dom": "^6.20.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.2.37",

View File

@@ -28,11 +28,13 @@ import {
import {
ArrowBack,
Refresh,
History
History,
Download
} from '@mui/icons-material';
import MaterialComparisonResult from '../components/MaterialComparisonResult';
import { compareMaterialRevisions, confirmMaterialPurchase } from '../api';
import { compareMaterialRevisions, confirmMaterialPurchase, api } from '../api';
import { exportComparisonToExcel } from '../utils/excelExport';
const MaterialComparisonPage = () => {
const navigate = useNavigate();
@@ -69,6 +71,20 @@ const MaterialComparisonPage = () => {
previousRevision,
filename
});
// 🚨 테스트: MaterialsPage API 직접 호출해서 길이 정보 확인
try {
const testResult = await api.get('/files/materials', {
params: { job_no: jobNo, revision: currentRevision, limit: 10 }
});
const pipeData = testResult.data.materials?.filter(m => m.classified_category === 'PIPE');
console.log('🧪 MaterialsPage API 테스트 (길이 있는지 확인):', pipeData);
if (pipeData && pipeData.length > 0) {
console.log('🧪 첫 번째 파이프 상세:', JSON.stringify(pipeData[0], null, 2));
}
} catch (e) {
console.log('🧪 MaterialsPage API 테스트 실패:', e);
}
const result = await compareMaterialRevisions(
jobNo,
@@ -78,6 +94,7 @@ const MaterialComparisonPage = () => {
);
console.log('✅ 비교 결과 성공:', result);
console.log('🔍 전체 데이터 구조:', JSON.stringify(result.data || result, null, 2));
setComparisonResult(result.data || result);
} catch (err) {
@@ -135,6 +152,24 @@ const MaterialComparisonPage = () => {
}
};
const handleExportToExcel = () => {
if (!comparisonResult) {
alert('내보낼 비교 데이터가 없습니다.');
return;
}
const additionalInfo = {
jobNo: jobNo,
currentRevision: currentRevision,
previousRevision: previousRevision,
filename: filename
};
const baseFilename = `리비전비교_${jobNo}_${currentRevision}_vs_${previousRevision}`;
exportComparisonToExcel(comparisonResult, baseFilename, additionalInfo);
};
const renderComparisonResults = () => {
const { summary, new_items = [], modified_items = [], removed_items = [] } = comparisonResult;
@@ -218,7 +253,7 @@ const MaterialComparisonPage = () => {
);
};
const renderMaterialTable = (items, type) => {
const renderMaterialTable = (items, type) => {
if (items.length === 0) {
return (
<Alert severity="info">
@@ -229,6 +264,8 @@ const MaterialComparisonPage = () => {
);
}
console.log(`🔍 ${type} 테이블 렌더링:`, items); // 디버깅용
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
@@ -247,46 +284,108 @@ const MaterialComparisonPage = () => {
)}
{type !== 'modified' && <TableCell align="center">수량</TableCell>}
<TableCell>단위</TableCell>
<TableCell>길이(mm)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color={type === 'new' ? 'primary' : type === 'modified' ? 'warning' : 'error'}
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
{type === 'modified' && (
<>
<TableCell align="center">{item.previous_quantity}</TableCell>
<TableCell align="center">{item.current_quantity}</TableCell>
<TableCell align="center">
{items.map((item, index) => {
console.log(`🔍 항목 ${index}:`, item); // 각 항목 확인
// 파이프인 경우 길이 정보 표시
console.log(`🔧 길이 확인 - ${item.category}:`, item.pipe_details); // 디버깅
console.log(`🔧 전체 아이템:`, item); // 전체 구조 확인
let lengthInfo = '-';
if (item.category === 'PIPE' && item.pipe_details?.length_mm && item.pipe_details.length_mm > 0) {
const avgUnitLength = item.pipe_details.length_mm;
const currentTotalLength = item.pipe_details.total_length_mm || (item.quantity || 0) * avgUnitLength;
if (type === 'modified') {
// 변경된 파이프: 백엔드에서 계산된 실제 길이 사용
let prevTotalLength, lengthChange;
if (item.previous_pipe_details && item.previous_pipe_details.total_length_mm) {
// 백엔드에서 실제 이전 총길이를 제공한 경우
prevTotalLength = item.previous_pipe_details.total_length_mm;
lengthChange = currentTotalLength - prevTotalLength;
} else {
// 백업: 비율 계산
const prevRatio = (item.previous_quantity || 0) / (item.current_quantity || item.quantity || 1);
prevTotalLength = currentTotalLength * prevRatio;
lengthChange = currentTotalLength - prevTotalLength;
}
lengthInfo = (
<Box>
<Typography variant="body2">
이전: {Math.round(prevTotalLength).toLocaleString()}mm 현재: {Math.round(currentTotalLength).toLocaleString()}mm
</Typography>
<Typography
variant="body2"
fontWeight="bold"
color={item.quantity_change > 0 ? 'success.main' : 'error.main'}
fontWeight="bold"
color={lengthChange > 0 ? 'success.main' : lengthChange < 0 ? 'error.main' : 'text.primary'}
>
{item.quantity_change > 0 ? '+' : ''}{item.quantity_change}
변화: {lengthChange > 0 ? '+' : ''}{Math.round(lengthChange).toLocaleString()}mm
</Typography>
</Box>
);
} else {
// 신규/삭제된 파이프: 실제 총길이 사용
lengthInfo = (
<Box>
<Typography variant="body2" fontWeight="bold">
길이: {Math.round(currentTotalLength).toLocaleString()}mm
</Typography>
</Box>
);
}
} else if (item.category === 'PIPE') {
lengthInfo = '길이 정보 없음';
}
return (
<TableRow key={index}>
<TableCell>
<Chip
label={item.category}
size="small"
color={type === 'new' ? 'primary' : type === 'modified' ? 'warning' : 'error'}
/>
</TableCell>
<TableCell>{item.description}</TableCell>
<TableCell>{item.size_spec || '-'}</TableCell>
<TableCell>{item.material_grade || '-'}</TableCell>
{type === 'modified' && (
<>
<TableCell align="center">{item.previous_quantity}</TableCell>
<TableCell align="center">{item.current_quantity}</TableCell>
<TableCell align="center">
<Typography
variant="body2"
fontWeight="bold"
color={item.quantity_change > 0 ? 'success.main' : 'error.main'}
>
{item.quantity_change > 0 ? '+' : ''}{item.quantity_change}
</Typography>
</TableCell>
</>
)}
{type !== 'modified' && (
<TableCell align="center">
<Typography variant="body2" fontWeight="bold">
{item.quantity}
</Typography>
</TableCell>
</>
)}
{type !== 'modified' && (
)}
<TableCell>{item.unit || 'EA'}</TableCell>
<TableCell align="center">
<Typography variant="body2" fontWeight="bold">
{item.quantity}
<Typography variant="body2" color={lengthInfo !== '-' ? 'primary.main' : 'text.secondary'}>
{lengthInfo}
</Typography>
</TableCell>
)}
<TableCell>{item.unit || 'EA'}</TableCell>
</TableRow>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
@@ -341,6 +440,15 @@ const MaterialComparisonPage = () => {
>
새로고침
</Button>
<Button
variant="outlined"
color="primary"
startIcon={<Download />}
onClick={handleExportToExcel}
disabled={!comparisonResult}
>
엑셀 내보내기
</Button>
<Button
variant="outlined"
startIcon={<ArrowBack />}

View File

@@ -25,8 +25,9 @@ import {
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ShoppingCart from '@mui/icons-material/ShoppingCart';
import { Compare as CompareIcon } from '@mui/icons-material';
import { Compare as CompareIcon, Download } from '@mui/icons-material';
import { api, fetchFiles } from '../api';
import { exportMaterialsToExcel } from '../utils/excelExport';
const MaterialsPage = () => {
const [materials, setMaterials] = useState([]);
@@ -580,6 +581,25 @@ const MaterialsPage = () => {
return colors[category] || 'default';
};
// 엑셀 내보내기 함수
const handleExportToExcel = () => {
if (materials.length === 0) {
alert('내보낼 자재 데이터가 없습니다.');
return;
}
const additionalInfo = {
filename: fileName,
jobNo: jobNo,
revision: currentRevision,
uploadDate: new Date().toLocaleDateString()
};
const baseFilename = `자재목록_${jobNo}_${currentRevision}`;
exportMaterialsToExcel(materials, baseFilename, additionalInfo);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
@@ -645,6 +665,16 @@ const MaterialsPage = () => {
</Button>
)}
<Button
variant="outlined"
color="primary"
startIcon={<Download />}
onClick={handleExportToExcel}
disabled={materials.length === 0}
>
엑셀 내보내기
</Button>
<Button
variant="contained"
color="success"

View File

@@ -0,0 +1,295 @@
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
/**
* 자재 목록을 카테고리별로 그룹화
*/
const groupMaterialsByCategory = (materials) => {
const groups = {};
materials.forEach(material => {
const category = material.classified_category || material.category || 'UNCATEGORIZED';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(material);
});
return groups;
};
/**
* 동일한 자재끼리 합치기 (자재 설명 + 사이즈 기준)
*/
const consolidateMaterials = (materials, isComparison = false) => {
const consolidated = {};
materials.forEach(material => {
const category = material.classified_category || material.category || 'UNCATEGORIZED';
const description = material.original_description || material.description || '';
const sizeSpec = material.size_spec || '';
// 그룹화 키: 카테고리 + 자재설명 + 사이즈
const groupKey = `${category}|${description}|${sizeSpec}`;
if (!consolidated[groupKey]) {
consolidated[groupKey] = {
...material,
quantity: 0,
totalLength: 0, // 파이프용
itemCount: 0, // 파이프 개수
lineNumbers: [], // 라인 번호들
// 비교 모드용
previous_quantity: 0,
current_quantity: 0,
quantity_change: 0
};
}
const group = consolidated[groupKey];
group.quantity += material.quantity || 0;
// 비교 모드인 경우 이전/현재 수량도 합산
if (isComparison) {
group.previous_quantity += material.previous_quantity || 0;
group.current_quantity += material.current_quantity || 0;
group.quantity_change += material.quantity_change || 0;
}
// 파이프인 경우 길이 계산
if (category === 'PIPE') {
const lengthMm = material.pipe_details?.length_mm || 0;
const lengthM = lengthMm / 1000;
if (isComparison) {
// 비교 모드에서는 이전/현재 길이 계산
group.totalLength += lengthM * (material.current_quantity || material.quantity || 0);
group.itemCount += material.current_quantity || material.quantity || 0;
// 이전 길이도 저장
if (!group.previousTotalLength) group.previousTotalLength = 0;
group.previousTotalLength += lengthM * (material.previous_quantity || 0);
} else {
group.totalLength += lengthM * (material.quantity || 0);
group.itemCount += material.quantity || 0;
}
}
// 라인 번호 수집
if (material.line_number) {
group.lineNumbers.push(material.line_number);
}
});
// 라인 번호를 문자열로 변환
Object.values(consolidated).forEach(group => {
group.line_number = group.lineNumbers.length > 0
? group.lineNumbers.join(', ')
: '-';
});
return Object.values(consolidated);
};
/**
* 자재 데이터를 엑셀용 형태로 변환
*/
const formatMaterialForExcel = (material, includeComparison = false) => {
const category = material.classified_category || material.category || '-';
const isPipe = category === 'PIPE';
const base = {
'카테고리': category,
'자재 설명': material.original_description || material.description || '-',
'사이즈': material.size_spec || '-',
'라인 번호': material.line_number || '-'
};
// 파이프인 경우 길이(m) 표시, 그 외는 수량
if (isPipe) {
// consolidateMaterials에서 이미 계산된 totalLength 사용
const totalLength = material.totalLength || 0;
const itemCount = material.itemCount || material.quantity || 0;
base['길이(m)'] = totalLength > 0 ? totalLength.toFixed(2) : 0;
base['개수'] = itemCount;
base['단위'] = 'M';
} else {
base['수량'] = material.quantity || 0;
base['단위'] = material.unit || 'EA';
}
// 비교 모드인 경우 추가 정보
if (includeComparison) {
if (material.previous_quantity !== undefined) {
if (isPipe) {
const prevTotalLength = material.previousTotalLength || 0;
const currTotalLength = material.totalLength || 0;
base['이전 길이(m)'] = prevTotalLength > 0 ? prevTotalLength.toFixed(2) : 0;
base['현재 길이(m)'] = currTotalLength > 0 ? currTotalLength.toFixed(2) : 0;
base['길이 변경(m)'] = ((currTotalLength - prevTotalLength)).toFixed(2);
base['이전 개수'] = material.previous_quantity;
base['현재 개수'] = material.current_quantity;
} else {
base['이전 수량'] = material.previous_quantity;
base['현재 수량'] = material.current_quantity;
base['변경량'] = material.quantity_change;
}
}
base['변경 유형'] = material.change_type || (
material.previous_quantity !== undefined ? '수량 변경' :
material.quantity_change === undefined ? '신규' : '변경'
);
}
return base;
};
/**
* 일반 자재 목록 엑셀 내보내기
*/
export const exportMaterialsToExcel = (materials, filename, additionalInfo = {}) => {
try {
// 카테고리별로 그룹화
const categoryGroups = groupMaterialsByCategory(materials);
// 전체 자재 합치기 (먼저 계산)
const consolidatedMaterials = consolidateMaterials(materials);
// 새 워크북 생성
const workbook = XLSX.utils.book_new();
// 요약 시트 생성
const summaryData = [
['파일 정보', ''],
['파일명', additionalInfo.filename || ''],
['Job No', additionalInfo.jobNo || ''],
['리비전', additionalInfo.revision || ''],
['업로드일', additionalInfo.uploadDate || new Date().toLocaleDateString()],
['총 자재 수', consolidatedMaterials.length],
['', ''],
['카테고리별 요약', ''],
['카테고리', '수량']
];
// 카테고리별 요약 추가 (합쳐진 자재 기준)
Object.entries(categoryGroups).forEach(([category, items]) => {
const consolidatedItems = consolidateMaterials(items);
summaryData.push([category, consolidatedItems.length]);
});
const summarySheet = XLSX.utils.aoa_to_sheet(summaryData);
XLSX.utils.book_append_sheet(workbook, summarySheet, '요약');
// 전체 자재 시트 (합쳐진 자재)
const allMaterialsFormatted = consolidatedMaterials.map(material => formatMaterialForExcel(material));
const allSheet = XLSX.utils.json_to_sheet(allMaterialsFormatted);
XLSX.utils.book_append_sheet(workbook, allSheet, '전체 자재');
// 카테고리별 시트 생성 (합쳐진 자재)
Object.entries(categoryGroups).forEach(([category, items]) => {
const consolidatedItems = consolidateMaterials(items);
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material));
const categorySheet = XLSX.utils.json_to_sheet(formattedItems);
// 시트명에서 특수문자 제거 (엑셀 시트명 규칙)
const sheetName = category.replace(/[\\\/\*\?\[\]]/g, '_').substring(0, 31);
XLSX.utils.book_append_sheet(workbook, categorySheet, sheetName);
});
// 파일 저장
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const finalFilename = `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`;
saveAs(data, finalFilename);
return true;
} catch (error) {
console.error('엑셀 내보내기 실패:', error);
alert('엑셀 파일 생성에 실패했습니다: ' + error.message);
return false;
}
};
/**
* 리비전 비교 결과 엑셀 내보내기
*/
export const exportComparisonToExcel = (comparisonData, filename, additionalInfo = {}) => {
try {
const { summary, new_items = [], modified_items = [], removed_items = [] } = comparisonData;
// 새 워크북 생성
const workbook = XLSX.utils.book_new();
// 요약 시트
const summaryData = [
['리비전 비교 정보', ''],
['Job No', additionalInfo.jobNo || ''],
['현재 리비전', additionalInfo.currentRevision || ''],
['이전 리비전', additionalInfo.previousRevision || ''],
['비교일', new Date().toLocaleDateString()],
['', ''],
['비교 결과 요약', ''],
['구분', '건수'],
['총 현재 자재', summary?.total_current_items || 0],
['총 이전 자재', summary?.total_previous_items || 0],
['신규 자재', summary?.new_items_count || 0],
['변경 자재', summary?.modified_items_count || 0],
['삭제 자재', summary?.removed_items_count || 0]
];
const summarySheet = XLSX.utils.aoa_to_sheet(summaryData);
XLSX.utils.book_append_sheet(workbook, summarySheet, '비교 요약');
// 신규 자재 시트 (카테고리별, 합쳐진 자재)
if (new_items.length > 0) {
const newItemsGroups = groupMaterialsByCategory(new_items);
Object.entries(newItemsGroups).forEach(([category, items]) => {
const consolidatedItems = consolidateMaterials(items, true);
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material, true));
const sheet = XLSX.utils.json_to_sheet(formattedItems);
const sheetName = `신규_${category.replace(/[\\\/\*\?\[\]]/g, '_')}`.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
});
}
// 변경 자재 시트 (카테고리별, 합쳐진 자재)
if (modified_items.length > 0) {
const modifiedItemsGroups = groupMaterialsByCategory(modified_items);
Object.entries(modifiedItemsGroups).forEach(([category, items]) => {
const consolidatedItems = consolidateMaterials(items, true);
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material, true));
const sheet = XLSX.utils.json_to_sheet(formattedItems);
const sheetName = `변경_${category.replace(/[\\\/\*\?\[\]]/g, '_')}`.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
});
}
// 삭제 자재 시트 (카테고리별, 합쳐진 자재)
if (removed_items.length > 0) {
const removedItemsGroups = groupMaterialsByCategory(removed_items);
Object.entries(removedItemsGroups).forEach(([category, items]) => {
const consolidatedItems = consolidateMaterials(items, true);
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material, true));
const sheet = XLSX.utils.json_to_sheet(formattedItems);
const sheetName = `삭제_${category.replace(/[\\\/\*\?\[\]]/g, '_')}`.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
});
}
// 파일 저장
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const finalFilename = `${filename}_비교결과_${new Date().toISOString().split('T')[0]}.xlsx`;
saveAs(data, finalFilename);
return true;
} catch (error) {
console.error('엑셀 내보내기 실패:', error);
alert('엑셀 파일 생성에 실패했습니다: ' + error.message);
return false;
}
};