diff --git a/backend/app/routers/material_comparison.py b/backend/app/routers/material_comparison.py index 32ab9d4..110ca0d 100644 --- a/backend/app/routers/material_comparison.py +++ b/backend/app/routers/material_comparison.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 496ee19..78229ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ad54ad2..25f5258 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/pages/MaterialComparisonPage.jsx b/frontend/src/pages/MaterialComparisonPage.jsx index e8548f0..33b2e7f 100644 --- a/frontend/src/pages/MaterialComparisonPage.jsx +++ b/frontend/src/pages/MaterialComparisonPage.jsx @@ -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 ( @@ -229,6 +264,8 @@ const MaterialComparisonPage = () => { ); } + console.log(`🔍 ${type} 테이블 렌더링:`, items); // 디버깅용 + return ( @@ -247,46 +284,108 @@ const MaterialComparisonPage = () => { )} {type !== 'modified' && 수량} 단위 + 길이(mm) - {items.map((item, index) => ( - - - - - {item.description} - {item.size_spec || '-'} - {item.material_grade || '-'} - {type === 'modified' && ( - <> - {item.previous_quantity} - {item.current_quantity} - + {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 = ( + + + 이전: {Math.round(prevTotalLength).toLocaleString()}mm → 현재: {Math.round(currentTotalLength).toLocaleString()}mm + 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 + + + ); + } else { + // 신규/삭제된 파이프: 실제 총길이 사용 + lengthInfo = ( + + + 총 길이: {Math.round(currentTotalLength).toLocaleString()}mm + + + ); + } + } else if (item.category === 'PIPE') { + lengthInfo = '길이 정보 없음'; + } + + return ( + + + + + {item.description} + {item.size_spec || '-'} + {item.material_grade || '-'} + {type === 'modified' && ( + <> + {item.previous_quantity} + {item.current_quantity} + + 0 ? 'success.main' : 'error.main'} + > + {item.quantity_change > 0 ? '+' : ''}{item.quantity_change} + + + + )} + {type !== 'modified' && ( + + + {item.quantity} - - )} - {type !== 'modified' && ( + )} + {item.unit || 'EA'} - - {item.quantity} + + {lengthInfo} - )} - {item.unit || 'EA'} - - ))} + + ); + })}
@@ -341,6 +440,15 @@ const MaterialComparisonPage = () => { > 새로고침 + )} + +