diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index e91ed98..cbf6cf5 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -205,7 +205,11 @@ async def upload_file( parsed_count = len(materials_data) # 로그 제거 - # 리비전 업로드인 경우만 자동 리비전 생성 + # 신규 자재 카운트 초기화 + new_materials_count = 0 + existing_materials_descriptions = set() + + # 리비전 업로드인 경우만 자동 리비전 생성 및 기존 자재 조회 if parent_file_id is not None: # 로그 제거 # 부모 파일의 정보 조회 @@ -255,6 +259,29 @@ async def upload_file( revision = "Rev.1" print(f"첫 번째 리비전: {revision}") + # 부모 파일의 자재 목록 조회 (기존 자재 확인용 - 수량 포함) + existing_materials_query = text(""" + SELECT original_description, size_spec, SUM(quantity) as total_quantity + FROM materials + WHERE file_id = :parent_file_id + GROUP BY original_description, size_spec + """) + + existing_result = db.execute(existing_materials_query, { + "parent_file_id": parent_file_id + }) + + existing_materials_with_quantity = {} + for row in existing_result: + # 설명과 사이즈를 조합하여 유니크 키 생성 + key = f"{row.original_description}|{row.size_spec or ''}" + existing_materials_descriptions.add(key) + existing_materials_with_quantity[key] = float(row.total_quantity or 0) + + print(f"기존 자재 수: {len(existing_materials_descriptions)}") + if len(existing_materials_descriptions) > 0: + print(f"기존 자재 샘플 (처음 5개): {list(existing_materials_descriptions)[:5]}") + # 파일명을 부모와 동일하게 유지 file.filename = parent_file[0] else: @@ -351,7 +378,66 @@ async def upload_file( }) materials_inserted += 1 + # 리비전 업로드인 경우 차이분 계산 + materials_diff = [] + if parent_file_id is not None: + # 새 파일의 자재들을 수량별로 그룹화 + new_materials_grouped = {} + for material_data in materials_to_classify: + description = material_data["original_description"] + size_spec = material_data["size_spec"] + quantity = float(material_data.get("quantity", 0)) + + material_key = f"{description}|{size_spec or ''}" + if material_key in new_materials_grouped: + new_materials_grouped[material_key]["quantity"] += quantity + new_materials_grouped[material_key]["items"].append(material_data) + else: + new_materials_grouped[material_key] = { + "quantity": quantity, + "items": [material_data], + "description": description, + "size_spec": size_spec + } + + # 차이분 계산 + for material_key, new_data in new_materials_grouped.items(): + existing_quantity = existing_materials_with_quantity.get(material_key, 0) + new_quantity = new_data["quantity"] + + if new_quantity > existing_quantity: + # 증가분이 있는 경우 + diff_quantity = new_quantity - existing_quantity + print(f"차이분 발견: {new_data['description'][:50]}... (증가: {diff_quantity})") + + # 증가분만큼 자재 데이터 생성 + for item in new_data["items"]: + if diff_quantity <= 0: + break + + item_quantity = float(item.get("quantity", 0)) + if item_quantity <= diff_quantity: + # 이 아이템 전체를 포함 + materials_diff.append(item) + diff_quantity -= item_quantity + new_materials_count += 1 + else: + # 이 아이템의 일부만 포함 + item_copy = item.copy() + item_copy["quantity"] = diff_quantity + materials_diff.append(item_copy) + new_materials_count += 1 + break + else: + print(f"수량 감소/동일: {new_data['description'][:50]}... (기존:{existing_quantity} 새:{new_quantity})") + + # 차이분만 처리하도록 materials_to_classify 교체 + materials_to_classify = materials_diff + print(f"차이분 자재 개수: {len(materials_to_classify)}") + # 분류가 필요한 자재 처리 + print(f"분류할 자재 총 개수: {len(materials_to_classify)}") + for material_data in materials_to_classify: # 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등) description = material_data["original_description"] @@ -462,6 +548,8 @@ async def upload_file( material_id = material_result.fetchone()[0] materials_inserted += 1 + # 리비전 업로드인 경우 신규 자재 카운트는 이미 위에서 처리됨 + # PIPE 분류 결과인 경우 상세 정보 저장 if classification_result.get("category") == "PIPE": print("PIPE 상세 정보 저장 시작") @@ -1063,13 +1151,20 @@ async def upload_file( print(f"활동 로그 기록 실패: {str(e)}") # 로그 실패는 업로드 성공에 영향을 주지 않음 + # 리비전 업로드인 경우 메시지 다르게 표시 + if parent_file_id is not None: + message = f"리비전 업로드 성공! {new_materials_count}개의 신규 자재가 추가되었습니다." + else: + message = f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다." + return { "success": True, - "message": f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다.", + "message": message, "original_filename": file.filename, "file_id": file_id, "materials_count": materials_inserted, "saved_materials_count": materials_inserted, + "new_materials_count": new_materials_count if parent_file_id is not None else None, # 신규 자재 수 "revision": revision, # 생성된 리비전 정보 추가 "uploaded_by": username, # 업로드한 사용자 정보 추가 "parsed_count": parsed_count @@ -1948,37 +2043,45 @@ async def compare_revisions( }) new_materials = new_result.fetchall() - # 자재 키 생성 함수 + # 자재 키 생성 함수 (전체 수량 기준) def create_material_key(material): - return f"{material.original_description}_{material.size_spec}_{material.material_grade}" + return f"{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}" - # 기존 자재를 딕셔너리로 변환 + # 기존 자재를 딕셔너리로 변환 (수량 합산) old_materials_dict = {} for material in old_materials: key = create_material_key(material) - old_materials_dict[key] = { - "original_description": material.original_description, - "quantity": float(material.quantity) if material.quantity else 0, - "unit": material.unit, - "size_spec": material.size_spec, - "material_grade": material.material_grade, - "classified_category": material.classified_category, - "classification_confidence": material.classification_confidence - } + if key in old_materials_dict: + # 동일한 자재가 있으면 수량 합산 + old_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0 + else: + old_materials_dict[key] = { + "original_description": material.original_description, + "quantity": float(material.quantity) if material.quantity else 0, + "unit": material.unit, + "size_spec": material.size_spec, + "material_grade": material.material_grade, + "classified_category": material.classified_category, + "classification_confidence": material.classification_confidence + } - # 새 자재를 딕셔너리로 변환 + # 새 자재를 딕셔너리로 변환 (수량 합산) new_materials_dict = {} for material in new_materials: key = create_material_key(material) - new_materials_dict[key] = { - "original_description": material.original_description, - "quantity": float(material.quantity) if material.quantity else 0, - "unit": material.unit, - "size_spec": material.size_spec, - "material_grade": material.material_grade, - "classified_category": material.classified_category, - "classification_confidence": material.classification_confidence - } + if key in new_materials_dict: + # 동일한 자재가 있으면 수량 합산 + new_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0 + else: + new_materials_dict[key] = { + "original_description": material.original_description, + "quantity": float(material.quantity) if material.quantity else 0, + "unit": material.unit, + "size_spec": material.size_spec, + "material_grade": material.material_grade, + "classified_category": material.classified_category, + "classification_confidence": material.classification_confidence + } # 변경 사항 분석 all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys()) @@ -2006,14 +2109,22 @@ async def compare_revisions( "change_type": "added" }) elif old_item and new_item: - # 수량 변경 확인 - if old_item["quantity"] != new_item["quantity"]: + # 수량 변경 확인 (전체 수량 기준) + old_qty = old_item["quantity"] + new_qty = new_item["quantity"] + qty_diff = new_qty - old_qty + + # 수량 차이가 있으면 변경된 것으로 간주 (소수점 오차 고려) + if abs(qty_diff) > 0.001: + change_type = "quantity_increased" if qty_diff > 0 else "quantity_decreased" changed_items.append({ "key": key, "old_item": old_item, "new_item": new_item, - "quantity_change": new_item["quantity"] - old_item["quantity"], - "change_type": "quantity_changed" + "quantity_change": qty_diff, + "quantity_change_abs": abs(qty_diff), + "change_type": change_type, + "change_percentage": (qty_diff / old_qty * 100) if old_qty > 0 else 0 }) # 분류별 통계 diff --git a/frontend/src/components/BOMFileUpload.jsx b/frontend/src/components/BOMFileUpload.jsx index fdd97cf..5eb1514 100644 --- a/frontend/src/components/BOMFileUpload.jsx +++ b/frontend/src/components/BOMFileUpload.jsx @@ -34,19 +34,20 @@ const BOMFileUpload = ({ color: '#4a5568', marginBottom: '8px' }}> - BOM 이름 + BOM 이름 * setBomName(e.target.value)} - placeholder="예: PIPING_BOM_A구역" + placeholder="예: PIPING_BOM_A구역, 배관자재_1차, VALVE_LIST_Rev0" style={{ width: '100%', padding: '12px', border: '1px solid #e2e8f0', borderRadius: '8px', - fontSize: '14px' + fontSize: '14px', + background: bomName ? '#f0fff4' : 'white' }} />
- 동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다 + 💡 이 이름은 엑셀 내보내기 파일명과 자재 관리에 사용됩니다. + 동일한 BOM 이름으로 재업로드 시 리비전이 자동 증가합니다.
diff --git a/frontend/src/pages/BOMWorkspacePage.jsx b/frontend/src/pages/BOMWorkspacePage.jsx index 330e95f..54e9cad 100644 --- a/frontend/src/pages/BOMWorkspacePage.jsx +++ b/frontend/src/pages/BOMWorkspacePage.jsx @@ -9,6 +9,7 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => { const [error, setError] = useState(''); const [sidebarWidth, setSidebarWidth] = useState(300); const [previewWidth, setPreviewWidth] = useState(400); + const [groupedFiles, setGroupedFiles] = useState({}); // 업로드 관련 상태 const [uploading, setUploading] = useState(false); @@ -20,6 +21,30 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => { const [editingField, setEditingField] = useState(null); const [editValue, setEditValue] = useState(''); + // 파일을 BOM 이름별로 그룹화 + const groupFilesByBOM = (fileList) => { + const groups = {}; + + fileList.forEach(file => { + const bomName = file.bom_name || file.original_filename; + if (!groups[bomName]) { + groups[bomName] = []; + } + groups[bomName].push(file); + }); + + // 각 그룹 내에서 리비전 번호로 정렬 + Object.keys(groups).forEach(bomName => { + groups[bomName].sort((a, b) => { + const revA = parseInt(a.revision?.replace('Rev.', '') || '0'); + const revB = parseInt(b.revision?.replace('Rev.', '') || '0'); + return revB - revA; // 최신 리비전이 위로 + }); + }); + + return groups; + }; + useEffect(() => { console.log('🔄 프로젝트 변경됨:', project); const jobNo = project?.official_project_code || project?.job_no; @@ -60,6 +85,11 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => { setFiles(fileList); + // 파일을 그룹화 + const grouped = groupFilesByBOM(fileList); + setGroupedFiles(grouped); + console.log('📂 그룹화된 파일:', grouped); + // 기존 선택된 파일이 목록에 있는지 확인 if (selectedFile && !fileList.find(f => f.id === selectedFile.id)) { setSelectedFile(null); @@ -239,11 +269,50 @@ const BOMWorkspacePage = ({ project, onNavigate, onBack }) => { jobNo: file.job_no, bomName: file.bom_name || file.original_filename, revision: file.revision, - filename: file.original_filename + filename: file.original_filename, + selectedProject: project }); } }; + // 리비전 업로드 + const handleRevisionUpload = async (parentFile) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.csv,.xlsx,.xls'; + + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + setUploading(true); + const jobNo = project?.official_project_code || project?.job_no; + const formData = new FormData(); + formData.append('file', file); + formData.append('job_no', jobNo); + formData.append('bom_name', parentFile.bom_name || parentFile.original_filename); + formData.append('parent_file_id', parentFile.id); // 부모 파일 ID 추가 + + const response = await api.post('/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + if (response.data?.success) { + alert(`리비전 ${response.data.revision} 업로드 성공!\n신규 자재: ${response.data.new_materials_count || 0}개`); + await loadFiles(); + } + } catch (err) { + console.error('리비전 업로드 실패:', err); + alert('리비전 업로드에 실패했습니다: ' + (err.response?.data?.detail || err.message)); + } finally { + setUploading(false); + } + }; + + input.click(); + }; + return (