From 5f7a6f0b3ad47654545177c646bc7a7c199ad340 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 17 Jul 2025 10:44:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EC=9E=AC=20=EB=B6=84=EB=A5=98?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 자재 카테고리별 상세 테이블 생성 (fitting, valve, flange, bolt, gasket, instrument) - PIPE, FITTING, VALVE 분류 결과를 각 상세 테이블에 저장하는 로직 구현 - 프론트엔드 라우팅 정리 및 BOM 현황 페이지 기능 개선 - 자재확인 페이지 에러 처리 개선 TODO: FLANGE, BOLT, GASKET, INSTRUMENT 저장 로직 추가 필요 --- backend/app/api/files.py | 572 ++++++++- backend/app/main.py | 97 +- backend/app/models.py | 241 +++- backend/app/routers/files.py | 1018 +++++++++------- backend/app/services/bolt_classifier.py | 2 +- backend/app/services/fitting_classifier.py | 39 +- backend/app/services/flange_classifier.py | 31 +- backend/app/services/gasket_classifier.py | 2 +- backend/app/services/instrument_classifier.py | 2 +- backend/app/services/material_classifier.py | 2 +- backend/app/services/pipe_classifier.py | 65 +- backend/app/services/valve_classifier.py | 22 +- .../scripts/05_add_length_to_materials.sql | 5 + .../05_create_material_standards_tables.sql | 137 +++ ...5_create_pipe_details_and_requirements.sql | 109 ++ ...pipe_details_and_requirements_postgres.sql | 115 ++ .../06_insert_material_standards_data.py | 462 ++++++++ ...07_execute_material_standards_migration.py | 193 +++ .../scripts/create_material_detail_tables.sql | 237 ++++ database/init/01_schema.sql | 1 + .../init/02_add_classification_details.sql | 8 + frontend/src/App.jsx | 4 +- frontend/src/api.js | 12 + frontend/src/components/FileUpload.jsx | 132 +-- frontend/src/components/PipeDetailsCard.jsx | 31 + frontend/src/pages/BOMStatusPage.jsx | 56 +- frontend/src/pages/JobSelectionPage.jsx | 2 +- frontend/src/pages/MaterialLookupPage.jsx | 221 ---- frontend/src/pages/MaterialsPage.jsx | 1042 +++++++++++++++-- frontend/src/pages/ProjectSelectionPage.jsx | 26 +- 30 files changed, 3963 insertions(+), 923 deletions(-) create mode 100644 backend/scripts/05_add_length_to_materials.sql create mode 100644 backend/scripts/05_create_material_standards_tables.sql create mode 100644 backend/scripts/05_create_pipe_details_and_requirements.sql create mode 100644 backend/scripts/05_create_pipe_details_and_requirements_postgres.sql create mode 100644 backend/scripts/06_insert_material_standards_data.py create mode 100644 backend/scripts/07_execute_material_standards_migration.py create mode 100644 backend/scripts/create_material_detail_tables.sql create mode 100644 database/init/02_add_classification_details.sql create mode 100644 frontend/src/components/PipeDetailsCard.jsx delete mode 100644 frontend/src/pages/MaterialLookupPage.jsx diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 14d345f..896f1b0 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -8,9 +8,18 @@ from datetime import datetime import uuid import pandas as pd import re +import json from pathlib import Path from ..database import get_db +from app.services.material_classifier import classify_material +from app.services.bolt_classifier import classify_bolt +from app.services.flange_classifier import classify_flange +from app.services.fitting_classifier import classify_fitting +from app.services.gasket_classifier import classify_gasket +from app.services.instrument_classifier import classify_instrument +from app.services.pipe_classifier import classify_pipe +from app.services.valve_classifier import classify_valve router = APIRouter() @@ -59,7 +68,8 @@ def generate_unique_filename(original_filename: str) -> str: def parse_dataframe(df): df = df.dropna(how='all') - df.columns = df.columns.str.strip().str.lower() + # 원본 컬럼명 유지 (소문자 변환하지 않음) + df.columns = df.columns.str.strip() column_mapping = { 'description': ['description', 'item', 'material', '품명', '자재명'], @@ -75,10 +85,16 @@ def parse_dataframe(df): mapped_columns = {} for standard_col, possible_names in column_mapping.items(): for possible_name in possible_names: - if possible_name in df.columns: - mapped_columns[standard_col] = possible_name + # 대소문자 구분 없이 매핑 + for col in df.columns: + if possible_name.lower() == col.lower(): + mapped_columns[standard_col] = col + break + if standard_col in mapped_columns: break + print(f"찾은 컬럼 매핑: {mapped_columns}") + materials = [] for index, row in df.iterrows(): description = str(row.get(mapped_columns.get('description', ''), '')) @@ -89,6 +105,15 @@ def parse_dataframe(df): except: quantity = 0 + # 길이 정보 파싱 + length_raw = row.get(mapped_columns.get('length', ''), None) + length_value = None + if pd.notna(length_raw) and length_raw != '': + try: + length_value = float(length_raw) + except: + length_value = None + material_grade = "" if "ASTM" in description.upper(): astm_match = re.search(r'ASTM\s+([A-Z0-9\s]+)', description.upper()) @@ -112,6 +137,7 @@ def parse_dataframe(df): 'unit': "EA", 'size_spec': size_spec, 'material_grade': material_grade, + 'length': length_value, 'line_number': index + 1, 'row_number': index + 1 }) @@ -140,7 +166,7 @@ async def upload_file( revision: str = Form("Rev.0"), db: Session = Depends(get_db) ): - if not validate_file_extension(file.filename): + if not validate_file_extension(str(file.filename)): raise HTTPException( status_code=400, detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}" @@ -149,7 +175,7 @@ async def upload_file( if file.size and file.size > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다") - unique_filename = generate_unique_filename(file.filename) + unique_filename = generate_unique_filename(str(file.filename)) file_path = UPLOAD_DIR / unique_filename try: @@ -183,19 +209,133 @@ async def upload_file( file_id = file_result.fetchone()[0] - # 자재 데이터 저장 + # 자재 데이터 저장 (분류 포함) materials_inserted = 0 for material_data in materials_data: + # 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등) + description = material_data["original_description"] + size_spec = material_data["size_spec"] + + # 각 분류기로 시도 (올바른 매개변수 사용) + print(f"분류 시도: {description}") + + # 분류기 호출 시 타임아웃 및 예외 처리 + classification_result = None + try: + # 파이프 분류기 호출 시 length 매개변수 전달 + length_value = None + if 'length' in material_data: + try: + length_value = float(material_data['length']) + except: + length_value = None + # None이면 0.0으로 대체 + if length_value is None: + length_value = 0.0 + + # 타임아웃 설정 (10초) + import signal + def timeout_handler(signum, frame): + raise TimeoutError("분류기 실행 시간 초과") + + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(10) # 10초 타임아웃 + + try: + classification_result = classify_pipe("", description, size_spec, length_value) + print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + finally: + signal.alarm(0) # 타임아웃 해제 + + if classification_result.get("overall_confidence", 0) < 0.5: + signal.alarm(10) + try: + classification_result = classify_fitting("", description, size_spec) + print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + finally: + signal.alarm(0) + + if classification_result.get("overall_confidence", 0) < 0.5: + signal.alarm(10) + try: + classification_result = classify_valve("", description, size_spec) + print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + finally: + signal.alarm(0) + + if classification_result.get("overall_confidence", 0) < 0.5: + signal.alarm(10) + try: + classification_result = classify_flange("", description, size_spec) + print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + finally: + signal.alarm(0) + + if classification_result.get("overall_confidence", 0) < 0.5: + signal.alarm(10) + try: + classification_result = classify_bolt("", description, size_spec) + print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + finally: + signal.alarm(0) + + if classification_result.get("overall_confidence", 0) < 0.5: + signal.alarm(10) + try: + classification_result = classify_gasket("", description, size_spec) + print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + finally: + signal.alarm(0) + + if classification_result.get("overall_confidence", 0) < 0.5: + signal.alarm(10) + try: + classification_result = classify_instrument("", description, size_spec) + print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + finally: + signal.alarm(0) + + except (TimeoutError, Exception) as e: + print(f"분류기 실행 중 오류 발생: {e}") + # 기본 분류 결과 생성 + classification_result = { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": f"분류기 오류: {str(e)}" + } + + print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") + + # 분류 결과에서 상세 정보 추출 + if classification_result.get('category') == 'PIPE': + classification_details = classification_result + elif classification_result.get('category') == 'FITTING': + classification_details = classification_result + elif classification_result.get('category') == 'VALVE': + classification_details = classification_result + else: + classification_details = {} + # DB에 저장 시 JSON 직렬화 + classification_details = json.dumps(classification_details, ensure_ascii=False) + + # 디버깅: 저장 직전 데이터 확인 + print(f"=== 자재[{materials_inserted + 1}] 저장 직전 ===") + print(f"자재명: {material_data['original_description']}") + print(f"분류결과: {classification_result.get('category')}") + print(f"신뢰도: {classification_result.get('overall_confidence', 0)}") + print(f"classification_details 길이: {len(classification_details)}") + print(f"classification_details 샘플: {classification_details[:200]}...") + print("=" * 50) material_insert_query = text(""" INSERT INTO materials ( file_id, original_description, quantity, unit, size_spec, material_grade, line_number, row_number, classified_category, - classification_confidence, is_verified, created_at + classification_confidence, classification_details, is_verified, created_at ) VALUES ( :file_id, :original_description, :quantity, :unit, :size_spec, :material_grade, :line_number, :row_number, :classified_category, - :classification_confidence, :is_verified, :created_at + :classification_confidence, :classification_details, :is_verified, :created_at ) """) @@ -208,11 +348,155 @@ async def upload_file( "material_grade": material_data["material_grade"], "line_number": material_data["line_number"], "row_number": material_data["row_number"], - "classified_category": None, - "classification_confidence": None, + "classified_category": classification_result.get("category", "UNKNOWN"), + "classification_confidence": classification_result.get("overall_confidence", 0.0), + "classification_details": classification_details, "is_verified": False, "created_at": datetime.now() }) + + # 각 카테고리별로 상세 테이블에 저장 + category = classification_result.get('category') + confidence = classification_result.get('overall_confidence', 0) + + if category == 'PIPE' and confidence >= 0.5: + try: + # 분류 결과에서 파이프 상세 정보 추출 + pipe_info = classification_result + + # cutting_dimensions에서 length 정보 가져오기 + cutting_dims = pipe_info.get('cutting_dimensions', {}) + length_mm = cutting_dims.get('length_mm') + + # length_mm가 없으면 원본 데이터의 length 사용 + if not length_mm and material_data.get('length'): + length_mm = material_data['length'] + + pipe_insert_query = text(""" + INSERT INTO pipe_details ( + material_id, file_id, size_inches, schedule_type, material_spec, + manufacturing_method, length_mm, outer_diameter_mm, wall_thickness_mm, + weight_per_meter_kg, classification_confidence, additional_info + ) + VALUES ( + (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), + :file_id, :size_inches, :schedule_type, :material_spec, + :manufacturing_method, :length_mm, :outer_diameter_mm, :wall_thickness_mm, + :weight_per_meter_kg, :classification_confidence, :additional_info + ) + """) + + db.execute(pipe_insert_query, { + "file_id": file_id, + "description": material_data["original_description"], + "row_number": material_data["row_number"], + "size_inches": pipe_info.get('nominal_diameter', ''), + "schedule_type": pipe_info.get('schedule', ''), + "material_spec": pipe_info.get('material_spec', ''), + "manufacturing_method": pipe_info.get('manufacturing_method', ''), + "length_mm": length_mm, + "outer_diameter_mm": pipe_info.get('outer_diameter_mm'), + "wall_thickness_mm": pipe_info.get('wall_thickness_mm'), + "weight_per_meter_kg": pipe_info.get('weight_per_meter_kg'), + "classification_confidence": classification_result.get('overall_confidence', 0.0), + "additional_info": json.dumps(pipe_info, ensure_ascii=False) + }) + + print(f"PIPE 상세정보 저장 완료: {material_data['original_description']}") + + except Exception as e: + print(f"PIPE 상세정보 저장 실패: {e}") + # 에러가 발생해도 전체 프로세스는 계속 진행 + + elif category == 'FITTING' and confidence >= 0.5: + try: + fitting_info = classification_result + + fitting_insert_query = text(""" + INSERT INTO fitting_details ( + material_id, file_id, fitting_type, fitting_subtype, + connection_method, connection_code, pressure_rating, max_pressure, + manufacturing_method, material_standard, material_grade, material_type, + main_size, reduced_size, classification_confidence, additional_info + ) + VALUES ( + (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), + :file_id, :fitting_type, :fitting_subtype, + :connection_method, :connection_code, :pressure_rating, :max_pressure, + :manufacturing_method, :material_standard, :material_grade, :material_type, + :main_size, :reduced_size, :classification_confidence, :additional_info + ) + """) + + db.execute(fitting_insert_query, { + "file_id": file_id, + "description": material_data["original_description"], + "row_number": material_data["row_number"], + "fitting_type": fitting_info.get('fitting_type', {}).get('type', ''), + "fitting_subtype": fitting_info.get('fitting_type', {}).get('subtype', ''), + "connection_method": fitting_info.get('connection_method', {}).get('method', ''), + "connection_code": fitting_info.get('connection_method', {}).get('matched_code', ''), + "pressure_rating": fitting_info.get('pressure_rating', {}).get('rating', ''), + "max_pressure": fitting_info.get('pressure_rating', {}).get('max_pressure', ''), + "manufacturing_method": fitting_info.get('manufacturing', {}).get('method', ''), + "material_standard": fitting_info.get('material', {}).get('standard', ''), + "material_grade": fitting_info.get('material', {}).get('grade', ''), + "material_type": fitting_info.get('material', {}).get('material_type', ''), + "main_size": fitting_info.get('size_info', {}).get('main_size', ''), + "reduced_size": fitting_info.get('size_info', {}).get('reduced_size', ''), + "classification_confidence": confidence, + "additional_info": json.dumps(fitting_info, ensure_ascii=False) + }) + + print(f"FITTING 상세정보 저장 완료: {material_data['original_description']}") + + except Exception as e: + print(f"FITTING 상세정보 저장 실패: {e}") + + elif category == 'VALVE' and confidence >= 0.5: + try: + valve_info = classification_result + + valve_insert_query = text(""" + INSERT INTO valve_details ( + material_id, file_id, valve_type, valve_subtype, actuator_type, + connection_method, pressure_rating, pressure_class, + body_material, trim_material, size_inches, + fire_safe, low_temp_service, classification_confidence, additional_info + ) + VALUES ( + (SELECT id FROM materials WHERE file_id = :file_id AND original_description = :description AND row_number = :row_number), + :file_id, :valve_type, :valve_subtype, :actuator_type, + :connection_method, :pressure_rating, :pressure_class, + :body_material, :trim_material, :size_inches, + :fire_safe, :low_temp_service, :classification_confidence, :additional_info + ) + """) + + db.execute(valve_insert_query, { + "file_id": file_id, + "description": material_data["original_description"], + "row_number": material_data["row_number"], + "valve_type": valve_info.get('valve_type', ''), + "valve_subtype": valve_info.get('valve_subtype', ''), + "actuator_type": valve_info.get('actuator_type', ''), + "connection_method": valve_info.get('connection_method', ''), + "pressure_rating": valve_info.get('pressure_rating', ''), + "pressure_class": valve_info.get('pressure_class', ''), + "body_material": valve_info.get('body_material', ''), + "trim_material": valve_info.get('trim_material', ''), + "size_inches": valve_info.get('size', ''), + "fire_safe": valve_info.get('fire_safe', False), + "low_temp_service": valve_info.get('low_temp_service', False), + "classification_confidence": confidence, + "additional_info": json.dumps(valve_info, ensure_ascii=False) + }) + + print(f"VALVE 상세정보 저장 완료: {material_data['original_description']}") + + except Exception as e: + print(f"VALVE 상세정보 저장 실패: {e}") + materials_inserted += 1 db.commit() @@ -256,6 +540,7 @@ async def get_materials( query = """ SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, m.size_spec, m.material_grade, m.line_number, m.row_number, + m.classified_category, m.classification_confidence, m.classification_details, m.created_at, f.original_filename, f.project_id, f.job_no, f.revision, p.official_project_code, p.project_name @@ -383,6 +668,9 @@ async def get_materials( "material_grade": m.material_grade, "line_number": m.line_number, "row_number": m.row_number, + "classified_category": m.classified_category, + "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0, + "classification_details": json.loads(m.classification_details) if m.classification_details else None, "created_at": m.created_at } for m in materials @@ -444,3 +732,267 @@ async def get_materials_summary( except Exception as e: raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}") + +@router.get("/materials/compare-revisions") +async def compare_revisions( + job_no: str, + filename: str, + old_revision: str, + new_revision: str, + db: Session = Depends(get_db) +): + """ + 리비전 간 자재 비교 + """ + try: + # 기존 리비전 자재 조회 + old_materials_query = text(""" + SELECT m.original_description, m.quantity, m.unit, m.size_spec, + m.material_grade, m.classified_category, m.classification_confidence + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.job_no = :job_no + AND f.original_filename = :filename + AND f.revision = :old_revision + """) + + old_result = db.execute(old_materials_query, { + "job_no": job_no, + "filename": filename, + "old_revision": old_revision + }) + old_materials = old_result.fetchall() + + # 새 리비전 자재 조회 + new_materials_query = text(""" + SELECT m.original_description, m.quantity, m.unit, m.size_spec, + m.material_grade, m.classified_category, m.classification_confidence + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.job_no = :job_no + AND f.original_filename = :filename + AND f.revision = :new_revision + """) + + new_result = db.execute(new_materials_query, { + "job_no": job_no, + "filename": filename, + "new_revision": new_revision + }) + new_materials = new_result.fetchall() + + # 자재 키 생성 함수 + def create_material_key(material): + return f"{material.original_description}_{material.size_spec}_{material.material_grade}" + + # 기존 자재를 딕셔너리로 변환 + 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 + } + + # 새 자재를 딕셔너리로 변환 + 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 + } + + # 변경 사항 분석 + all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys()) + + added_items = [] + removed_items = [] + changed_items = [] + + for key in all_keys: + old_item = old_materials_dict.get(key) + new_item = new_materials_dict.get(key) + + if old_item and not new_item: + # 삭제된 항목 + removed_items.append({ + "key": key, + "item": old_item, + "change_type": "removed" + }) + elif not old_item and new_item: + # 추가된 항목 + added_items.append({ + "key": key, + "item": new_item, + "change_type": "added" + }) + elif old_item and new_item: + # 수량 변경 확인 + if old_item["quantity"] != new_item["quantity"]: + 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" + }) + + # 분류별 통계 + def calculate_category_stats(items): + stats = {} + for item in items: + category = item.get("item", {}).get("classified_category", "OTHER") + if category not in stats: + stats[category] = {"count": 0, "total_quantity": 0} + stats[category]["count"] += 1 + stats[category]["total_quantity"] += item.get("item", {}).get("quantity", 0) + return stats + + added_stats = calculate_category_stats(added_items) + removed_stats = calculate_category_stats(removed_items) + changed_stats = calculate_category_stats(changed_items) + + return { + "success": True, + "comparison": { + "old_revision": old_revision, + "new_revision": new_revision, + "filename": filename, + "job_no": job_no, + "summary": { + "added_count": len(added_items), + "removed_count": len(removed_items), + "changed_count": len(changed_items), + "total_changes": len(added_items) + len(removed_items) + len(changed_items) + }, + "changes": { + "added": added_items, + "removed": removed_items, + "changed": changed_items + }, + "category_stats": { + "added": added_stats, + "removed": removed_stats, + "changed": changed_stats + } + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}") + +@router.post("/materials/update-classification-details") +async def update_classification_details( + file_id: Optional[int] = None, + db: Session = Depends(get_db) +): + """기존 자재들의 classification_details 업데이트""" + try: + # 업데이트할 자재들 조회 + query = """ + SELECT id, original_description, size_spec, classified_category + FROM materials + WHERE classification_details IS NULL OR classification_details = '{}' + """ + params = {} + if file_id: + query += " AND file_id = :file_id" + params["file_id"] = file_id + + query += " ORDER BY id" + result = db.execute(text(query), params) + materials = result.fetchall() + + if not materials: + return { + "success": True, + "message": "업데이트할 자재가 없습니다.", + "updated_count": 0 + } + + updated_count = 0 + for material in materials: + material_id = material.id + description = material.original_description + size_spec = material.size_spec + category = material.classified_category + + print(f"자재 {material_id} 재분류 중: {description}") + + # 카테고리별로 적절한 분류기 호출 + classification_result = None + + if category == 'PIPE': + classification_result = classify_pipe("", description, size_spec, 0.0) + elif category == 'FITTING': + classification_result = classify_fitting("", description, size_spec) + elif category == 'VALVE': + classification_result = classify_valve("", description, size_spec) + elif category == 'FLANGE': + classification_result = classify_flange("", description, size_spec) + elif category == 'BOLT': + classification_result = classify_bolt("", description, size_spec) + elif category == 'GASKET': + classification_result = classify_gasket("", description, size_spec) + elif category == 'INSTRUMENT': + classification_result = classify_instrument("", description, size_spec) + else: + # 카테고리가 없으면 모든 분류기 시도 + classification_result = classify_pipe("", description, size_spec, 0.0) + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_fitting("", description, size_spec) + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_valve("", description, size_spec) + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_flange("", description, size_spec) + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_bolt("", description, size_spec) + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_gasket("", description, size_spec) + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_instrument("", description, size_spec) + + if classification_result: + # classification_details를 JSON으로 직렬화 + classification_details = json.dumps(classification_result, ensure_ascii=False) + + # DB 업데이트 + update_query = text(""" + UPDATE materials + SET classification_details = :classification_details, + updated_at = NOW() + WHERE id = :material_id + """) + + db.execute(update_query, { + "material_id": material_id, + "classification_details": classification_details + }) + + updated_count += 1 + print(f"자재 {material_id} 업데이트 완료") + + db.commit() + + return { + "success": True, + "message": f"{updated_count}개 자재의 분류 상세정보가 업데이트되었습니다.", + "updated_count": updated_count, + "total_materials": len(materials) + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"분류 상세정보 업데이트 실패: {str(e)}") diff --git a/backend/app/main.py b/backend/app/main.py index aa3deef..9df1b56 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -440,6 +440,7 @@ def parse_file(file_path: str) -> List[Dict]: column_mapping = { 'description': ['DESCRIPTION', 'Description', 'description', 'DESC', 'Desc', 'desc', 'ITEM', 'Item', 'item'], 'quantity': ['QTY', 'Quantity', 'quantity', 'QTY.', 'Qty', 'qty', 'AMOUNT', 'Amount', 'amount'], + 'length': ['LENGTH', 'Length', 'length', 'LG', 'Lg', 'lg', 'LENGTH_MM', 'Length_mm', 'length_mm'], 'unit': ['UNIT', 'Unit', 'unit', 'UOM', 'Uom', 'uom'], 'drawing': ['DRAWING', 'Drawing', 'drawing', 'DWG', 'Dwg', 'dwg'], 'area': ['AREA', 'Area', 'area', 'AREA_CODE', 'Area_Code', 'area_code'], @@ -466,6 +467,8 @@ def parse_file(file_path: str) -> List[Dict]: description = str(row.get(found_columns.get('description', ''), '') or '') quantity_raw = row.get(found_columns.get('quantity', 1), 1) quantity = float(quantity_raw) if quantity_raw is not None else 1.0 + length_raw = row.get(found_columns.get('length', 0), 0) + length = float(length_raw) if length_raw is not None else 0.0 unit = str(row.get(found_columns.get('unit', 'EA'), 'EA') or 'EA') drawing = str(row.get(found_columns.get('drawing', ''), '') or '') area = str(row.get(found_columns.get('area', ''), '') or '') @@ -475,6 +478,7 @@ def parse_file(file_path: str) -> List[Dict]: "line_number": index + 1, "original_description": description, "quantity": quantity, + "length": length, "unit": unit, "drawing_name": drawing, "area_code": area, @@ -505,42 +509,85 @@ def classify_material_item(material: Dict) -> Dict: ) description = material.get("original_description", "") + size_spec = material.get("size_spec", "") + length = material.get("length", 0.0) # 길이 정보 추가 - # 각 분류기로 분류 시도 - classifiers = [ - ("PIPE", pipe_classifier.classify_pipe), - ("FITTING", fitting_classifier.classify_fitting), - ("BOLT", bolt_classifier.classify_bolt), - ("VALVE", valve_classifier.classify_valve), - ("INSTRUMENT", instrument_classifier.classify_instrument), - ("FLANGE", flange_classifier.classify_flange), - ("GASKET", gasket_classifier.classify_gasket) - ] + print(f"분류 시도: {description}") - best_result = None - best_confidence = 0.0 + # 각 분류기로 분류 시도 (개선된 순서와 기준) + desc_upper = description.upper() - for category, classifier_func in classifiers: - try: - result = classifier_func(description) - if result and result.get("confidence", 0) > best_confidence: - best_result = result - best_confidence = result.get("confidence", 0) - except Exception: - continue + # 1. 명확한 키워드 우선 확인 (높은 신뢰도) + if any(keyword in desc_upper for keyword in ['FLG', 'FLANGE', '플랜지', 'RF', 'WN', 'SO', 'BLIND']): + classification_result = flange_classifier.classify_flange("", description, size_spec, length) + print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + elif any(keyword in desc_upper for keyword in ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', '밸브', '게이트', '볼']): + classification_result = valve_classifier.classify_valve("", description, size_spec, length) + print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + elif any(keyword in desc_upper for keyword in ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'CAP', 'COUPLING', '엘보', '티', '리듀서', '캡']): + classification_result = fitting_classifier.classify_fitting("", description, size_spec, length) + print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + elif any(keyword in desc_upper for keyword in ['BOLT', 'STUD', 'NUT', 'SCREW', '볼트', '너트', '스터드']): + classification_result = bolt_classifier.classify_bolt("", description, size_spec, length) + print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + elif any(keyword in desc_upper for keyword in ['GASKET', 'GASK', '가스켓']): + classification_result = gasket_classifier.classify_gasket("", description, size_spec, length) + print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + elif any(keyword in desc_upper for keyword in ['GAUGE', 'SENSOR', 'TRANSMITTER', 'INSTRUMENT', '계기', '게이지']): + classification_result = instrument_classifier.classify_instrument("", description, size_spec, length) + print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + elif any(keyword in desc_upper for keyword in ['PIPE', 'TUBE', '파이프', '배관']): + classification_result = pipe_classifier.classify_pipe("", description, size_spec, length) + print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + else: + # 2. 일반적인 분류 시도 (낮은 신뢰도 임계값) + classification_result = flange_classifier.classify_flange("", description, size_spec, length) + print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.3: + classification_result = valve_classifier.classify_valve("", description, size_spec, length) + print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.3: + classification_result = fitting_classifier.classify_fitting("", description, size_spec, length) + print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.3: + classification_result = pipe_classifier.classify_pipe("", description, size_spec, length) + print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.3: + classification_result = bolt_classifier.classify_bolt("", description, size_spec, length) + print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.3: + classification_result = gasket_classifier.classify_gasket("", description, size_spec, length) + print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.3: + classification_result = instrument_classifier.classify_instrument("", description, size_spec, length) + print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") # 재질 분류 material_result = material_classifier.classify_material(description) # 최종 결과 조합 + # schedule이 딕셔너리인 경우 문자열로 변환 + schedule_value = classification_result.get("schedule", "") + if isinstance(schedule_value, dict): + schedule_value = schedule_value.get("schedule", "") + final_result = { **material, - "classified_category": best_result.get("category", "UNKNOWN") if best_result else "UNKNOWN", - "classified_subcategory": best_result.get("subcategory", "") if best_result else "", + "classified_category": classification_result.get("category", "UNKNOWN"), + "classified_subcategory": classification_result.get("subcategory", ""), "material_grade": material_result.get("grade", "") if material_result else "", - "schedule": best_result.get("schedule", "") if best_result else "", - "size_spec": best_result.get("size_spec", "") if best_result else "", - "classification_confidence": best_confidence + "schedule": schedule_value, + "size_spec": classification_result.get("size_spec", ""), + "classification_confidence": classification_result.get("overall_confidence", 0.0), + "length": length # 길이 정보 추가 } return final_result diff --git a/backend/app/models.py b/backend/app/models.py index 59c76ef..3dfea24 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Numeric, ForeignKey +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Numeric, ForeignKey, JSON from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship from datetime import datetime @@ -59,12 +59,13 @@ class Material(Base): size_spec = Column(String(50)) quantity = Column(Numeric(10, 3), nullable=False) unit = Column(String(10), nullable=False) + # length = Column(Numeric(10, 3)) # 임시로 주석 처리 drawing_name = Column(String(100)) area_code = Column(String(20)) line_no = Column(String(50)) classification_confidence = Column(Numeric(3, 2)) is_verified = Column(Boolean, default=False) - verified_by = Column(String(100)) + verified_by = Column(String(50)) verified_at = Column(DateTime) drawing_reference = Column(String(100)) notes = Column(Text) @@ -72,3 +73,239 @@ class Material(Base): # 관계 설정 file = relationship("File", back_populates="materials") + +# ========== 자재 규격/재질 기준표 테이블들 ========== + +class MaterialStandard(Base): + """자재 규격 표준 (ASTM, KS, JIS 등)""" + __tablename__ = "material_standards" + + id = Column(Integer, primary_key=True, index=True) + standard_code = Column(String(20), unique=True, nullable=False, index=True) # ASTM_ASME, KS, JIS + standard_name = Column(String(100), nullable=False) # 미국재질학회, 한국산업표준, 일본공업규격 + description = Column(Text) + country = Column(String(50)) # USA, KOREA, JAPAN + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + categories = relationship("MaterialCategory", back_populates="standard") + +class MaterialCategory(Base): + """제조방식별 카테고리 (FORGED, WELDED, CAST 등)""" + __tablename__ = "material_categories" + + id = Column(Integer, primary_key=True, index=True) + standard_id = Column(Integer, ForeignKey("material_standards.id")) + category_code = Column(String(50), nullable=False) # FORGED_GRADES, WELDED_GRADES, CAST_GRADES + category_name = Column(String(100), nullable=False) # 단조품, 용접품, 주조품 + description = Column(Text) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + standard = relationship("MaterialStandard", back_populates="categories") + specifications = relationship("MaterialSpecification", back_populates="category") + +class MaterialSpecification(Base): + """구체적인 규격 (A182, A105, D3507 등)""" + __tablename__ = "material_specifications" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("material_categories.id")) + spec_code = Column(String(20), nullable=False) # A182, A105, D3507 + spec_name = Column(String(100), nullable=False) # 탄소강 단조품, 배관용 탄소강관 + description = Column(Text) + material_type = Column(String(50)) # carbon_alloy, stainless, carbon + manufacturing = Column(String(50)) # FORGED, WELDED_FABRICATED, CAST, SEAMLESS + pressure_rating = Column(String(100)) # 150LB ~ 9000LB + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + category = relationship("MaterialCategory", back_populates="specifications") + grades = relationship("MaterialGrade", back_populates="specification") + patterns = relationship("MaterialPattern", back_populates="specification") + +class MaterialGrade(Base): + """등급별 상세 정보 (F1, F5, WPA, WPB 등)""" + __tablename__ = "material_grades" + + id = Column(Integer, primary_key=True, index=True) + specification_id = Column(Integer, ForeignKey("material_specifications.id")) + grade_code = Column(String(20), nullable=False) # F1, F5, WPA, WPB + grade_name = Column(String(100)) + composition = Column(String(200)) # 0.5Mo, 5Cr-0.5Mo, 18Cr-8Ni + applications = Column(String(200)) # 중온용, 고온용, 저압용 + temp_max = Column(String(50)) # 482°C, 649°C + temp_range = Column(String(100)) # -29°C ~ 400°C + yield_strength = Column(String(50)) # 30 ksi, 35 ksi + tensile_strength = Column(String(50)) + corrosion_resistance = Column(String(50)) # 보통, 우수 + stabilizer = Column(String(50)) # Titanium, Niobium + base_grade = Column(String(20)) # 304, 316 + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specification = relationship("MaterialSpecification", back_populates="grades") + +class MaterialPattern(Base): + """정규식 패턴들""" + __tablename__ = "material_patterns" + + id = Column(Integer, primary_key=True, index=True) + specification_id = Column(Integer, ForeignKey("material_specifications.id")) + pattern = Column(Text, nullable=False) # 정규식 패턴 + description = Column(String(200)) + priority = Column(Integer, default=1) # 패턴 우선순위 + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + specification = relationship("MaterialSpecification", back_populates="patterns") + +class SpecialMaterial(Base): + """특수 재질 (INCONEL, HASTELLOY, TITANIUM 등)""" + __tablename__ = "special_materials" + + id = Column(Integer, primary_key=True, index=True) + material_type = Column(String(50), nullable=False) # SUPER_ALLOYS, TITANIUM, COPPER_ALLOYS + material_name = Column(String(100), nullable=False) # INCONEL, HASTELLOY, TITANIUM + description = Column(Text) + composition = Column(String(200)) + applications = Column(Text) + temp_max = Column(String(50)) + manufacturing = Column(String(50)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + grades = relationship("SpecialMaterialGrade", back_populates="material") + patterns = relationship("SpecialMaterialPattern", back_populates="material") + +class SpecialMaterialGrade(Base): + """특수 재질 등급""" + __tablename__ = "special_material_grades" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("special_materials.id")) + grade_code = Column(String(20), nullable=False) # 600, 625, C276 + composition = Column(String(200)) + applications = Column(String(200)) + temp_max = Column(String(50)) + strength = Column(String(50)) + purity = Column(String(100)) + corrosion = Column(String(50)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("SpecialMaterial", back_populates="grades") + +class SpecialMaterialPattern(Base): + """특수 재질 정규식 패턴""" + __tablename__ = "special_material_patterns" + + id = Column(Integer, primary_key=True, index=True) + material_id = Column(Integer, ForeignKey("special_materials.id")) + pattern = Column(Text, nullable=False) + description = Column(String(200)) + priority = Column(Integer, default=1) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + material = relationship("SpecialMaterial", back_populates="patterns") + +# ========== 파이프 상세 정보 및 사용자 요구사항 테이블 ========== + +class PipeDetail(Base): + """파이프 상세 정보""" + __tablename__ = "pipe_details" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 재질 정보 + material_standard = Column(String(50)) # ASTM, KS, JIS 등 + material_grade = Column(String(50)) # A106, A53, STPG370 등 + material_type = Column(String(50)) # CARBON, STAINLESS 등 + + # 파이프 특화 정보 + manufacturing_method = Column(String(50)) # SEAMLESS, WELDED, CAST + end_preparation = Column(String(50)) # BOTH_ENDS_BEVELED, ONE_END_BEVELED, NO_BEVEL + schedule = Column(String(50)) # SCH 10, 20, 40, 80 등 + wall_thickness = Column(String(50)) # 벽두께 정보 + + # 치수 정보 + nominal_size = Column(String(50)) # MAIN_NOM (인치, 직경) + length_mm = Column(Numeric(10, 3)) # LENGTH (길이) + + # 신뢰도 + material_confidence = Column(Numeric(3, 2)) + manufacturing_confidence = Column(Numeric(3, 2)) + end_prep_confidence = Column(Numeric(3, 2)) + schedule_confidence = Column(Numeric(3, 2)) + + # 메타데이터 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + file = relationship("File", backref="pipe_details") + +class RequirementType(Base): + """요구사항 타입 마스터""" + __tablename__ = "requirement_types" + + id = Column(Integer, primary_key=True, index=True) + type_code = Column(String(50), unique=True, nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT' 등 + type_name = Column(String(100), nullable=False) # '임팩테스트', '열처리' 등 + category = Column(String(50), nullable=False) # 'TEST', 'TREATMENT', 'CERTIFICATION', 'CUSTOM' 등 + description = Column(Text) # 타입 설명 + is_active = Column(Boolean, default=True) + + created_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + requirements = relationship("UserRequirement", back_populates="requirement_type") + +class UserRequirement(Base): + """사용자 추가 요구사항""" + __tablename__ = "user_requirements" + + id = Column(Integer, primary_key=True, index=True) + file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + + # 요구사항 타입 + requirement_type = Column(String(50), nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 + + # 요구사항 내용 + requirement_title = Column(String(200), nullable=False) # '임팩테스트', '열처리', '인증서' 등 + requirement_description = Column(Text) # 상세 설명 + requirement_spec = Column(Text) # 구체적 스펙 (예: "Charpy V-notch -20°C") + + # 상태 관리 + status = Column(String(20), default='PENDING') # 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED' + priority = Column(String(20), default='NORMAL') # 'LOW', 'NORMAL', 'HIGH', 'URGENT' + + # 담당자 정보 + assigned_to = Column(String(100)) # 담당자명 + due_date = Column(DateTime) # 완료 예정일 + + # 메타데이터 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + # 관계 설정 + file = relationship("File", backref="user_requirements") + requirement_type_rel = relationship("RequirementType", back_populates="requirements") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index d893f3d..aa95029 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -113,6 +113,15 @@ def parse_dataframe(df): else: size_spec = "" + # LENGTH 정보 추출 + length_raw = row.get(mapped_columns.get('length', ''), '') + length_value = None + if pd.notna(length_raw) and str(length_raw).strip() != '': + try: + length_value = float(str(length_raw).strip()) + except (ValueError, TypeError): + length_value = None + if description and description not in ['nan', 'None', '']: materials.append({ 'original_description': description, @@ -120,6 +129,7 @@ def parse_dataframe(df): 'unit': "EA", 'size_spec': size_spec, 'material_grade': material_grade, + 'length': length_value, 'line_number': index + 1, 'row_number': index + 1 }) @@ -169,12 +179,12 @@ async def upload_file( raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}") try: - print("파일 저장 시작") + print("파일 파싱 시작") materials_data = parse_file_data(str(file_path)) parsed_count = len(materials_data) print(f"파싱 완료: {parsed_count}개 자재") - # 파일 정보 저장 (project_id 제거) + # 파일 정보 저장 print("DB 저장 시작") file_insert_query = text(""" INSERT INTO files (filename, original_filename, file_path, job_no, revision, description, file_size, parsed_count, is_active) @@ -198,41 +208,68 @@ async def upload_file( print(f"파일 저장 완료: file_id = {file_id}") # 자재 데이터 저장 (분류 포함) - print("자재 분류 및 저장 시작") materials_inserted = 0 - classification_stats = { - 'BOLT': 0, 'FLANGE': 0, 'FITTING': 0, 'GASKET': 0, - 'INSTRUMENT': 0, 'PIPE': 0, 'VALVE': 0, 'MATERIAL': 0, 'OTHER': 0 - } - for material_data in materials_data: - # 자재 분류 실행 - classification_result = classify_material_item( - material_data["original_description"], - material_data["size_spec"] - ) + # 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등) + description = material_data["original_description"] + size_spec = material_data["size_spec"] - # 분류 통계 업데이트 - category = classification_result.get('category', 'OTHER') - if category in classification_stats: - classification_stats[category] += 1 + # 각 분류기로 시도 (올바른 매개변수 사용) + print(f"분류 시도: {description}") + # LENGTH 정보 추출 + length_value = None + if "length" in material_data: + try: + length_value = float(material_data["length"]) + except (ValueError, TypeError): + length_value = None + + classification_result = classify_pipe("", description, size_spec, length_value) + print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_fitting("", description, size_spec) + print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_valve("", description, size_spec) + print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_flange("", description, size_spec) + print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_bolt("", description, size_spec) + print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_gasket("", description, size_spec) + print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + if classification_result.get("overall_confidence", 0) < 0.5: + classification_result = classify_instrument("", description, size_spec) + print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})") + + print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") + + # 기본 자재 정보 저장 material_insert_query = text(""" INSERT INTO materials ( file_id, original_description, quantity, unit, size_spec, material_grade, line_number, row_number, classified_category, - classification_confidence, is_verified, created_at, - subcategory, standard, grade + classification_confidence, is_verified, created_at ) VALUES ( :file_id, :original_description, :quantity, :unit, :size_spec, :material_grade, :line_number, :row_number, :classified_category, - :classification_confidence, :is_verified, :created_at, - :subcategory, :standard, :grade + :classification_confidence, :is_verified, :created_at ) + RETURNING id """) - db.execute(material_insert_query, { + material_result = db.execute(material_insert_query, { "file_id": file_id, "original_description": material_data["original_description"], "quantity": material_data["quantity"], @@ -241,290 +278,307 @@ async def upload_file( "material_grade": material_data["material_grade"], "line_number": material_data["line_number"], "row_number": material_data["row_number"], - "classified_category": classification_result.get('category', 'OTHER'), - "classification_confidence": classification_result.get('confidence', 0.0), + "classified_category": classification_result.get("category", "UNKNOWN"), + "classification_confidence": classification_result.get("overall_confidence", 0.0), "is_verified": False, - "created_at": datetime.now(), - "subcategory": classification_result.get('subcategory', ''), - "standard": classification_result.get('standard', ''), - "grade": classification_result.get('grade', '') + "created_at": datetime.now() }) + + material_id = material_result.fetchone()[0] materials_inserted += 1 + + # PIPE 분류 결과인 경우 상세 정보 저장 + if classification_result.get("category") == "PIPE": + print("PIPE 상세 정보 저장 시작") + + # 길이 정보 추출 + length_mm = None + if "length_info" in classification_result: + length_mm = classification_result["length_info"].get("length_mm") + + pipe_detail_insert_query = text(""" + INSERT INTO pipe_details ( + file_id, material_standard, material_grade, material_type, + manufacturing_method, end_preparation, schedule, wall_thickness, + nominal_size, length_mm, material_confidence, manufacturing_confidence, + end_prep_confidence, schedule_confidence + ) + VALUES ( + :file_id, :material_standard, :material_grade, :material_type, + :manufacturing_method, :end_preparation, :schedule, :wall_thickness, + :nominal_size, :length_mm, :material_confidence, :manufacturing_confidence, + :end_prep_confidence, :schedule_confidence + ) + """) + + # 재질 정보 + material_info = classification_result.get("material", {}) + manufacturing_info = classification_result.get("manufacturing", {}) + end_prep_info = classification_result.get("end_preparation", {}) + schedule_info = classification_result.get("schedule", {}) + size_info = classification_result.get("size_info", {}) + + db.execute(pipe_detail_insert_query, { + "file_id": file_id, + "material_standard": material_info.get("standard"), + "material_grade": material_info.get("grade"), + "material_type": material_info.get("material_type"), + "manufacturing_method": manufacturing_info.get("method"), + "end_preparation": end_prep_info.get("type"), + "schedule": schedule_info.get("schedule"), + "wall_thickness": schedule_info.get("wall_thickness"), + "nominal_size": size_info.get("nominal_size"), + "length_mm": length_mm, + "material_confidence": material_info.get("confidence", 0.0), + "manufacturing_confidence": manufacturing_info.get("confidence", 0.0), + "end_prep_confidence": end_prep_info.get("confidence", 0.0), + "schedule_confidence": schedule_info.get("confidence", 0.0) + }) + + print("PIPE 상세 정보 저장 완료") - print(f"자재 저장 완료: {materials_inserted}개") - print("커밋 직전") db.commit() - print("커밋 완료") + print(f"자재 저장 완료: {materials_inserted}개") return { "success": True, - "message": f"완전한 DB 저장 성공! {materials_inserted}개 자재 저장됨", + "message": f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다.", "original_filename": file.filename, "file_id": file_id, - "parsed_materials_count": parsed_count, - "saved_materials_count": materials_inserted, - "classification_stats": classification_stats, - "sample_materials": materials_data[:3] if materials_data else [] + "materials_count": materials_inserted, + "parsed_count": parsed_count } except Exception as e: db.rollback() if os.path.exists(file_path): os.remove(file_path) - import traceback - print(traceback.format_exc()) # 에러 전체 로그 출력 raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}") + +@router.get("/files") +async def get_files( + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): + """파일 목록 조회""" + try: + query = """ + SELECT id, filename, original_filename, job_no, revision, + description, file_size, parsed_count, created_at, is_active + FROM files + WHERE is_active = TRUE + """ + params = {} + + if job_no: + query += " AND job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY created_at DESC" + + result = db.execute(text(query), params) + files = result.fetchall() + + return [ + { + "id": file.id, + "filename": file.filename, + "original_filename": file.original_filename, + "job_no": file.job_no, + "revision": file.revision, + "description": file.description, + "file_size": file.file_size, + "parsed_count": file.parsed_count, + "created_at": file.created_at, + "is_active": file.is_active + } + for file in files + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}") + +@router.delete("/files/{file_id}") +async def delete_file(file_id: int, db: Session = Depends(get_db)): + """파일 삭제""" + try: + # 자재 먼저 삭제 + db.execute(text("DELETE FROM materials WHERE file_id = :file_id"), {"file_id": file_id}) + + # 파일 삭제 + result = db.execute(text("DELETE FROM files WHERE id = :file_id"), {"file_id": file_id}) + + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") + + db.commit() + + return { + "success": True, + "message": "파일이 삭제되었습니다" + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}") + @router.get("/materials") async def get_materials( project_id: Optional[int] = None, - job_id: Optional[int] = None, + file_id: Optional[int] = None, + job_no: Optional[str] = None, + filename: Optional[str] = None, revision: Optional[str] = None, - grouping: Optional[str] = None, + skip: int = 0, + limit: int = 100, search: Optional[str] = None, - search_value: Optional[str] = None, item_type: Optional[str] = None, material_grade: Optional[str] = None, size_spec: Optional[str] = None, file_filter: Optional[str] = None, sort_by: Optional[str] = None, - skip: int = 0, - limit: int = 100, db: Session = Depends(get_db) ): - """자재 목록 조회 (개선된 버전)""" + """ + 저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능) + """ try: - # 기본 쿼리 구성 - base_query = """ - SELECT - m.id, - m.original_description, - m.quantity, - m.unit, - m.size_spec, - m.material_grade, - m.line_number, - m.row_number, - m.classified_category, - m.classification_confidence, - m.is_verified, - m.created_at, - f.job_no as job_number, - f.revision, - f.original_filename, - f.project_id, - p.project_name, - COUNT(*) OVER() as total_count + query = """ + SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, + m.size_spec, m.material_grade, m.line_number, m.row_number, + m.created_at, m.classified_category, m.classification_confidence, + f.original_filename, f.project_id, f.job_no, f.revision, + p.official_project_code, p.project_name FROM materials m LEFT JOIN files f ON m.file_id = f.id LEFT JOIN projects p ON f.project_id = p.id WHERE 1=1 """ - params = {} - conditions = [] - - # 프로젝트 필터 if project_id: - conditions.append("f.project_id = :project_id") + query += " AND f.project_id = :project_id" params["project_id"] = project_id - - # Job ID 필터 - if job_id: - conditions.append("f.job_no = (SELECT job_no FROM jobs WHERE id = :job_id)") - params["job_id"] = job_id - - # 리비전 필터 + if file_id: + query += " AND m.file_id = :file_id" + params["file_id"] = file_id + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + if filename: + query += " AND f.original_filename = :filename" + params["filename"] = filename if revision: - conditions.append("f.revision = :revision") + query += " AND f.revision = :revision" params["revision"] = revision - - # 검색 필터 (개선된 버전) - if search and search_value: - try: - if search == "project": - conditions.append("p.project_name ILIKE :search_value") - elif search == "job": - conditions.append("f.job_no ILIKE :search_value") - elif search == "material": - conditions.append("m.original_description ILIKE :search_value") - elif search == "description": - conditions.append("m.original_description ILIKE :search_value") - elif search == "grade": - conditions.append("m.material_grade ILIKE :search_value") - elif search == "size": - conditions.append("m.size_spec ILIKE :search_value") - elif search == "filename": - conditions.append("f.original_filename ILIKE :search_value") - else: - # 기본 검색 (기존 방식) - conditions.append("(m.original_description ILIKE :search_value OR m.material_grade ILIKE :search_value)") - - params["search_value"] = f"%{search_value}%" - except Exception as e: - print(f"검색 필터 처리 오류: {e}") - # 오류 발생 시 기본 검색으로 fallback - conditions.append("(m.original_description ILIKE :search_value OR m.material_grade ILIKE :search_value)") - params["search_value"] = f"%{search_value}%" - - # 품목 타입 필터 + if search: + query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" + params["search"] = f"%{search}%" if item_type: - conditions.append("m.classified_category = :item_type") + query += " AND m.classified_category = :item_type" params["item_type"] = item_type - - # 재질 필터 if material_grade: - conditions.append("m.material_grade ILIKE :material_grade") + query += " AND m.material_grade ILIKE :material_grade" params["material_grade"] = f"%{material_grade}%" - - # 사이즈 필터 if size_spec: - conditions.append("m.size_spec ILIKE :size_spec") + query += " AND m.size_spec ILIKE :size_spec" params["size_spec"] = f"%{size_spec}%" - - # 파일명 필터 if file_filter: - conditions.append("f.original_filename ILIKE :file_filter") + query += " AND f.original_filename ILIKE :file_filter" params["file_filter"] = f"%{file_filter}%" - # 조건 추가 - if conditions: - base_query += " AND " + " AND ".join(conditions) - - # 그룹핑 처리 - if grouping: - if grouping == "item": - base_query += " GROUP BY m.classified_category, m.original_description, m.size_spec, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.revision, f.original_filename, f.project_id, p.project_name" - elif grouping == "material": - base_query += " GROUP BY m.material_grade, m.original_description, m.size_spec, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.revision, f.original_filename, f.project_id, p.project_name" - elif grouping == "size": - base_query += " GROUP BY m.size_spec, m.original_description, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.revision, f.original_filename, f.project_id, p.project_name" - elif grouping == "job": - base_query += " GROUP BY f.job_no, m.original_description, m.size_spec, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.revision, f.original_filename, f.project_id, p.project_name" - elif grouping == "revision": - base_query += " GROUP BY f.revision, m.original_description, m.size_spec, m.material_grade, m.id, m.quantity, m.unit, m.line_number, m.row_number, m.classified_category, m.classification_confidence, m.is_verified, m.created_at, f.job_no, f.original_filename, f.project_id, p.project_name" - - # 정렬 + # 정렬 처리 if sort_by: if sort_by == "quantity_desc": - base_query += " ORDER BY SUM(m.quantity) DESC" + query += " ORDER BY m.quantity DESC" elif sort_by == "quantity_asc": - base_query += " ORDER BY SUM(m.quantity) ASC" + query += " ORDER BY m.quantity ASC" elif sort_by == "name_asc": - base_query += " ORDER BY m.original_description ASC" + query += " ORDER BY m.original_description ASC" elif sort_by == "name_desc": - base_query += " ORDER BY m.original_description DESC" + query += " ORDER BY m.original_description DESC" elif sort_by == "created_desc": - base_query += " ORDER BY m.created_at DESC" + query += " ORDER BY m.created_at DESC" elif sort_by == "created_asc": - base_query += " ORDER BY m.created_at ASC" + query += " ORDER BY m.created_at ASC" else: - base_query += " ORDER BY m.id DESC" + query += " ORDER BY m.line_number ASC" else: - base_query += " ORDER BY m.id DESC" - - # 페이징 - base_query += " LIMIT :limit OFFSET :skip" + query += " ORDER BY m.line_number ASC" + + query += " LIMIT :limit OFFSET :skip" params["limit"] = limit params["skip"] = skip - result = db.execute(text(base_query), params) + result = db.execute(text(query), params) materials = result.fetchall() - # 리비전 비교 데이터 생성 - revision_comparison = None - if revision and revision != "Rev.0": - comparison_query = """ - SELECT - m.original_description, - m.size_spec, - m.material_grade, - SUM(CASE WHEN f.revision = :current_revision THEN m.quantity ELSE 0 END) as current_qty, - SUM(CASE WHEN f.revision = :prev_revision THEN m.quantity ELSE 0 END) as prev_qty - FROM materials m - LEFT JOIN files f ON m.file_id = f.id - WHERE f.project_id = :project_id - AND f.revision IN (:current_revision, :prev_revision) - GROUP BY m.original_description, m.size_spec, m.material_grade - HAVING - SUM(CASE WHEN f.revision = :current_revision THEN m.quantity ELSE 0 END) != - SUM(CASE WHEN f.revision = :prev_revision THEN m.quantity ELSE 0 END) - """ - - comparison_params = { - "project_id": project_id, - "current_revision": revision, - "prev_revision": f"Rev.{int(revision.split('.')[-1]) - 1}" - } - - comparison_result = db.execute(text(comparison_query), comparison_params) - comparison_data = comparison_result.fetchall() - - if comparison_data: - changes = [] - for row in comparison_data: - change = row.current_qty - row.prev_qty - if change != 0: - changes.append({ - "description": row.original_description, - "size_spec": row.size_spec, - "material_grade": row.material_grade, - "current_qty": row.current_qty, - "prev_qty": row.prev_qty, - "change": change - }) - - revision_comparison = { - "summary": f"{revision}에서 {len(changes)}개 항목이 변경되었습니다", - "changes": changes - } + # 전체 개수 조회 + count_query = """ + SELECT COUNT(*) as total + FROM materials m + LEFT JOIN files f ON m.file_id = f.id + WHERE 1=1 + """ + count_params = {} - # 결과 포맷팅 - formatted_materials = [] - for material in materials: - # 라인 번호 문자열 생성 - line_numbers = [material.line_number] if material.line_number else [] - line_numbers_str = ", ".join(map(str, line_numbers)) if line_numbers else "" - - # 수량 변경 계산 (리비전 비교) - quantity_change = None - if revision_comparison: - for change in revision_comparison["changes"]: - if (change["description"] == material.original_description and - change["size_spec"] == material.size_spec and - change["material_grade"] == material.material_grade): - quantity_change = change["change"] - break - - formatted_material = { - "id": material.id, - "original_description": material.original_description, - "quantity": float(material.quantity) if material.quantity else 0, - "unit": material.unit or "EA", - "size_spec": material.size_spec or "", - "material_grade": material.material_grade or "", - "line_number": material.line_number, - "line_numbers_str": line_numbers_str, - "line_count": len(line_numbers), - "classified_category": material.classified_category or "OTHER", - "classification_confidence": float(material.classification_confidence) if material.classification_confidence else 0, - "is_verified": material.is_verified or False, - "created_at": material.created_at.isoformat() if material.created_at else None, - "job_number": material.job_number, - "revision": material.revision or "Rev.0", - "original_filename": material.original_filename, - "project_id": material.project_id, - "project_name": material.project_name, - "quantity_change": quantity_change - } - - formatted_materials.append(formatted_material) + if project_id: + count_query += " AND f.project_id = :project_id" + count_params["project_id"] = project_id - total_count = materials[0].total_count if materials else 0 + if file_id: + count_query += " AND m.file_id = :file_id" + count_params["file_id"] = file_id + + if search: + count_query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)" + count_params["search"] = f"%{search}%" + + if item_type: + count_query += " AND m.classified_category = :item_type" + count_params["item_type"] = item_type + + if material_grade: + count_query += " AND m.material_grade ILIKE :material_grade" + count_params["material_grade"] = f"%{material_grade}%" + + if size_spec: + count_query += " AND m.size_spec ILIKE :size_spec" + count_params["size_spec"] = f"%{size_spec}%" + + if file_filter: + count_query += " AND f.original_filename ILIKE :file_filter" + count_params["file_filter"] = f"%{file_filter}%" + + count_result = db.execute(text(count_query), count_params) + total_count = count_result.fetchone()[0] return { - "materials": formatted_materials, + "success": True, "total_count": total_count, - "revision_comparison": revision_comparison + "returned_count": len(materials), + "skip": skip, + "limit": limit, + "materials": [ + { + "id": m.id, + "file_id": m.file_id, + "filename": m.original_filename, + "project_id": m.project_id, + "project_code": m.official_project_code, + "project_name": m.project_name, + "original_description": m.original_description, + "quantity": float(m.quantity) if m.quantity else 0, + "unit": m.unit, + "size_spec": m.size_spec, + "material_grade": m.material_grade, + "line_number": m.line_number, + "row_number": m.row_number, + "classified_category": m.classified_category, + "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0, + "created_at": m.created_at + } + for m in materials + ] } except Exception as e: @@ -532,8 +586,8 @@ async def get_materials( @router.get("/materials/summary") async def get_materials_summary( - job_no: Optional[str] = None, - file_id: Optional[str] = None, + project_id: Optional[int] = None, + file_id: Optional[int] = None, db: Session = Depends(get_db) ): """자재 요약 통계""" @@ -555,9 +609,9 @@ async def get_materials_summary( params = {} - if job_no: - query += " AND f.job_no = :job_no" - params["job_no"] = job_no + if project_id: + query += " AND f.project_id = :project_id" + params["project_id"] = project_id if file_id: query += " AND m.file_id = :file_id" @@ -582,167 +636,345 @@ async def get_materials_summary( except Exception as e: raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}") -# Job 검증 함수 (파일 끝에 추가할 예정) -async def validate_job_exists(job_no: str, db: Session): - """Job 존재 여부 및 활성 상태 확인""" + +@router.get("/materials/compare-revisions") +async def compare_revisions( + job_no: str, + filename: str, + old_revision: str, + new_revision: str, + db: Session = Depends(get_db) +): + """ + 리비전 간 자재 비교 + """ try: - query = text("SELECT job_no, job_name, status FROM jobs WHERE job_no = :job_no AND is_active = true") - job = db.execute(query, {"job_no": job_no}).fetchone() + # 기존 리비전 자재 조회 + old_materials_query = text(""" + SELECT m.original_description, m.quantity, m.unit, m.size_spec, + m.material_grade, m.classified_category, m.classification_confidence + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.job_no = :job_no + AND f.original_filename = :filename + AND f.revision = :old_revision + """) - if not job: - return {"valid": False, "error": f"Job No. '{job_no}'를 찾을 수 없습니다"} + old_result = db.execute(old_materials_query, { + "job_no": job_no, + "filename": filename, + "old_revision": old_revision + }) + old_materials = old_result.fetchall() - if job.status == '완료': - return {"valid": False, "error": f"완료된 Job '{job.job_name}'에는 파일을 업로드할 수 없습니다"} + # 새 리비전 자재 조회 + new_materials_query = text(""" + SELECT m.original_description, m.quantity, m.unit, m.size_spec, + m.material_grade, m.classified_category, m.classification_confidence + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE f.job_no = :job_no + AND f.original_filename = :filename + AND f.revision = :new_revision + """) + + new_result = db.execute(new_materials_query, { + "job_no": job_no, + "filename": filename, + "new_revision": new_revision + }) + new_materials = new_result.fetchall() + + # 자재 키 생성 함수 + def create_material_key(material): + return f"{material.original_description}_{material.size_spec}_{material.material_grade}" + + # 기존 자재를 딕셔너리로 변환 + 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 + } + + # 새 자재를 딕셔너리로 변환 + 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 + } + + # 변경 사항 분석 + all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys()) + + added_items = [] + removed_items = [] + changed_items = [] + + for key in all_keys: + old_item = old_materials_dict.get(key) + new_item = new_materials_dict.get(key) + + if old_item and not new_item: + # 삭제된 항목 + removed_items.append({ + "key": key, + "item": old_item, + "change_type": "removed" + }) + elif not old_item and new_item: + # 추가된 항목 + added_items.append({ + "key": key, + "item": new_item, + "change_type": "added" + }) + elif old_item and new_item: + # 수량 변경 확인 + if old_item["quantity"] != new_item["quantity"]: + 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" + }) + + # 분류별 통계 + def calculate_category_stats(items): + stats = {} + for item in items: + category = item.get("item", {}).get("classified_category", "OTHER") + if category not in stats: + stats[category] = {"count": 0, "total_quantity": 0} + stats[category]["count"] += 1 + stats[category]["total_quantity"] += item.get("item", {}).get("quantity", 0) + return stats + + added_stats = calculate_category_stats(added_items) + removed_stats = calculate_category_stats(removed_items) + changed_stats = calculate_category_stats(changed_items) return { - "valid": True, - "job": { - "job_no": job.job_no, - "job_name": job.job_name, - "status": job.status + "success": True, + "comparison": { + "old_revision": old_revision, + "new_revision": new_revision, + "filename": filename, + "job_no": job_no, + "summary": { + "added_count": len(added_items), + "removed_count": len(removed_items), + "changed_count": len(changed_items), + "total_changes": len(added_items) + len(removed_items) + len(changed_items) + }, + "changes": { + "added": added_items, + "removed": removed_items, + "changed": changed_items + }, + "category_stats": { + "added": added_stats, + "removed": removed_stats, + "changed": changed_stats + } } } except Exception as e: - return {"valid": False, "error": f"Job 검증 실패: {str(e)}"} -def get_major_category(description): - """간단한 키워드 기반 대분류""" - desc_upper = description.upper() - - if 'PIPE' in desc_upper or 'TUBE' in desc_upper: - return 'pipe' - elif any(word in desc_upper for word in ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'CAP', 'COUPLING']): - return 'fitting' - elif 'VALVE' in desc_upper: - return 'valve' - elif 'FLANGE' in desc_upper or 'FLG' in desc_upper: - return 'flange' - elif any(word in desc_upper for word in ['GAUGE', 'SENSOR', 'INSTRUMENT', 'TRANSMITTER']): - return 'instrument' - elif 'GASKET' in desc_upper or 'GASK' in desc_upper: - return 'gasket' - elif any(word in desc_upper for word in ['BOLT', 'STUD', 'NUT', 'SCREW']): - return 'bolt' - else: - return 'other' + raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}") -def classify_material_item(description: str, size_spec: str = "") -> dict: +@router.get("/pipe-details") +async def get_pipe_details( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + db: Session = Depends(get_db) +): """ - 자재를 각 분류기로 보내서 분류하는 통합 함수 - - Args: - description: 자재 설명 - size_spec: 사이즈 정보 - - Returns: - 분류 결과 딕셔너리 + PIPE 상세 정보 조회 """ - desc_upper = description.upper().strip() - - # 1. 볼트 분류 - bolt_result = classify_bolt(description) - if bolt_result.get('confidence', 0) > 0.7: + try: + query = """ + SELECT pd.*, f.original_filename, f.job_no, f.revision, + m.original_description, m.quantity, m.unit + FROM pipe_details pd + LEFT JOIN files f ON pd.file_id = f.id + LEFT JOIN materials m ON pd.file_id = m.file_id + AND m.classified_category = 'PIPE' + WHERE 1=1 + """ + params = {} + + if file_id: + query += " AND pd.file_id = :file_id" + params["file_id"] = file_id + + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + + query += " ORDER BY pd.created_at DESC" + + result = db.execute(text(query), params) + pipe_details = result.fetchall() + + return [ + { + "id": pd.id, + "file_id": pd.file_id, + "original_filename": pd.original_filename, + "job_no": pd.job_no, + "revision": pd.revision, + "original_description": pd.original_description, + "quantity": pd.quantity, + "unit": pd.unit, + "material_standard": pd.material_standard, + "material_grade": pd.material_grade, + "material_type": pd.material_type, + "manufacturing_method": pd.manufacturing_method, + "end_preparation": pd.end_preparation, + "schedule": pd.schedule, + "wall_thickness": pd.wall_thickness, + "nominal_size": pd.nominal_size, + "length_mm": pd.length_mm, + "material_confidence": pd.material_confidence, + "manufacturing_confidence": pd.manufacturing_confidence, + "end_prep_confidence": pd.end_prep_confidence, + "schedule_confidence": pd.schedule_confidence, + "created_at": pd.created_at, + "updated_at": pd.updated_at + } + for pd in pipe_details + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"PIPE 상세 정보 조회 실패: {str(e)}") + +@router.get("/user-requirements") +async def get_user_requirements( + file_id: Optional[int] = None, + job_no: Optional[str] = None, + status: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + 사용자 요구사항 조회 + """ + try: + query = """ + SELECT ur.*, f.original_filename, f.job_no, f.revision, + rt.type_name, rt.category + FROM user_requirements ur + LEFT JOIN files f ON ur.file_id = f.id + LEFT JOIN requirement_types rt ON ur.requirement_type = rt.type_code + WHERE 1=1 + """ + params = {} + + if file_id: + query += " AND ur.file_id = :file_id" + params["file_id"] = file_id + + if job_no: + query += " AND f.job_no = :job_no" + params["job_no"] = job_no + + if status: + query += " AND ur.status = :status" + params["status"] = status + + query += " ORDER BY ur.created_at DESC" + + result = db.execute(text(query), params) + requirements = result.fetchall() + + return [ + { + "id": req.id, + "file_id": req.file_id, + "original_filename": req.original_filename, + "job_no": req.job_no, + "revision": req.revision, + "requirement_type": req.requirement_type, + "type_name": req.type_name, + "category": req.category, + "requirement_title": req.requirement_title, + "requirement_description": req.requirement_description, + "requirement_spec": req.requirement_spec, + "status": req.status, + "priority": req.priority, + "assigned_to": req.assigned_to, + "due_date": req.due_date, + "created_at": req.created_at, + "updated_at": req.updated_at + } + for req in requirements + ] + + except Exception as e: + raise HTTPException(status_code=500, detail=f"사용자 요구사항 조회 실패: {str(e)}") + +@router.post("/user-requirements") +async def create_user_requirement( + file_id: int, + requirement_type: str, + requirement_title: str, + requirement_description: Optional[str] = None, + requirement_spec: Optional[str] = None, + priority: str = "NORMAL", + assigned_to: Optional[str] = None, + due_date: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + 사용자 요구사항 생성 + """ + try: + insert_query = text(""" + INSERT INTO user_requirements ( + file_id, requirement_type, requirement_title, requirement_description, + requirement_spec, priority, assigned_to, due_date + ) + VALUES ( + :file_id, :requirement_type, :requirement_title, :requirement_description, + :requirement_spec, :priority, :assigned_to, :due_date + ) + RETURNING id + """) + + result = db.execute(insert_query, { + "file_id": file_id, + "requirement_type": requirement_type, + "requirement_title": requirement_title, + "requirement_description": requirement_description, + "requirement_spec": requirement_spec, + "priority": priority, + "assigned_to": assigned_to, + "due_date": due_date + }) + + requirement_id = result.fetchone()[0] + db.commit() + return { - 'category': 'BOLT', - 'subcategory': bolt_result.get('bolt_type', 'UNKNOWN'), - 'standard': bolt_result.get('standard', ''), - 'grade': bolt_result.get('grade', ''), - 'confidence': bolt_result.get('confidence', 0), - 'details': bolt_result + "success": True, + "message": "요구사항이 생성되었습니다", + "requirement_id": requirement_id } - - # 2. 플랜지 분류 - flange_result = classify_flange(description) - if flange_result.get('confidence', 0) > 0.7: - return { - 'category': 'FLANGE', - 'subcategory': flange_result.get('flange_type', 'UNKNOWN'), - 'standard': flange_result.get('standard', ''), - 'grade': flange_result.get('grade', ''), - 'confidence': flange_result.get('confidence', 0), - 'details': flange_result - } - - # 3. 피팅 분류 - fitting_result = classify_fitting(description) - if fitting_result.get('confidence', 0) > 0.7: - return { - 'category': 'FITTING', - 'subcategory': fitting_result.get('fitting_type', 'UNKNOWN'), - 'standard': fitting_result.get('standard', ''), - 'grade': fitting_result.get('grade', ''), - 'confidence': fitting_result.get('confidence', 0), - 'details': fitting_result - } - - # 4. 가스켓 분류 - gasket_result = classify_gasket(description) - if gasket_result.get('confidence', 0) > 0.7: - return { - 'category': 'GASKET', - 'subcategory': gasket_result.get('gasket_type', 'UNKNOWN'), - 'standard': gasket_result.get('standard', ''), - 'grade': gasket_result.get('grade', ''), - 'confidence': gasket_result.get('confidence', 0), - 'details': gasket_result - } - - # 5. 계기 분류 - instrument_result = classify_instrument(description) - if instrument_result.get('confidence', 0) > 0.7: - return { - 'category': 'INSTRUMENT', - 'subcategory': instrument_result.get('instrument_type', 'UNKNOWN'), - 'standard': instrument_result.get('standard', ''), - 'grade': instrument_result.get('grade', ''), - 'confidence': instrument_result.get('confidence', 0), - 'details': instrument_result - } - - # 6. 파이프 분류 - pipe_result = classify_pipe(description) - if pipe_result.get('confidence', 0) > 0.7: - return { - 'category': 'PIPE', - 'subcategory': pipe_result.get('pipe_type', 'UNKNOWN'), - 'standard': pipe_result.get('standard', ''), - 'grade': pipe_result.get('grade', ''), - 'confidence': pipe_result.get('confidence', 0), - 'details': pipe_result - } - - # 7. 밸브 분류 - valve_result = classify_valve(description) - if valve_result.get('confidence', 0) > 0.7: - return { - 'category': 'VALVE', - 'subcategory': valve_result.get('valve_type', 'UNKNOWN'), - 'standard': valve_result.get('standard', ''), - 'grade': valve_result.get('grade', ''), - 'confidence': valve_result.get('confidence', 0), - 'details': valve_result - } - - # 8. 재질 분류 (기본) - material_result = classify_material(description) - if material_result.get('confidence', 0) > 0.5: - return { - 'category': 'MATERIAL', - 'subcategory': material_result.get('material_type', 'UNKNOWN'), - 'standard': material_result.get('standard', ''), - 'grade': material_result.get('grade', ''), - 'confidence': material_result.get('confidence', 0), - 'details': material_result - } - - # 9. 기본 분류 (키워드 기반) - category = get_major_category(description) - return { - 'category': category.upper(), - 'subcategory': 'UNKNOWN', - 'standard': '', - 'grade': '', - 'confidence': 0.3, - 'details': {} - } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"요구사항 생성 실패: {str(e)}") \ No newline at end of file diff --git a/backend/app/services/bolt_classifier.py b/backend/app/services/bolt_classifier.py index 6128d49..329e9a0 100644 --- a/backend/app/services/bolt_classifier.py +++ b/backend/app/services/bolt_classifier.py @@ -183,7 +183,7 @@ BOLT_GRADES = { } } -def classify_bolt(dat_file: str, description: str, main_nom: str) -> Dict: +def classify_bolt(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict: """ 완전한 BOLT 분류 diff --git a/backend/app/services/fitting_classifier.py b/backend/app/services/fitting_classifier.py index addb852..044252f 100644 --- a/backend/app/services/fitting_classifier.py +++ b/backend/app/services/fitting_classifier.py @@ -86,11 +86,11 @@ FITTING_TYPES = { }, "OLET": { - "dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_"], - "description_keywords": ["OLET", "올렛", "O-LET"], + "dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_", "SOCK-O-LET", "WELD-O-LET"], + "description_keywords": ["OLET", "올렛", "O-LET", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET"], "subtypes": { - "SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL"], - "WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL"], + "SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET"], + "WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET"], "THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL"], "ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL"] }, @@ -171,7 +171,7 @@ PRESSURE_RATINGS = { } def classify_fitting(dat_file: str, description: str, main_nom: str, - red_nom: str = None) -> Dict: + red_nom: str = None, length: float = None) -> Dict: """ 완전한 FITTING 분류 @@ -185,7 +185,21 @@ def classify_fitting(dat_file: str, description: str, main_nom: str, 완전한 피팅 분류 결과 """ - # 1. 재질 분류 (공통 모듈 사용) + desc_upper = description.upper() + dat_upper = dat_file.upper() + + # 1. 명칭 우선 확인 (피팅 키워드가 있으면 피팅) + fitting_keywords = ['ELBOW', 'TEE', 'REDUCER', 'CAP', 'NIPPLE', 'SWAGE', 'OLET', 'COUPLING', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', 'SOCK-O-LET', 'WELD-O-LET', 'SOCKOLET', 'WELDOLET'] + is_fitting = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords) + + if not is_fitting: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "피팅 키워드 없음" + } + + # 2. 재질 분류 (공통 모듈 사용) material_result = classify_material(description) # 2. 피팅 타입 분류 @@ -328,7 +342,7 @@ def classify_fitting_subtype(fitting_type: str, description: str, # 2. 사이즈 분석이 필요한 경우 (TEE, REDUCER 등) if type_data.get("size_analysis"): - if red_nom and red_nom.strip() and red_nom != main_nom: + if red_nom and str(red_nom).strip() and red_nom != main_nom: return { "subtype": "REDUCING", "confidence": 0.85, @@ -343,7 +357,7 @@ def classify_fitting_subtype(fitting_type: str, description: str, # 3. 두 사이즈가 필요한 경우 확인 if type_data.get("requires_two_sizes"): - if red_nom and red_nom.strip(): + if red_nom and str(red_nom).strip(): confidence = 0.8 evidence = [f"TWO_SIZES_PROVIDED: {main_nom} x {red_nom}"] else: @@ -511,11 +525,12 @@ def determine_fitting_manufacturing(material_result: Dict, connection_result: Di def format_fitting_size(main_nom: str, red_nom: str = None) -> str: """피팅 사이즈 표기 포맷팅""" - - if red_nom and red_nom.strip() and red_nom != main_nom: - return f"{main_nom} x {red_nom}" + main_nom_str = str(main_nom) if main_nom is not None else "" + red_nom_str = str(red_nom) if red_nom is not None else "" + if red_nom_str.strip() and red_nom_str != main_nom_str: + return f"{main_nom_str} x {red_nom_str}" else: - return main_nom + return main_nom_str def calculate_fitting_confidence(confidence_scores: Dict) -> float: """피팅 분류 전체 신뢰도 계산""" diff --git a/backend/app/services/flange_classifier.py b/backend/app/services/flange_classifier.py index d139c4f..c83b588 100644 --- a/backend/app/services/flange_classifier.py +++ b/backend/app/services/flange_classifier.py @@ -10,8 +10,8 @@ from .material_classifier import classify_material, get_manufacturing_method_fro # ========== SPECIAL FLANGE 타입 ========== SPECIAL_FLANGE_TYPES = { "ORIFICE": { - "dat_file_patterns": ["FLG_ORI_", "ORI_"], - "description_keywords": ["ORIFICE", "오리피스", "유량측정"], + "dat_file_patterns": ["FLG_ORI_", "ORI_", "ORIFICE_"], + "description_keywords": ["ORIFICE", "오리피스", "유량측정", "구멍"], "characteristics": "유량 측정용 구멍", "special_features": ["BORE_SIZE", "FLOW_MEASUREMENT"] }, @@ -164,7 +164,7 @@ FLANGE_PRESSURE_RATINGS = { } def classify_flange(dat_file: str, description: str, main_nom: str, - red_nom: str = None) -> Dict: + red_nom: str = None, length: float = None) -> Dict: """ 완전한 FLANGE 분류 @@ -178,7 +178,21 @@ def classify_flange(dat_file: str, description: str, main_nom: str, 완전한 플랜지 분류 결과 """ - # 1. 재질 분류 (공통 모듈 사용) + desc_upper = description.upper() + dat_upper = dat_file.upper() + + # 1. 명칭 우선 확인 (플랜지 키워드가 있으면 플랜지) + flange_keywords = ['FLG', 'FLANGE', '플랜지'] + is_flange = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords) + + if not is_flange: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "플랜지 키워드 없음" + } + + # 2. 재질 분류 (공통 모듈 사용) material_result = classify_material(description) # 2. SPECIAL vs STANDARD 분류 @@ -490,11 +504,12 @@ def determine_flange_manufacturing(material_result: Dict, flange_type_result: Di def format_flange_size(main_nom: str, red_nom: str = None) -> str: """플랜지 사이즈 표기 포맷팅""" - - if red_nom and red_nom.strip() and red_nom != main_nom: - return f"{main_nom} x {red_nom}" + main_nom_str = str(main_nom) if main_nom is not None else "" + red_nom_str = str(red_nom) if red_nom is not None else "" + if red_nom_str.strip() and red_nom_str != main_nom_str: + return f"{main_nom_str} x {red_nom_str}" else: - return main_nom + return main_nom_str def calculate_flange_confidence(confidence_scores: Dict) -> float: """플랜지 분류 전체 신뢰도 계산""" diff --git a/backend/app/services/gasket_classifier.py b/backend/app/services/gasket_classifier.py index ca9f0d8..fba2df0 100644 --- a/backend/app/services/gasket_classifier.py +++ b/backend/app/services/gasket_classifier.py @@ -160,7 +160,7 @@ GASKET_SIZE_PATTERNS = { "thickness": r"THK?\s*(\d+(?:\.\d+)?)\s*MM" } -def classify_gasket(dat_file: str, description: str, main_nom: str) -> Dict: +def classify_gasket(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict: """ 완전한 GASKET 분류 diff --git a/backend/app/services/instrument_classifier.py b/backend/app/services/instrument_classifier.py index d95394d..9dab93b 100644 --- a/backend/app/services/instrument_classifier.py +++ b/backend/app/services/instrument_classifier.py @@ -46,7 +46,7 @@ INSTRUMENT_TYPES = { } } -def classify_instrument(dat_file: str, description: str, main_nom: str) -> Dict: +def classify_instrument(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict: """ 간단한 INSTRUMENT 분류 diff --git a/backend/app/services/material_classifier.py b/backend/app/services/material_classifier.py index d07dc1f..daaf4ab 100644 --- a/backend/app/services/material_classifier.py +++ b/backend/app/services/material_classifier.py @@ -23,7 +23,7 @@ def classify_material(description: str) -> Dict: 재질 분류 결과 딕셔너리 """ - desc_upper = description.upper().strip() + desc_upper = str(description).upper().strip() if description is not None else "" # 1단계: 특수 재질 우선 확인 (가장 구체적) special_result = check_special_materials(desc_upper) diff --git a/backend/app/services/pipe_classifier.py b/backend/app/services/pipe_classifier.py index 2a96639..5b5bbc2 100644 --- a/backend/app/services/pipe_classifier.py +++ b/backend/app/services/pipe_classifier.py @@ -63,7 +63,7 @@ PIPE_SCHEDULE = { } def classify_pipe(dat_file: str, description: str, main_nom: str, - length: float = None) -> Dict: + length: Optional[float] = None) -> Dict: """ 완전한 PIPE 분류 @@ -77,7 +77,38 @@ def classify_pipe(dat_file: str, description: str, main_nom: str, 완전한 파이프 분류 결과 """ - # 1. 재질 분류 (공통 모듈 사용) + desc_upper = description.upper() + + # 1. 명칭 우선 확인 (다른 자재 타입 키워드가 있으면 파이프가 아님) + other_material_keywords = [ + 'FLG', 'FLANGE', '플랜지', # 플랜지 + 'ELBOW', 'ELL', 'TEE', 'REDUCER', 'CAP', 'COUPLING', '엘보', '티', '리듀서', # 피팅 + 'VALVE', 'BALL', 'GATE', 'GLOBE', 'CHECK', '밸브', # 밸브 + 'BOLT', 'STUD', 'NUT', 'SCREW', '볼트', '너트', # 볼트 + 'GASKET', 'GASK', '가스켓', # 가스켓 + 'GAUGE', 'SENSOR', 'TRANSMITTER', 'INSTRUMENT', '계기' # 계기 + ] + + for keyword in other_material_keywords: + if keyword in desc_upper: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": f"다른 자재 키워드 발견: {keyword}" + } + + # 2. 파이프 키워드 확인 + pipe_keywords = ['PIPE', 'TUBE', '파이프', '배관'] + is_pipe = any(keyword in desc_upper for keyword in pipe_keywords) + + if not is_pipe: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "파이프 키워드 없음" + } + + # 3. 재질 분류 (공통 모듈 사용) material_result = classify_material(description) # 2. 제조 방법 분류 @@ -89,8 +120,8 @@ def classify_pipe(dat_file: str, description: str, main_nom: str, # 4. 스케줄 분류 schedule_result = classify_pipe_schedule(description) - # 5. 절단 치수 처리 - cutting_dimensions = extract_pipe_cutting_dimensions(length, description) + # 5. 길이(절단 치수) 처리 + length_info = extract_pipe_length_info(length, description) # 6. 최종 결과 조합 return { @@ -124,11 +155,11 @@ def classify_pipe(dat_file: str, description: str, main_nom: str, "confidence": schedule_result.get('confidence', 0.0) }, - "cutting_dimensions": cutting_dimensions, + "length_info": length_info, "size_info": { "nominal_size": main_nom, - "length_mm": cutting_dimensions.get('length_mm') + "length_mm": length_info.get('length_mm') }, # 전체 신뢰도 @@ -234,10 +265,10 @@ def classify_pipe_schedule(description: str) -> Dict: "confidence": 0.0 } -def extract_pipe_cutting_dimensions(length: float, description: str) -> Dict: - """파이프 절단 치수 정보 추출""" +def extract_pipe_length_info(length: Optional[float], description: str) -> Dict: + """파이프 길이(절단 치수) 정보 추출""" - cutting_info = { + length_info = { "length_mm": None, "source": None, "confidence": 0.0, @@ -246,31 +277,31 @@ def extract_pipe_cutting_dimensions(length: float, description: str) -> Dict: # 1. LENGTH 필드에서 추출 (우선) if length and length > 0: - cutting_info.update({ + length_info.update({ "length_mm": round(length, 1), "source": "LENGTH_FIELD", "confidence": 0.95, - "note": f"도면 명기 치수: {length}mm" + "note": f"도면 명기 길이: {length}mm" }) # 2. DESCRIPTION에서 백업 추출 else: desc_length = extract_length_from_description(description) if desc_length: - cutting_info.update({ + length_info.update({ "length_mm": desc_length, "source": "DESCRIPTION_PARSED", "confidence": 0.8, "note": f"설명란에서 추출: {desc_length}mm" }) else: - cutting_info.update({ + length_info.update({ "source": "NO_LENGTH_INFO", "confidence": 0.0, - "note": "절단 치수 정보 없음 - 도면 확인 필요" + "note": "길이 정보 없음 - 도면 확인 필요" }) - return cutting_info + return length_info def extract_length_from_description(description: str) -> Optional[float]: """DESCRIPTION에서 길이 정보 추출""" @@ -318,7 +349,7 @@ def generate_pipe_cutting_plan(pipe_data: Dict) -> Dict: cutting_plan = { "material_spec": f"{pipe_data['material']['grade']} {pipe_data['schedule']['schedule']}", - "length_mm": pipe_data['cutting_dimensions']['length_mm'], + "length_mm": pipe_data['length_info']['length_mm'], "end_preparation": pipe_data['end_preparation']['cutting_note'], "machining_required": pipe_data['end_preparation']['machining_required'] } @@ -332,6 +363,6 @@ def generate_pipe_cutting_plan(pipe_data: Dict) -> Dict: 가공여부: {'베벨가공 필요' if cutting_plan['machining_required'] else '직각절단만'} """.strip() else: - cutting_plan["cutting_instruction"] = "도면 확인 후 절단 치수 입력 필요" + cutting_plan["cutting_instruction"] = "도면 확인 후 길이 정보 입력 필요" return cutting_plan diff --git a/backend/app/services/valve_classifier.py b/backend/app/services/valve_classifier.py index e332e96..d09e9d2 100644 --- a/backend/app/services/valve_classifier.py +++ b/backend/app/services/valve_classifier.py @@ -66,7 +66,7 @@ VALVE_TYPES = { "RELIEF_VALVE": { "dat_file_patterns": ["RELIEF_", "RV_", "PSV_"], - "description_keywords": ["RELIEF VALVE", "PRESSURE RELIEF", "SAFETY VALVE", "릴리프"], + "description_keywords": ["RELIEF VALVE", "PRESSURE RELIEF", "SAFETY VALVE", "PSV", "압력 안전", "릴리프"], "characteristics": "안전 압력 방출용", "typical_connections": ["FLANGED", "THREADED"], "pressure_range": "150LB ~ 2500LB", @@ -196,20 +196,34 @@ VALVE_PRESSURE_RATINGS = { } } -def classify_valve(dat_file: str, description: str, main_nom: str) -> Dict: +def classify_valve(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict: """ 완전한 VALVE 분류 Args: dat_file: DAT_FILE 필드 description: DESCRIPTION 필드 - main_nom: MAIN_NOM 필드 (밸브 사이즈) + main_nom: MAIN_NOM 필드 (사이즈) Returns: 완전한 밸브 분류 결과 """ - # 1. 재질 분류 (공통 모듈 사용) + desc_upper = description.upper() + dat_upper = dat_file.upper() + + # 1. 명칭 우선 확인 (밸브 키워드가 있으면 밸브) + valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'PLUG', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '플러그'] + is_valve = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords) + + if not is_valve: + return { + "category": "UNKNOWN", + "overall_confidence": 0.0, + "reason": "밸브 키워드 없음" + } + + # 2. 재질 분류 (공통 모듈 사용) material_result = classify_material(description) # 2. 밸브 타입 분류 diff --git a/backend/scripts/05_add_length_to_materials.sql b/backend/scripts/05_add_length_to_materials.sql new file mode 100644 index 0000000..345529d --- /dev/null +++ b/backend/scripts/05_add_length_to_materials.sql @@ -0,0 +1,5 @@ +-- materials 테이블에 length 컬럼 추가 +ALTER TABLE materials ADD COLUMN length NUMERIC(10, 3); + +-- 기존 데이터의 length 컬럼을 NULL로 초기화 +UPDATE materials SET length = NULL; \ No newline at end of file diff --git a/backend/scripts/05_create_material_standards_tables.sql b/backend/scripts/05_create_material_standards_tables.sql new file mode 100644 index 0000000..dbed040 --- /dev/null +++ b/backend/scripts/05_create_material_standards_tables.sql @@ -0,0 +1,137 @@ +-- 자재 규격/재질 기준표 테이블 생성 스크립트 +-- 실행 순서: 1) material_standards, 2) material_categories, 3) material_specifications, 4) material_grades, 5) material_patterns +-- 특수 재질: 6) special_materials, 7) special_material_grades, 8) special_material_patterns + +-- 1. 자재 규격 표준 테이블 +CREATE TABLE IF NOT EXISTS material_standards ( + id SERIAL PRIMARY KEY, + standard_code VARCHAR(20) UNIQUE NOT NULL, + standard_name VARCHAR(100) NOT NULL, + description TEXT, + country VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. 제조방식별 카테고리 테이블 +CREATE TABLE IF NOT EXISTS material_categories ( + id SERIAL PRIMARY KEY, + standard_id INTEGER REFERENCES material_standards(id), + category_code VARCHAR(50) NOT NULL, + category_name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. 구체적인 규격 테이블 +CREATE TABLE IF NOT EXISTS material_specifications ( + id SERIAL PRIMARY KEY, + category_id INTEGER REFERENCES material_categories(id), + spec_code VARCHAR(20) NOT NULL, + spec_name VARCHAR(100) NOT NULL, + description TEXT, + material_type VARCHAR(50), + manufacturing VARCHAR(50), + pressure_rating VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 4. 등급별 상세 정보 테이블 +CREATE TABLE IF NOT EXISTS material_grades ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES material_specifications(id), + grade_code VARCHAR(20) NOT NULL, + grade_name VARCHAR(100), + composition VARCHAR(200), + applications VARCHAR(200), + temp_max VARCHAR(50), + temp_range VARCHAR(100), + yield_strength VARCHAR(50), + tensile_strength VARCHAR(50), + corrosion_resistance VARCHAR(50), + stabilizer VARCHAR(50), + base_grade VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 5. 정규식 패턴 테이블 +CREATE TABLE IF NOT EXISTS material_patterns ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES material_specifications(id), + pattern TEXT NOT NULL, + description VARCHAR(200), + priority INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 6. 특수 재질 테이블 +CREATE TABLE IF NOT EXISTS special_materials ( + id SERIAL PRIMARY KEY, + material_type VARCHAR(50) NOT NULL, + material_name VARCHAR(100) NOT NULL, + description TEXT, + composition VARCHAR(200), + applications TEXT, + temp_max VARCHAR(50), + manufacturing VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 7. 특수 재질 등급 테이블 +CREATE TABLE IF NOT EXISTS special_material_grades ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES special_materials(id), + grade_code VARCHAR(20) NOT NULL, + composition VARCHAR(200), + applications VARCHAR(200), + temp_max VARCHAR(50), + strength VARCHAR(50), + purity VARCHAR(100), + corrosion VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 8. 특수 재질 정규식 패턴 테이블 +CREATE TABLE IF NOT EXISTS special_material_patterns ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES special_materials(id), + pattern TEXT NOT NULL, + description VARCHAR(200), + priority INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_material_standards_code ON material_standards(standard_code); +CREATE INDEX IF NOT EXISTS idx_material_categories_standard ON material_categories(standard_id); +CREATE INDEX IF NOT EXISTS idx_material_specifications_category ON material_specifications(category_id); +CREATE INDEX IF NOT EXISTS idx_material_grades_specification ON material_grades(specification_id); +CREATE INDEX IF NOT EXISTS idx_material_patterns_specification ON material_patterns(specification_id); +CREATE INDEX IF NOT EXISTS idx_special_materials_type ON special_materials(material_type); +CREATE INDEX IF NOT EXISTS idx_special_material_grades_material ON special_material_grades(material_id); +CREATE INDEX IF NOT EXISTS idx_special_material_patterns_material ON special_material_patterns(material_id); + +-- 활성 상태 인덱스 +CREATE INDEX IF NOT EXISTS idx_material_standards_active ON material_standards(is_active); +CREATE INDEX IF NOT EXISTS idx_material_categories_active ON material_categories(is_active); +CREATE INDEX IF NOT EXISTS idx_material_specifications_active ON material_specifications(is_active); +CREATE INDEX IF NOT EXISTS idx_material_grades_active ON material_grades(is_active); +CREATE INDEX IF NOT EXISTS idx_material_patterns_active ON material_patterns(is_active); +CREATE INDEX IF NOT EXISTS idx_special_materials_active ON special_materials(is_active); +CREATE INDEX IF NOT EXISTS idx_special_material_grades_active ON special_material_grades(is_active); +CREATE INDEX IF NOT EXISTS idx_special_material_patterns_active ON special_material_patterns(is_active); \ No newline at end of file diff --git a/backend/scripts/05_create_pipe_details_and_requirements.sql b/backend/scripts/05_create_pipe_details_and_requirements.sql new file mode 100644 index 0000000..152338b --- /dev/null +++ b/backend/scripts/05_create_pipe_details_and_requirements.sql @@ -0,0 +1,109 @@ +-- 파이프 상세 정보 및 사용자 요구사항 테이블 생성 +-- 2024-01-XX + +-- 파이프 상세 정보 테이블 +CREATE TABLE IF NOT EXISTS pipe_details ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL, + + -- 재질 정보 + material_standard TEXT, -- ASTM, KS, JIS 등 + material_grade TEXT, -- A106, A53, STPG370 등 + material_type TEXT, -- CARBON, STAINLESS 등 + + -- 파이프 특화 정보 + manufacturing_method TEXT, -- SEAMLESS, WELDED, CAST + end_preparation TEXT, -- BOTH_ENDS_BEVELED, ONE_END_BEVELED, NO_BEVEL + schedule TEXT, -- SCH 10, 20, 40, 80 등 + wall_thickness TEXT, -- 벽두께 정보 + + -- 치수 정보 + nominal_size TEXT, -- MAIN_NOM (인치, 직경) + length_mm REAL, -- LENGTH (길이) + + -- 신뢰도 + material_confidence REAL, + manufacturing_confidence REAL, + end_prep_confidence REAL, + schedule_confidence REAL, + + -- 메타데이터 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE +); + +-- 요구사항 타입 마스터 테이블 +CREATE TABLE IF NOT EXISTS requirement_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type_code TEXT UNIQUE NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT' 등 + type_name TEXT NOT NULL, -- '임팩테스트', '열처리' 등 + category TEXT NOT NULL, -- 'TEST', 'TREATMENT', 'CERTIFICATION', 'CUSTOM' 등 + description TEXT, -- 타입 설명 + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 사용자 추가 요구사항 테이블 +CREATE TABLE IF NOT EXISTS user_requirements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL, + + -- 요구사항 타입 + requirement_type TEXT NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 + + -- 요구사항 내용 + requirement_title TEXT NOT NULL, -- '임팩테스트', '열처리', '인증서' 등 + requirement_description TEXT, -- 상세 설명 + requirement_spec TEXT, -- 구체적 스펙 (예: "Charpy V-notch -20°C") + + -- 상태 관리 + status TEXT DEFAULT 'PENDING', -- 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED' + priority TEXT DEFAULT 'NORMAL', -- 'LOW', 'NORMAL', 'HIGH', 'URGENT' + + -- 담당자 정보 + assigned_to TEXT, -- 담당자명 + due_date DATE, -- 완료 예정일 + + -- 메타데이터 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_pipe_details_file_id ON pipe_details(file_id); +CREATE INDEX IF NOT EXISTS idx_user_requirements_file_id ON user_requirements(file_id); +CREATE INDEX IF NOT EXISTS idx_user_requirements_status ON user_requirements(status); +CREATE INDEX IF NOT EXISTS idx_user_requirements_type ON user_requirements(requirement_type); + +-- 기본 요구사항 타입 데이터 삽입 +INSERT OR IGNORE INTO requirement_types (type_code, type_name, category, description) VALUES +('IMPACT_TEST', '임팩테스트', 'TEST', 'Charpy V-notch, Izod 등의 충격 시험'), +('HEAT_TREATMENT', '열처리', 'TREATMENT', '정규화, 어닐링, 템퍼링 등의 열처리'), +('CERTIFICATION', '인증서', 'CERTIFICATION', '재질증명서, 시험성적서 등'), +('CUSTOM_SPEC', '특수사양', 'CUSTOM', '고객 특별 요구사항'), +('NDT_TEST', '비파괴검사', 'TEST', '초음파, 방사선, 자분탐상 등'), +('CHEMICAL_ANALYSIS', '화학분석', 'TEST', '화학성분 분석'), +('MECHANICAL_TEST', '기계적성질', 'TEST', '인장, 압축, 굽힘 시험'), +('SURFACE_TREATMENT', '표면처리', 'TREATMENT', '도금, 도장, 패시베이션 등'), +('DIMENSIONAL_CHECK', '치수검사', 'INSPECTION', '치수, 형상 검사'), +('WELDING_SPEC', '용접사양', 'SPEC', '용접 방법, 용접재 등'); + +-- 트리거 생성 (updated_at 자동 업데이트) +CREATE TRIGGER IF NOT EXISTS update_pipe_details_timestamp + AFTER UPDATE ON pipe_details + FOR EACH ROW +BEGIN + UPDATE pipe_details SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS update_user_requirements_timestamp + AFTER UPDATE ON user_requirements + FOR EACH ROW +BEGIN + UPDATE user_requirements SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; \ No newline at end of file diff --git a/backend/scripts/05_create_pipe_details_and_requirements_postgres.sql b/backend/scripts/05_create_pipe_details_and_requirements_postgres.sql new file mode 100644 index 0000000..7a4c7eb --- /dev/null +++ b/backend/scripts/05_create_pipe_details_and_requirements_postgres.sql @@ -0,0 +1,115 @@ +-- 파이프 상세 정보 및 사용자 요구사항 테이블 생성 (PostgreSQL) +-- 2024-01-XX + +-- 파이프 상세 정보 테이블 +CREATE TABLE IF NOT EXISTS pipe_details ( + id SERIAL PRIMARY KEY, + file_id INTEGER NOT NULL, + + -- 재질 정보 + material_standard VARCHAR(50), -- ASTM, KS, JIS 등 + material_grade VARCHAR(50), -- A106, A53, STPG370 등 + material_type VARCHAR(50), -- CARBON, STAINLESS 등 + + -- 파이프 특화 정보 + manufacturing_method VARCHAR(50), -- SEAMLESS, WELDED, CAST + end_preparation VARCHAR(50), -- BOTH_ENDS_BEVELED, ONE_END_BEVELED, NO_BEVEL + schedule VARCHAR(50), -- SCH 10, 20, 40, 80 등 + wall_thickness VARCHAR(50), -- 벽두께 정보 + + -- 치수 정보 + nominal_size VARCHAR(50), -- MAIN_NOM (인치, 직경) + length_mm DECIMAL(10, 3), -- LENGTH (길이) + + -- 신뢰도 + material_confidence DECIMAL(3, 2), + manufacturing_confidence DECIMAL(3, 2), + end_prep_confidence DECIMAL(3, 2), + schedule_confidence DECIMAL(3, 2), + + -- 메타데이터 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE +); + +-- 요구사항 타입 마스터 테이블 +CREATE TABLE IF NOT EXISTS requirement_types ( + id SERIAL PRIMARY KEY, + type_code VARCHAR(50) UNIQUE NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT' 등 + type_name VARCHAR(100) NOT NULL, -- '임팩테스트', '열처리' 등 + category VARCHAR(50) NOT NULL, -- 'TEST', 'TREATMENT', 'CERTIFICATION', 'CUSTOM' 등 + description TEXT, -- 타입 설명 + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 사용자 추가 요구사항 테이블 +CREATE TABLE IF NOT EXISTS user_requirements ( + id SERIAL PRIMARY KEY, + file_id INTEGER NOT NULL, + + -- 요구사항 타입 + requirement_type VARCHAR(50) NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 + + -- 요구사항 내용 + requirement_title VARCHAR(200) NOT NULL, -- '임팩테스트', '열처리', '인증서' 등 + requirement_description TEXT, -- 상세 설명 + requirement_spec TEXT, -- 구체적 스펙 (예: "Charpy V-notch -20°C") + + -- 상태 관리 + status VARCHAR(20) DEFAULT 'PENDING', -- 'PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED' + priority VARCHAR(20) DEFAULT 'NORMAL', -- 'LOW', 'NORMAL', 'HIGH', 'URGENT' + + -- 담당자 정보 + assigned_to VARCHAR(100), -- 담당자명 + due_date DATE, -- 완료 예정일 + + -- 메타데이터 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_pipe_details_file_id ON pipe_details(file_id); +CREATE INDEX IF NOT EXISTS idx_user_requirements_file_id ON user_requirements(file_id); +CREATE INDEX IF NOT EXISTS idx_user_requirements_status ON user_requirements(status); +CREATE INDEX IF NOT EXISTS idx_user_requirements_type ON user_requirements(requirement_type); + +-- 기본 요구사항 타입 데이터 삽입 +INSERT INTO requirement_types (type_code, type_name, category, description) VALUES +('IMPACT_TEST', '임팩테스트', 'TEST', 'Charpy V-notch, Izod 등의 충격 시험'), +('HEAT_TREATMENT', '열처리', 'TREATMENT', '정규화, 어닐링, 템퍼링 등의 열처리'), +('CERTIFICATION', '인증서', 'CERTIFICATION', '재질증명서, 시험성적서 등'), +('CUSTOM_SPEC', '특수사양', 'CUSTOM', '고객 특별 요구사항'), +('NDT_TEST', '비파괴검사', 'TEST', '초음파, 방사선, 자분탐상 등'), +('CHEMICAL_ANALYSIS', '화학분석', 'TEST', '화학성분 분석'), +('MECHANICAL_TEST', '기계적성질', 'TEST', '인장, 압축, 굽힘 시험'), +('SURFACE_TREATMENT', '표면처리', 'TREATMENT', '도금, 도장, 패시베이션 등'), +('DIMENSIONAL_CHECK', '치수검사', 'INSPECTION', '치수, 형상 검사'), +('WELDING_SPEC', '용접사양', 'SPEC', '용접 방법, 용접재 등') +ON CONFLICT (type_code) DO NOTHING; + +-- 트리거 함수 생성 (updated_at 자동 업데이트) +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 트리거 생성 +CREATE TRIGGER update_pipe_details_timestamp + BEFORE UPDATE ON pipe_details + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_requirements_timestamp + BEFORE UPDATE ON user_requirements + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/backend/scripts/06_insert_material_standards_data.py b/backend/scripts/06_insert_material_standards_data.py new file mode 100644 index 0000000..40c1e55 --- /dev/null +++ b/backend/scripts/06_insert_material_standards_data.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +""" +자재 규격/재질 기준표 데이터를 DB에 삽입하는 스크립트 +기존 materials_schema.py의 딕셔너리 데이터를 DB 테이블로 변환 +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from app.database import DATABASE_URL +from app.models import ( + MaterialStandard, MaterialCategory, MaterialSpecification, + MaterialGrade, MaterialPattern, SpecialMaterial, + SpecialMaterialGrade, SpecialMaterialPattern +) + +# 기존 materials_schema.py의 데이터 (일부만 예시로 포함) +MATERIAL_STANDARDS_DATA = { + "ASTM_ASME": { + "name": "미국재질학회", + "country": "USA", + "categories": { + "FORGED_GRADES": { + "name": "단조품", + "specifications": { + "A182": { + "name": "탄소강 단조품", + "material_type": "carbon_alloy", + "manufacturing": "FORGED", + "subtypes": { + "carbon_alloy": { + "manufacturing": "FORGED", + "grades": { + "F1": { + "composition": "0.5Mo", + "temp_max": "482°C", + "applications": "중온용" + }, + "F5": { + "composition": "5Cr-0.5Mo", + "temp_max": "649°C", + "applications": "고온용" + }, + "F11": { + "composition": "1.25Cr-0.5Mo", + "temp_max": "593°C", + "applications": "일반 고온용" + }, + "F22": { + "composition": "2.25Cr-1Mo", + "temp_max": "649°C", + "applications": "고온 고압용" + }, + "F91": { + "composition": "9Cr-1Mo-V", + "temp_max": "649°C", + "applications": "초고온용" + } + }, + "patterns": [ + r"ASTM\s+A182\s+(?:GR\s*)?F(\d+)", + r"A182\s+(?:GR\s*)?F(\d+)", + r"ASME\s+SA182\s+(?:GR\s*)?F(\d+)" + ] + }, + "stainless": { + "manufacturing": "FORGED", + "grades": { + "F304": { + "composition": "18Cr-8Ni", + "applications": "일반용", + "corrosion_resistance": "보통" + }, + "F304L": { + "composition": "18Cr-8Ni-저탄소", + "applications": "용접용", + "corrosion_resistance": "보통" + }, + "F316": { + "composition": "18Cr-10Ni-2Mo", + "applications": "내식성", + "corrosion_resistance": "우수" + }, + "F316L": { + "composition": "18Cr-10Ni-2Mo-저탄소", + "applications": "용접+내식성", + "corrosion_resistance": "우수" + }, + "F321": { + "composition": "18Cr-8Ni-Ti", + "applications": "고온안정화", + "stabilizer": "Titanium" + }, + "F347": { + "composition": "18Cr-8Ni-Nb", + "applications": "고온안정화", + "stabilizer": "Niobium" + } + }, + "patterns": [ + r"ASTM\s+A182\s+F(\d{3}[LH]*)", + r"A182\s+F(\d{3}[LH]*)", + r"ASME\s+SA182\s+F(\d{3}[LH]*)" + ] + } + } + }, + "A105": { + "name": "탄소강 단조품", + "description": "탄소강 단조품", + "composition": "탄소강", + "applications": "일반 압력용 단조품", + "manufacturing": "FORGED", + "pressure_rating": "150LB ~ 9000LB", + "patterns": [ + r"ASTM\s+A105(?:\s+(?:GR\s*)?([ABC]))?", + r"A105(?:\s+(?:GR\s*)?([ABC]))?", + r"ASME\s+SA105" + ] + } + } + }, + "WELDED_GRADES": { + "name": "용접품", + "specifications": { + "A234": { + "name": "탄소강 용접 피팅", + "material_type": "carbon", + "manufacturing": "WELDED_FABRICATED", + "subtypes": { + "carbon": { + "manufacturing": "WELDED_FABRICATED", + "grades": { + "WPA": { + "yield_strength": "30 ksi", + "applications": "저압용", + "temp_range": "-29°C ~ 400°C" + }, + "WPB": { + "yield_strength": "35 ksi", + "applications": "일반용", + "temp_range": "-29°C ~ 400°C" + }, + "WPC": { + "yield_strength": "40 ksi", + "applications": "고압용", + "temp_range": "-29°C ~ 400°C" + } + }, + "patterns": [ + r"ASTM\s+A234\s+(?:GR\s*)?WP([ABC])", + r"A234\s+(?:GR\s*)?WP([ABC])", + r"ASME\s+SA234\s+(?:GR\s*)?WP([ABC])" + ] + } + } + } + } + } + } + }, + "KS": { + "name": "한국산업표준", + "country": "KOREA", + "categories": { + "PIPE_GRADES": { + "name": "배관용", + "specifications": { + "D3507": { + "name": "배관용 탄소강관", + "description": "배관용 탄소강관", + "manufacturing": "SEAMLESS", + "patterns": [ + r"KS\s+D\s*3507\s+SPPS\s*(\d+)" + ] + }, + "D3583": { + "name": "압력배관용 탄소강관", + "description": "압력배관용 탄소강관", + "manufacturing": "SEAMLESS", + "patterns": [ + r"KS\s+D\s*3583\s+STPG\s*(\d+)" + ] + } + } + } + } + }, + "JIS": { + "name": "일본공업규격", + "country": "JAPAN", + "categories": { + "PIPE_GRADES": { + "name": "배관용", + "specifications": { + "G3452": { + "name": "배관용 탄소강관", + "description": "배관용 탄소강관", + "manufacturing": "WELDED", + "patterns": [ + r"JIS\s+G\s*3452\s+SGP" + ] + } + } + } + } + } +} + +SPECIAL_MATERIALS_DATA = { + "SUPER_ALLOYS": { + "INCONEL": { + "description": "니켈 기반 초합금", + "composition": "Ni-Cr", + "applications": "고온 산화 환경", + "temp_max": "1177°C", + "manufacturing": "FORGED_OR_CAST", + "grades": { + "600": { + "composition": "Ni-Cr", + "temp_max": "1177°C", + "applications": "고온 산화 환경" + }, + "625": { + "composition": "Ni-Cr-Mo", + "temp_max": "982°C", + "applications": "고온 부식 환경" + } + }, + "patterns": [ + r"INCONEL\s*(\d+)" + ] + } + }, + "TITANIUM": { + "TITANIUM": { + "description": "티타늄 및 티타늄 합금", + "composition": "Ti", + "applications": "화학공정, 항공우주", + "temp_max": "1177°C", + "manufacturing": "FORGED_OR_SEAMLESS", + "grades": { + "1": { + "purity": "상업용 순티타늄", + "strength": "낮음", + "applications": "화학공정" + }, + "2": { + "purity": "상업용 순티타늄 (일반)", + "strength": "보통", + "applications": "일반용" + }, + "5": { + "composition": "Ti-6Al-4V", + "strength": "고강도", + "applications": "항공우주" + } + }, + "patterns": [ + r"TITANIUM(?:\s+(?:GR|GRADE)\s*(\d+))?", + r"Ti(?:\s+(?:GR|GRADE)\s*(\d+))?", + r"ASTM\s+B\d+\s+(?:GR|GRADE)\s*(\d+)" + ] + } + } +} + +def insert_material_standards(): + """자재 규격 데이터를 DB에 삽입""" + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + session = Session() + + try: + print("자재 규격 데이터 삽입 시작...") + + # 1. 자재 규격 표준 삽입 + for standard_code, standard_data in MATERIAL_STANDARDS_DATA.items(): + standard = MaterialStandard( + standard_code=standard_code, + standard_name=standard_data["name"], + country=standard_data["country"], + description=f"{standard_data['name']} 규격" + ) + session.add(standard) + session.flush() # ID 생성 + + print(f" - {standard_code} ({standard_data['name']}) 추가됨") + + # 2. 카테고리 삽입 + for category_code, category_data in standard_data["categories"].items(): + category = MaterialCategory( + standard_id=standard.id, + category_code=category_code, + category_name=category_data["name"], + description=f"{category_data['name']} 분류" + ) + session.add(category) + session.flush() + + print(f" - {category_code} ({category_data['name']}) 추가됨") + + # 3. 규격 삽입 + for spec_code, spec_data in category_data["specifications"].items(): + specification = MaterialSpecification( + category_id=category.id, + spec_code=spec_code, + spec_name=spec_data["name"], + description=spec_data.get("description", ""), + material_type=spec_data.get("material_type"), + manufacturing=spec_data.get("manufacturing"), + pressure_rating=spec_data.get("pressure_rating") + ) + session.add(specification) + session.flush() + + print(f" - {spec_code} ({spec_data['name']}) 추가됨") + + # 4. 패턴 삽입 + if "patterns" in spec_data: + for i, pattern in enumerate(spec_data["patterns"]): + pattern_obj = MaterialPattern( + specification_id=specification.id, + pattern=pattern, + description=f"{spec_code} 패턴 {i+1}", + priority=i+1 + ) + session.add(pattern_obj) + + # 5. 등급 삽입 (subtypes가 있는 경우) + if "subtypes" in spec_data: + for subtype_name, subtype_data in spec_data["subtypes"].items(): + # subtypes의 grades 처리 + if "grades" in subtype_data: + for grade_code, grade_data in subtype_data["grades"].items(): + grade = MaterialGrade( + specification_id=specification.id, + grade_code=grade_code, + composition=grade_data.get("composition"), + applications=grade_data.get("applications"), + temp_max=grade_data.get("temp_max"), + temp_range=grade_data.get("temp_range"), + yield_strength=grade_data.get("yield_strength"), + corrosion_resistance=grade_data.get("corrosion_resistance"), + stabilizer=grade_data.get("stabilizer"), + base_grade=grade_data.get("base_grade") + ) + session.add(grade) + print(f" - {grade_code} 등급 추가됨") + + # 5. 등급 삽입 (직접 grades가 있는 경우) + elif "grades" in spec_data: + for grade_code, grade_data in spec_data["grades"].items(): + grade = MaterialGrade( + specification_id=specification.id, + grade_code=grade_code, + composition=grade_data.get("composition"), + applications=grade_data.get("applications"), + temp_max=grade_data.get("temp_max"), + temp_range=grade_data.get("temp_range"), + yield_strength=grade_data.get("yield_strength"), + corrosion_resistance=grade_data.get("corrosion_resistance"), + stabilizer=grade_data.get("stabilizer"), + base_grade=grade_data.get("base_grade") + ) + session.add(grade) + print(f" - {grade_code} 등급 추가됨") + + session.commit() + print("자재 규격 데이터 삽입 완료!") + + except Exception as e: + session.rollback() + print(f"오류 발생: {e}") + raise + finally: + session.close() + +def insert_special_materials(): + """특수 재질 데이터를 DB에 삽입""" + engine = create_engine(DATABASE_URL) + Session = sessionmaker(bind=engine) + session = Session() + + try: + print("특수 재질 데이터 삽입 시작...") + + for material_type, materials in SPECIAL_MATERIALS_DATA.items(): + for material_name, material_data in materials.items(): + # 특수 재질 추가 + special_material = SpecialMaterial( + material_type=material_type, + material_name=material_name, + description=material_data.get("description", ""), + composition=material_data.get("composition"), + applications=material_data.get("applications"), + temp_max=material_data.get("temp_max"), + manufacturing=material_data.get("manufacturing") + ) + session.add(special_material) + session.flush() + + print(f" - {material_name} ({material_type}) 추가됨") + + # 등급 추가 + if "grades" in material_data: + for grade_code, grade_data in material_data["grades"].items(): + grade = SpecialMaterialGrade( + material_id=special_material.id, + grade_code=grade_code, + composition=grade_data.get("composition"), + applications=grade_data.get("applications"), + temp_max=grade_data.get("temp_max"), + strength=grade_data.get("strength"), + purity=grade_data.get("purity"), + corrosion=grade_data.get("corrosion") + ) + session.add(grade) + print(f" - {grade_code} 등급 추가됨") + + # 패턴 추가 + if "patterns" in material_data: + for i, pattern in enumerate(material_data["patterns"]): + pattern_obj = SpecialMaterialPattern( + material_id=special_material.id, + pattern=pattern, + description=f"{material_name} 패턴 {i+1}", + priority=i+1 + ) + session.add(pattern_obj) + + session.commit() + print("특수 재질 데이터 삽입 완료!") + + except Exception as e: + session.rollback() + print(f"오류 발생: {e}") + raise + finally: + session.close() + +def main(): + """메인 실행 함수""" + print("자재 규격/재질 기준표 DB 데이터 삽입 시작") + print("=" * 50) + + # 1. 자재 규격 데이터 삽입 + insert_material_standards() + + print("\n" + "=" * 50) + + # 2. 특수 재질 데이터 삽입 + insert_special_materials() + + print("\n" + "=" * 50) + print("모든 데이터 삽입 완료!") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/scripts/07_execute_material_standards_migration.py b/backend/scripts/07_execute_material_standards_migration.py new file mode 100644 index 0000000..0547c5d --- /dev/null +++ b/backend/scripts/07_execute_material_standards_migration.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +자재 규격/재질 기준표 테이블 생성 및 데이터 삽입 스크립트 +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine, text +from app.database import DATABASE_URL + +def create_tables(): + """테이블 생성""" + engine = create_engine(DATABASE_URL) + + with engine.connect() as conn: + print("자재 규격/재질 기준표 테이블 생성 시작...") + + # 1. 자재 규격 표준 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS material_standards ( + id SERIAL PRIMARY KEY, + standard_code VARCHAR(20) UNIQUE NOT NULL, + standard_name VARCHAR(100) NOT NULL, + description TEXT, + country VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - material_standards 테이블 생성됨") + + # 2. 제조방식별 카테고리 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS material_categories ( + id SERIAL PRIMARY KEY, + standard_id INTEGER REFERENCES material_standards(id), + category_code VARCHAR(50) NOT NULL, + category_name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - material_categories 테이블 생성됨") + + # 3. 구체적인 규격 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS material_specifications ( + id SERIAL PRIMARY KEY, + category_id INTEGER REFERENCES material_categories(id), + spec_code VARCHAR(20) NOT NULL, + spec_name VARCHAR(100) NOT NULL, + description TEXT, + material_type VARCHAR(50), + manufacturing VARCHAR(50), + pressure_rating VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - material_specifications 테이블 생성됨") + + # 4. 등급별 상세 정보 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS material_grades ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES material_specifications(id), + grade_code VARCHAR(20) NOT NULL, + grade_name VARCHAR(100), + composition VARCHAR(200), + applications VARCHAR(200), + temp_max VARCHAR(50), + temp_range VARCHAR(100), + yield_strength VARCHAR(50), + tensile_strength VARCHAR(50), + corrosion_resistance VARCHAR(50), + stabilizer VARCHAR(50), + base_grade VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - material_grades 테이블 생성됨") + + # 5. 정규식 패턴 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS material_patterns ( + id SERIAL PRIMARY KEY, + specification_id INTEGER REFERENCES material_specifications(id), + pattern TEXT NOT NULL, + description VARCHAR(200), + priority INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - material_patterns 테이블 생성됨") + + # 6. 특수 재질 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS special_materials ( + id SERIAL PRIMARY KEY, + material_type VARCHAR(50) NOT NULL, + material_name VARCHAR(100) NOT NULL, + description TEXT, + composition VARCHAR(200), + applications TEXT, + temp_max VARCHAR(50), + manufacturing VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - special_materials 테이블 생성됨") + + # 7. 특수 재질 등급 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS special_material_grades ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES special_materials(id), + grade_code VARCHAR(20) NOT NULL, + composition VARCHAR(200), + applications VARCHAR(200), + temp_max VARCHAR(50), + strength VARCHAR(50), + purity VARCHAR(100), + corrosion VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - special_material_grades 테이블 생성됨") + + # 8. 특수 재질 정규식 패턴 테이블 + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS special_material_patterns ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES special_materials(id), + pattern TEXT NOT NULL, + description VARCHAR(200), + priority INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """)) + print(" - special_material_patterns 테이블 생성됨") + + # 인덱스 생성 + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_standards_code ON material_standards(standard_code);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_categories_standard ON material_categories(standard_id);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_specifications_category ON material_specifications(category_id);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_grades_specification ON material_grades(specification_id);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_patterns_specification ON material_patterns(specification_id);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_special_materials_type ON special_materials(material_type);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_special_material_grades_material ON special_material_grades(material_id);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_special_material_patterns_material ON special_material_patterns(material_id);")) + + # 활성 상태 인덱스 + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_standards_active ON material_standards(is_active);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_categories_active ON material_categories(is_active);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_specifications_active ON material_specifications(is_active);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_grades_active ON material_grades(is_active);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_material_patterns_active ON material_patterns(is_active);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_special_materials_active ON special_materials(is_active);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_special_material_grades_active ON special_material_grades(is_active);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_special_material_patterns_active ON special_material_patterns(is_active);")) + + conn.commit() + print("모든 테이블 및 인덱스 생성 완료!") + +def main(): + """메인 실행 함수""" + print("자재 규격/재질 기준표 DB 마이그레이션 시작") + print("=" * 50) + + create_tables() + + print("\n" + "=" * 50) + print("마이그레이션 완료!") + print("\n다음 단계: python scripts/06_insert_material_standards_data.py") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/scripts/create_material_detail_tables.sql b/backend/scripts/create_material_detail_tables.sql new file mode 100644 index 0000000..ae6d0e8 --- /dev/null +++ b/backend/scripts/create_material_detail_tables.sql @@ -0,0 +1,237 @@ +-- 1. FITTING 상세 테이블 +CREATE TABLE IF NOT EXISTS fitting_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 피팅 타입 정보 + fitting_type VARCHAR(50), + fitting_subtype VARCHAR(50), + + -- 연결 방식 + connection_method VARCHAR(50), + connection_code VARCHAR(50), + + -- 압력 등급 + pressure_rating VARCHAR(50), + max_pressure VARCHAR(50), + + -- 제작 방법 + manufacturing_method VARCHAR(50), + + -- 재질 정보 + material_standard VARCHAR(100), + material_grade VARCHAR(100), + material_type VARCHAR(50), + + -- 사이즈 정보 + main_size VARCHAR(50), + reduced_size VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. VALVE 상세 테이블 +CREATE TABLE IF NOT EXISTS valve_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 밸브 타입 정보 + valve_type VARCHAR(50), + valve_subtype VARCHAR(50), + actuator_type VARCHAR(50), + + -- 연결 방식 + connection_method VARCHAR(50), + + -- 압력 등급 + pressure_rating VARCHAR(50), + pressure_class VARCHAR(50), + + -- 재질 정보 + body_material VARCHAR(100), + trim_material VARCHAR(100), + + -- 사이즈 정보 + size_inches VARCHAR(50), + + -- 특수 사양 + fire_safe BOOLEAN, + low_temp_service BOOLEAN, + special_features JSONB, + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. FLANGE 상세 테이블 +CREATE TABLE IF NOT EXISTS flange_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 플랜지 타입 + flange_type VARCHAR(50), + facing_type VARCHAR(50), + + -- 압력 등급 + pressure_rating VARCHAR(50), + + -- 재질 정보 + material_standard VARCHAR(100), + material_grade VARCHAR(100), + + -- 사이즈 정보 + size_inches VARCHAR(50), + bolt_hole_count INTEGER, + bolt_hole_size VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 4. BOLT 상세 테이블 +CREATE TABLE IF NOT EXISTS bolt_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 볼트 타입 + bolt_type VARCHAR(50), + thread_type VARCHAR(50), + + -- 사양 정보 + diameter VARCHAR(50), + length VARCHAR(50), + + -- 재질 정보 + material_standard VARCHAR(100), + material_grade VARCHAR(100), + coating_type VARCHAR(100), + + -- 너트/와셔 정보 + includes_nut BOOLEAN, + includes_washer BOOLEAN, + nut_type VARCHAR(50), + washer_type VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 5. GASKET 상세 테이블 +CREATE TABLE IF NOT EXISTS gasket_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 가스켓 타입 + gasket_type VARCHAR(50), + gasket_subtype VARCHAR(50), + + -- 재질 정보 + material_type VARCHAR(100), + filler_material VARCHAR(100), + + -- 사이즈 및 등급 + size_inches VARCHAR(50), + pressure_rating VARCHAR(50), + thickness VARCHAR(50), + + -- 특수 사양 + temperature_range VARCHAR(100), + fire_safe BOOLEAN, + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 6. INSTRUMENT 상세 테이블 +CREATE TABLE IF NOT EXISTS instrument_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 계장품 타입 + instrument_type VARCHAR(50), + instrument_subtype VARCHAR(50), + + -- 측정 사양 + measurement_type VARCHAR(50), + measurement_range VARCHAR(100), + accuracy VARCHAR(50), + + -- 연결 정보 + connection_type VARCHAR(50), + connection_size VARCHAR(50), + + -- 재질 정보 + body_material VARCHAR(100), + wetted_parts_material VARCHAR(100), + + -- 전기 사양 + electrical_rating VARCHAR(100), + output_signal VARCHAR(50), + + -- 신뢰도 + classification_confidence FLOAT, + + -- 추가 정보 + additional_info JSONB, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 인덱스 생성 +CREATE INDEX idx_fitting_details_material_id ON fitting_details(material_id); +CREATE INDEX idx_fitting_details_file_id ON fitting_details(file_id); +CREATE INDEX idx_fitting_details_type ON fitting_details(fitting_type); + +CREATE INDEX idx_valve_details_material_id ON valve_details(material_id); +CREATE INDEX idx_valve_details_file_id ON valve_details(file_id); +CREATE INDEX idx_valve_details_type ON valve_details(valve_type); + +CREATE INDEX idx_flange_details_material_id ON flange_details(material_id); +CREATE INDEX idx_flange_details_file_id ON flange_details(file_id); + +CREATE INDEX idx_bolt_details_material_id ON bolt_details(material_id); +CREATE INDEX idx_bolt_details_file_id ON bolt_details(file_id); + +CREATE INDEX idx_gasket_details_material_id ON gasket_details(material_id); +CREATE INDEX idx_gasket_details_file_id ON gasket_details(file_id); + +CREATE INDEX idx_instrument_details_material_id ON instrument_details(material_id); +CREATE INDEX idx_instrument_details_file_id ON instrument_details(file_id); \ No newline at end of file diff --git a/database/init/01_schema.sql b/database/init/01_schema.sql index b77aab1..f28750f 100644 --- a/database/init/01_schema.sql +++ b/database/init/01_schema.sql @@ -81,6 +81,7 @@ CREATE TABLE materials ( -- 분류 신뢰도 및 검증 classification_confidence DECIMAL(3,2), -- 0.00-1.00 분류 신뢰도 + classification_details JSONB, -- 분류 상세 정보 (JSON) is_verified BOOLEAN DEFAULT false, -- 사용자 검증 여부 verified_by VARCHAR(100), verified_at TIMESTAMP, diff --git a/database/init/02_add_classification_details.sql b/database/init/02_add_classification_details.sql new file mode 100644 index 0000000..0faf0b6 --- /dev/null +++ b/database/init/02_add_classification_details.sql @@ -0,0 +1,8 @@ +-- classification_details 컬럼 추가 마이그레이션 +-- 생성일: 2025.01.27 + +-- materials 테이블에 classification_details 컬럼 추가 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS classification_details JSONB; + +-- 인덱스 추가 (JSONB 컬럼 검색 최적화) +CREATE INDEX IF NOT EXISTS idx_materials_classification_details ON materials USING GIN (classification_details); \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 94d03f1..81be89d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import JobSelectionPage from './pages/JobSelectionPage'; -import BOMManagerPage from './pages/BOMManagerPage'; +import BOMStatusPage from './pages/BOMStatusPage'; import MaterialsPage from './pages/MaterialsPage'; function App() { @@ -9,7 +9,7 @@ function App() { } /> - } /> + } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 91b4ea1..59e50cb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -94,6 +94,18 @@ export function createJob(data) { return api.post('/jobs', data); } +// 리비전 비교 +export function compareRevisions(jobNo, filename, oldRevision, newRevision) { + return api.get('/files/materials/compare-revisions', { + params: { + job_no: jobNo, + filename: filename, + old_revision: oldRevision, + new_revision: newRevision + } + }); +} + // 프로젝트 수정 export function updateProject(projectId, data) { return api.put(`/projects/${projectId}`, data); diff --git a/frontend/src/components/FileUpload.jsx b/frontend/src/components/FileUpload.jsx index 4e45844..90ebcfa 100644 --- a/frontend/src/components/FileUpload.jsx +++ b/frontend/src/components/FileUpload.jsx @@ -388,20 +388,20 @@ function FileUpload({ selectedProject, onUploadSuccess }) { 📊 업로드 결과 - - + + - - + - - + /> + + - @@ -413,9 +413,9 @@ function FileUpload({ selectedProject, onUploadSuccess }) { - - + /> + + @@ -434,11 +434,11 @@ function FileUpload({ selectedProject, onUploadSuccess }) { /> {stat.count}개 ({stat.percentage}%) - + - ))} - - )} + ))} + + )} @@ -470,67 +470,67 @@ function FileUpload({ selectedProject, onUploadSuccess }) { - ) : ( - <> - + console.log('드래그 앤 드롭 영역 클릭됨')} - sx={{ - p: 4, - textAlign: 'center', - border: 2, - borderStyle: 'dashed', - borderColor: isDragActive ? 'primary.main' : 'grey.300', - bgcolor: isDragActive ? 'primary.50' : 'grey.50', - cursor: 'pointer', - transition: 'all 0.2s ease', - '&:hover': { - borderColor: 'primary.main', - bgcolor: 'primary.50' - } - }} - > - - - - {isDragActive - ? "파일을 여기에 놓으세요!" - : "Excel 파일을 드래그하거나 클릭하여 선택" - } - - - 지원 형식: .xlsx, .xls, .csv (최대 10MB) - - - + + - - + + 💡 업로드 및 분류 프로세스: - - - • BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다 - - + + + • BOM(Bill of Materials) 파일을 업로드하면 자동으로 자재 정보를 추출합니다 + + • 각 자재는 8가지 분류기(볼트, 플랜지, 피팅, 가스켓, 계기, 파이프, 밸브, 재질)로 자동 분류됩니다 • 분류 결과는 신뢰도 점수와 함께 저장되며, 필요시 수동 검증이 가능합니다 - - - + + + )} ); diff --git a/frontend/src/components/PipeDetailsCard.jsx b/frontend/src/components/PipeDetailsCard.jsx new file mode 100644 index 0000000..0f79250 --- /dev/null +++ b/frontend/src/components/PipeDetailsCard.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Card, CardContent, Typography, Box } from '@mui/material'; + +const PipeDetailsCard = ({ material, fileId }) => { + // 간단한 테스트 버전 + return ( + + + + PIPE 상세 정보 (테스트) + + + + 자재명: {material.original_description} + + + 분류: {material.classified_category} + + + 사이즈: {material.size_spec || '정보 없음'} + + + 수량: {material.quantity} {material.unit} + + + + + ); +}; + +export default PipeDetailsCard; \ No newline at end of file diff --git a/frontend/src/pages/BOMStatusPage.jsx b/frontend/src/pages/BOMStatusPage.jsx index 5f448c0..fbe47b3 100644 --- a/frontend/src/pages/BOMStatusPage.jsx +++ b/frontend/src/pages/BOMStatusPage.jsx @@ -17,7 +17,7 @@ const BOMStatusPage = () => { setLoading(true); setError(''); try { - let url = '/files'; + let url = 'http://localhost:8000/files'; if (jobNo) { url += `?job_no=${jobNo}`; } @@ -28,6 +28,7 @@ const BOMStatusPage = () => { else setFiles([]); } catch (e) { setError('파일 목록을 불러오지 못했습니다.'); + console.error('파일 목록 로드 에러:', e); } finally { setLoading(false); } @@ -45,10 +46,10 @@ const BOMStatusPage = () => { setUploading(true); setError(''); try { - const formData = new FormData(); + const formData = new FormData(); formData.append('file', file); formData.append('project_id', 1); // 예시: 실제 프로젝트 ID로 대체 필요 - const res = await fetch('/files/upload', { + const res = await fetch('http://localhost:8000/upload', { method: 'POST', body: formData }); @@ -74,49 +75,62 @@ const BOMStatusPage = () => { accept=".csv,.xlsx,.xls" onChange={e => setFile(e.target.files[0])} disabled={uploading} - /> + /> + {error && {error}} {loading && } - + 파일명 리비전 세부내역 리비전 삭제 - - - + + + {files.map(file => ( {file.original_filename || file.filename} {file.revision} - - - - + + - - - - - + + ))} - -
-
+ + + ); }; diff --git a/frontend/src/pages/JobSelectionPage.jsx b/frontend/src/pages/JobSelectionPage.jsx index f0601d8..bd1d0ad 100644 --- a/frontend/src/pages/JobSelectionPage.jsx +++ b/frontend/src/pages/JobSelectionPage.jsx @@ -40,7 +40,7 @@ const JobSelectionPage = () => { const handleConfirm = () => { if (selectedJobNo && selectedJobName) { - navigate(`/bom-manager?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`); + navigate(`/bom-status?job_no=${selectedJobNo}&job_name=${encodeURIComponent(selectedJobName)}`); } }; diff --git a/frontend/src/pages/MaterialLookupPage.jsx b/frontend/src/pages/MaterialLookupPage.jsx deleted file mode 100644 index 77a961b..0000000 --- a/frontend/src/pages/MaterialLookupPage.jsx +++ /dev/null @@ -1,221 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Box, - Typography, - FormControl, - InputLabel, - Select, - MenuItem, - Button, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - CircularProgress, - Alert -} from '@mui/material'; - -// API 함수는 기존 api.js의 fetchJobs, fetchFiles, fetchMaterials를 활용한다고 가정 -import { fetchJobs, fetchMaterials } from '../api'; - -const MaterialLookupPage = () => { - const [jobs, setJobs] = useState([]); - const [files, setFiles] = useState([]); - const [revisions, setRevisions] = useState([]); - const [selectedJobNo, setSelectedJobNo] = useState(''); - const [selectedFilename, setSelectedFilename] = useState(''); - const [selectedRevision, setSelectedRevision] = useState(''); - const [materials, setMaterials] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - // 1. Job 목록 불러오기 (최초 1회) - useEffect(() => { - async function loadJobs() { - try { - const res = await fetchJobs({}); - if (res.data && res.data.jobs) setJobs(res.data.jobs); - } catch (e) { - setError('Job 목록을 불러오지 못했습니다.'); - } - } - loadJobs(); - }, []); - - // 2. Job 선택 시 해당 도면(파일) 목록 불러오기 - useEffect(() => { - async function loadFiles() { - if (!selectedJobNo) { - setFiles([]); - setRevisions([]); - setSelectedFilename(''); - setSelectedRevision(''); - return; - } - try { - const res = await fetch(`/files?job_no=${selectedJobNo}`); - const data = await res.json(); - if (Array.isArray(data)) setFiles(data); - else if (data && Array.isArray(data.files)) setFiles(data.files); - else setFiles([]); - setSelectedFilename(''); - setSelectedRevision(''); - setRevisions([]); - } catch (e) { - setFiles([]); - setRevisions([]); - setError('도면 목록을 불러오지 못했습니다.'); - } - } - loadFiles(); - }, [selectedJobNo]); - - // 3. 도면 선택 시 해당 리비전 목록 추출 - useEffect(() => { - if (!selectedFilename) { - setRevisions([]); - setSelectedRevision(''); - return; - } - const filtered = files.filter(f => f.original_filename === selectedFilename); - setRevisions(filtered.map(f => f.revision)); - setSelectedRevision(''); - }, [selectedFilename, files]); - - // 4. 조회 버튼 클릭 시 자재 목록 불러오기 - const handleLookup = async () => { - setLoading(true); - setError(''); - setMaterials([]); - try { - const params = { - job_no: selectedJobNo, - filename: selectedFilename, - revision: selectedRevision - }; - const res = await fetchMaterials(params); - if (res.data && Array.isArray(res.data.materials)) { - setMaterials(res.data.materials); - } else { - setMaterials([]); - } - } catch (e) { - setError('자재 목록을 불러오지 못했습니다.'); - } finally { - setLoading(false); - } - }; - - // 5. 3개 모두 선택 시 자동 조회 (원하면 주석 해제) - // useEffect(() => { - // if (selectedJobNo && selectedFilename && selectedRevision) { - // handleLookup(); - // } - // }, [selectedJobNo, selectedFilename, selectedRevision]); - - return ( - - - 자재 상세 조회 (Job No + 도면명 + 리비전) - - - {/* Job No 드롭다운 */} - - Job No - - - {/* 도면명 드롭다운 */} - - 도면명(파일명) - - - {/* 리비전 드롭다운 */} - - 리비전 - - - - - {error && {error}} - {loading && } - {!loading && materials.length > 0 && ( - - - - - 품명 - 수량 - 단위 - 사이즈 - 재질 - 라인번호 - - - - {materials.map(mat => ( - - {mat.original_description} - {mat.quantity} - {mat.unit} - {mat.size_spec} - {mat.material_grade} - {mat.line_number} - - ))} - -
-
- )} - {!loading && materials.length === 0 && (selectedJobNo && selectedFilename && selectedRevision) && ( - - 해당 조건에 맞는 자재가 없습니다. - - )} -
- ); -}; - -export default MaterialLookupPage; \ No newline at end of file diff --git a/frontend/src/pages/MaterialsPage.jsx b/frontend/src/pages/MaterialsPage.jsx index 90faa91..9b7859e 100644 --- a/frontend/src/pages/MaterialsPage.jsx +++ b/frontend/src/pages/MaterialsPage.jsx @@ -1,8 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, + Card, + CardContent, Typography, - Button, + Grid, + Chip, + Alert, + CircularProgress, + Tabs, + Tab, Table, TableBody, TableCell, @@ -10,169 +17,448 @@ import { TableHead, TableRow, Paper, - CircularProgress, - Alert, - Chip, - Card, - CardContent, - Grid + Button, + FormControlLabel, + Switch } from '@mui/material'; -import { useSearchParams, useNavigate } from 'react-router-dom'; -import { fetchMaterialsSummary } from '../api'; +import PipeDetailsCard from '../components/PipeDetailsCard'; +import { Pie, Bar } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, + ArcElement +} from 'chart.js'; +import { api } from '../api'; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, + ArcElement +); const MaterialsPage = () => { const [materials, setMaterials] = useState([]); - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - - const fileId = searchParams.get('file_id'); - const jobNo = searchParams.get('job_no'); - const filename = searchParams.get('filename'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [fileId, setFileId] = useState(null); + const [activeTab, setActiveTab] = useState(0); + const [selectedCategories, setSelectedCategories] = useState([]); + const [revisionComparison, setRevisionComparison] = useState(null); + const [showOnlyPurchaseRequired, setShowOnlyPurchaseRequired] = useState(false); - // 자재 목록 불러오기 - const loadMaterials = async () => { - if (!fileId) return; - - setLoading(true); - setError(''); + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const id = urlParams.get('fileId'); + if (id) { + setFileId(id); + loadMaterials(id); + } else { + setLoading(false); + setError('파일 ID가 지정되지 않았습니다. BOM 현황 페이지에서 파일을 선택해주세요.'); + } + }, []); + + const loadMaterials = async (id) => { + console.log('자재 로딩 시작, file_id:', id); try { - // 자재 목록 조회 - const response = await fetch(`http://localhost:8000/files/materials?file_id=${fileId}`); - const data = await response.json(); - - if (data.materials && Array.isArray(data.materials)) { - // 동일 항목 그룹화 (품명 + 사이즈 + 재질이 같은 것들) - const groupedMaterials = groupMaterialsByItem(data.materials); - setMaterials(groupedMaterials); - } else { - setMaterials([]); - } - - // 요약 정보 조회 - const summaryResponse = await fetchMaterialsSummary({ file_id: fileId }); - if (summaryResponse.data.success) { - setSummary(summaryResponse.data.summary); - } - } catch (e) { - setError('자재 목록을 불러오지 못했습니다.'); - console.error('자재 로드 에러:', e); + setLoading(true); + const response = await api.get('/files/materials', { params: { file_id: parseInt(id) } }); + console.log('자재 데이터 로딩 성공:', response.data); + setMaterials(response.data); + setError(null); + } catch (err) { + setError('자재 정보를 불러오는데 실패했습니다.'); + console.error('자재 로딩 에러:', err); + console.error('에러 상세:', err.response?.data); } finally { setLoading(false); } }; - // 동일 항목 그룹화 함수 const groupMaterialsByItem = (materials) => { const grouped = {}; - materials.forEach(material => { - // 그룹화 키: 품명 + 사이즈 + 재질 + 분류 - const key = `${material.original_description}_${material.size_spec || ''}_${material.material_grade || ''}_${material.classified_category || ''}`; - + const key = `${material.original_description}_${material.size_spec}_${material.material_grade}`; if (!grouped[key]) { - grouped[key] = { - ...material, - totalQuantity: 0, - items: [] - }; + grouped[key] = []; } - - grouped[key].totalQuantity += material.quantity || 0; - grouped[key].items.push(material); + grouped[key].push(material); }); - - return Object.values(grouped).sort((a, b) => b.totalQuantity - a.totalQuantity); + return grouped; }; - useEffect(() => { - loadMaterials(); - }, [fileId]); + const getFilteredMaterials = () => { + if (selectedCategories.length === 0) { + return materials; + } + return materials.filter(material => + selectedCategories.includes(material.classified_category) + ); + }; + + const calculateCategoryStats = () => { + const stats = {}; + materials.forEach(material => { + const category = material.classified_category || 'UNKNOWN'; + if (!stats[category]) { + stats[category] = { count: 0, totalQuantity: 0 }; + } + stats[category].count++; + stats[category].totalQuantity += material.quantity || 0; + }); + return stats; + }; + + const getAvailableCategories = () => { + const categories = [...new Set(materials.map(m => m.classified_category).filter(Boolean))]; + return categories.sort(); + }; + + const calculateClassificationStats = () => { + const totalItems = materials.length; + const classifiedItems = materials.filter(m => m.classified_category && m.classified_category !== 'UNKNOWN').length; + const unclassifiedItems = totalItems - classifiedItems; + + const highConfidence = materials.filter(m => + m.classification_confidence && m.classification_confidence >= 0.8 + ).length; + const mediumConfidence = materials.filter(m => + m.classification_confidence && m.classification_confidence >= 0.5 && m.classification_confidence < 0.8 + ).length; + const lowConfidence = materials.filter(m => + m.classification_confidence && m.classification_confidence < 0.5 + ).length; + + const categoryBreakdown = {}; + materials.forEach(material => { + const category = material.classified_category || 'UNKNOWN'; + if (!categoryBreakdown[category]) { + categoryBreakdown[category] = { highConfidence: 0, mediumConfidence: 0, lowConfidence: 0 }; + } + + if (material.classification_confidence >= 0.8) { + categoryBreakdown[category].highConfidence++; + } else if (material.classification_confidence >= 0.5) { + categoryBreakdown[category].mediumConfidence++; + } else { + categoryBreakdown[category].lowConfidence++; + } + }); + + return { + totalItems, + classifiedItems, + unclassifiedItems, + highConfidence, + mediumConfidence, + lowConfidence, + categoryBreakdown + }; + }; + + const getDisplayInfo = (material) => { + const category = material.classified_category; + let details = material.classification_details || {}; + + // classification_details가 문자열인 경우 JSON 파싱 + if (typeof details === 'string') { + try { + details = JSON.parse(details); + } catch (e) { + console.error('분류 상세정보 파싱 실패:', e); + details = {}; + } + } + + switch (category) { + case 'PIPE': + // 1. classification_details에서 길이 정보 가져오기 + const cuttingDimensions = details?.cutting_dimensions || {}; + let lengthMm = cuttingDimensions?.length_mm; + + // 2. 백엔드에서 전달된 length 필드도 확인 + if (!lengthMm && material.length) { + lengthMm = material.length; + } + + if (lengthMm) { + return { + value: lengthMm, + unit: 'mm', + displayText: `${lengthMm}mm`, + isLength: true + }; + } + break; + case 'BOLT': + case 'NUT': + case 'WASHER': + return { + value: material.quantity, + unit: 'EA', + displayText: `${material.quantity} EA`, + isLength: false + }; + default: + return { + value: material.quantity, + unit: 'EA', + displayText: `${material.quantity} EA`, + isLength: false + }; + } + + // 기본값 + return { + value: material.quantity, + unit: 'EA', + displayText: `${material.quantity} EA`, + isLength: false + }; + }; + + const generateCategoryChartData = (category, items) => { + switch (category) { + case 'PIPE': + const totalLength = items.reduce((sum, item) => { + const details = item.classification_details || {}; + const cuttingDimensions = details?.cutting_dimensions || {}; + let lengthMm = cuttingDimensions?.length_mm; + + // 백엔드에서 전달된 length 필드도 확인 + if (!lengthMm && item.length) { + lengthMm = item.length; + } + + return sum + (lengthMm || 0); + }, 0); + return { + value: totalLength, + unit: 'mm', + displayText: `${totalLength}mm`, + isLength: true + }; + case 'BOLT': + case 'NUT': + case 'WASHER': + const totalQuantity = items.reduce((sum, item) => sum + (item.quantity || 0), 0); + return { + value: totalQuantity, + unit: 'EA', + displayText: `${totalQuantity} EA`, + isLength: false + }; + default: + const totalQty = items.reduce((sum, item) => sum + (item.quantity || 0), 0); + return { + value: totalQty, + unit: 'EA', + displayText: `${totalQty} EA`, + isLength: false + }; + } + }; + + const generateChartData = () => { + const categoryStats = calculateCategoryStats(); + const categories = Object.keys(categoryStats); + + return { + labels: categories, + datasets: [{ + label: '항목 수', + data: categories.map(cat => categoryStats[cat].count), + backgroundColor: categories.map(cat => getCategoryColor(cat) === 'primary' ? '#1976d2' : + getCategoryColor(cat) === 'secondary' ? '#9c27b0' : + getCategoryColor(cat) === 'error' ? '#d32f2f' : + getCategoryColor(cat) === 'warning' ? '#ed6c02' : + getCategoryColor(cat) === 'info' ? '#0288d1' : + getCategoryColor(cat) === 'success' ? '#2e7d32' : '#757575'), + borderWidth: 1 + }] + }; + }; + + const handleRevisionComparison = async () => { + try { + const response = await api.get(`/materials/${fileId}/revision-comparison`); + setRevisionComparison(response.data); + } catch (err) { + setError('리비전 비교를 불러오는데 실패했습니다.'); + console.error('리비전 비교 에러:', err); + } + }; + + const handleAutoRevisionComparison = async () => { + try { + const response = await api.get(`/materials/${fileId}/auto-revision-comparison`); + setRevisionComparison(response.data); + } catch (err) { + setError('자동 리비전 비교를 불러오는데 실패했습니다.'); + console.error('자동 리비전 비교 에러:', err); + } + }; + + const getPurchaseRequiredItems = () => { + if (!revisionComparison) return { added: [], changed: [] }; + + return { + added: revisionComparison.changes.added, + changed: revisionComparison.changes.changed.filter(item => item.quantity_change > 0) + }; + }; + + const calculateTotalPurchaseQuantity = () => { + const purchaseItems = getPurchaseRequiredItems(); + const addedQuantity = purchaseItems.added.reduce((sum, item) => sum + item.item.quantity, 0); + const changedQuantity = purchaseItems.changed.reduce((sum, item) => sum + item.quantity_change, 0); + return addedQuantity + changedQuantity; + }; + + const generateComparisonChartData = () => { + if (!revisionComparison) return null; + + const { added, removed, changed } = revisionComparison.changes; + + return { + labels: ['신규 추가', '삭제', '수량 증가', '수량 감소'], + datasets: [{ + label: '항목 수', + data: [ + added.length, + removed.length, + changed.filter(item => item.quantity_change > 0).length, + changed.filter(item => item.quantity_change < 0).length + ], + backgroundColor: ['#4caf50', '#f44336', '#ff9800', '#ff5722'], + borderWidth: 1 + }] + }; + }; - // 분류별 색상 지정 const getCategoryColor = (category) => { - const colors = { + const colorMap = { 'PIPE': 'primary', 'FITTING': 'secondary', 'VALVE': 'error', 'FLANGE': 'warning', 'BOLT': 'info', 'GASKET': 'success', - 'INSTRUMENT': 'default' + 'INSTRUMENT': 'primary', + 'OTHER': 'default' }; - return colors[category] || 'default'; + return colorMap[category] || 'default'; + }; + + const getClassifiedDescription = (material) => { + const details = material.classification_details; + if (typeof details === 'string') { + try { + const parsed = JSON.parse(details); + return parsed.description || material.original_description; + } catch (e) { + return material.original_description; + } + } + return details?.description || material.original_description; + }; + + const categoryStats = calculateCategoryStats(); + const classificationStats = calculateClassificationStats(); + const pieData = generateChartData(); + const comparisonData = generateComparisonChartData(); + + const summary = { + total_items: materials.length, + total_quantity: materials.reduce((sum, m) => sum + (m.quantity || 0), 0) }; return ( - - {/* 헤더 */} + - - - - 자재 목록 - {filename} + + 📋 자재 분류 결과 - - - Job No: {jobNo} + + 업로드된 BOM 파일의 자재 분류 결과를 확인하세요. - {/* 요약 정보 */} - {summary && ( - + {/* 요약 통계 */} + {materials.length > 0 && ( + + {/* 총 항목 수 */} - + - 총 항목 수 + 📊 총 항목 - - {summary.total_items} + + {summary.total_items.toLocaleString()}개 + + + 엑셀에서 추출된 총 항목 + + {/* 분류 완료된 항목 */} - + - 고유 품명 수 + ✅ 분류 완료 - - {summary.unique_descriptions} + + {classificationStats.classifiedItems.toLocaleString()}개 + + + {Math.round((classificationStats.classifiedItems / summary.total_items) * 100)}% 분류율 + + {/* 분류 미완료 항목 */} - + - 총 수량 + ⚠️ 분류 미완료 - - {summary.total_quantity} + + {classificationStats.unclassifiedItems.toLocaleString()}개 + + + 수동 분류 필요 + + {/* 총 수량 */} - + - 고유 재질 수 + 📦 총 수량 - - {summary.unique_materials} + + {summary.total_quantity.toLocaleString()} EA + + + 모든 자재의 총 수량 @@ -180,68 +466,576 @@ const MaterialsPage = () => { )} + {/* 분류 결과 상세 */} + {Object.keys(categoryStats).length > 0 && ( + + + + 🔍 분류기별 결과 + + + {Object.entries(categoryStats).map(([category, stats]) => ( + + + + + + {stats.count.toLocaleString()}개 + + + + 총 수량: {stats.totalQuantity.toLocaleString()} EA + + + 비율: {Math.round((stats.count / classificationStats.totalItems) * 100)}% + + + {/* 신뢰도 정보 */} + {classificationStats.categoryBreakdown[category] && ( + + + 신뢰도: + + 높음 {classificationStats.categoryBreakdown[category].highConfidence}개 + + + 중간 {classificationStats.categoryBreakdown[category].mediumConfidence}개 + + + 낮음 {classificationStats.categoryBreakdown[category].lowConfidence}개 + + + + )} + + + ))} + + + + )} + + {/* 분류 신뢰도 요약 */} + {classificationStats.classifiedItems > 0 && ( + + + + 🎯 분류 신뢰도 요약 + + + + + + {classificationStats.highConfidence}개 + + + 높은 신뢰도 (80% 이상) + + + + + + + {classificationStats.mediumConfidence}개 + + + 중간 신뢰도 (50-80%) + + + + + + + {classificationStats.lowConfidence}개 + + + 낮은 신뢰도 (50% 미만) + + + + + + + )} + {error && {error}} {loading && } - {/* 자재 목록 테이블 */} + {/* 탭 네비게이션 */} {!loading && materials.length > 0 && ( + + setActiveTab(newValue)}> + + + + + + )} + + {/* 차트 탭 */} + {!loading && materials.length > 0 && activeTab === 0 && ( + + + 📊 분류별 통계 (업체 견적 의뢰용) + + + + {/* 파이 차트 */} + + + + + 분류별 항목 수 + + + + + + + + + {/* 바 차트 */} + + + + + 분류별 수량 + + + stats.totalQuantity), + backgroundColor: Object.keys(categoryStats).map(cat => + getCategoryColor(cat) === 'primary' ? '#1976d2' : + getCategoryColor(cat) === 'secondary' ? '#9c27b0' : + getCategoryColor(cat) === 'error' ? '#d32f2f' : + getCategoryColor(cat) === 'warning' ? '#ed6c02' : + getCategoryColor(cat) === 'info' ? '#0288d1' : + getCategoryColor(cat) === 'success' ? '#2e7d32' : '#757575' + ) + }] + }} + options={{ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + } + }, + scales: { + y: { + beginAtZero: true + } + } + }} + /> + + + + + + + )} + + {/* 상세 목록 탭 */} + {!loading && materials.length > 0 && activeTab === 1 && ( + + + 📋 상세 자재 목록 (테스트) + + + 총 {materials.length}개 자재가 로드되었습니다. + + + {/* 필터 */} + + {getAvailableCategories().map(category => ( + { + if (selectedCategories.includes(category)) { + setSelectedCategories(selectedCategories.filter(c => c !== category)); + } else { + setSelectedCategories([...selectedCategories, category]); + } + }} + clickable + /> + ))} + + + {/* 자재 테이블 */} + 라인 분류 품명 사이즈 재질 - 총 수량 + 수량 단위 - 항목 수 신뢰도 - {materials.map((material, index) => ( - + {getFilteredMaterials().map((material, index) => { + const displayInfo = getDisplayInfo(material); + return ( + + + {material.line_number} + {getClassifiedDescription(material)} + {material.size_spec || '-'} + {material.material_grade || '-'} + {displayInfo.displayText} + {displayInfo.unit} - - {material.original_description} + = 0.8 ? 'success.main' : + material.classification_confidence >= 0.5 ? 'warning.main' : 'error.main'} + > + {Math.round((material.classification_confidence || 0) * 100)}% + + + + {/* PIPE 상세 정보 */} + {material.classified_category === 'PIPE' && ( + + + + + + )} + + ); + })} + +
+
+
+ )} + + {/* 리비전 비교 탭 */} + {!loading && materials.length > 0 && activeTab === 2 && ( + + + 🔄 리비전 비교 + + + + + + + + {revisionComparison && ( + + {/* 비교 요약 */} + + + + + 📊 변경 사항 요약 + + + + + + + + + {/* 발주 필요 수량 */} + + + + + 📦 발주 필요 수량 + + + sum + item.item.quantity, 0), + getPurchaseRequiredItems().changed.reduce((sum, item) => sum + item.quantity_change, 0) + ], + backgroundColor: ['#4caf50', '#ff9800'] + }] + }} + options={{ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + } + }, + scales: { + y: { + beginAtZero: true + } + } + }} + /> + + + + + + {/* 발주 필요 항목 테이블 */} + + + + + + {showOnlyPurchaseRequired ? '📋 발주 필요 항목만' : '📋 전체 변경 사항'} + + setShowOnlyPurchaseRequired(e.target.checked)} + /> + } + label="발주 필요 항목만" + /> + + + + + + 변경 유형 + 분류 + 품명 + 사이즈 + 재질 + 기존 수량 + 새 수량 + 발주 수량 + + + + {!showOnlyPurchaseRequired && ( + <> + {/* 추가된 항목 */} + {revisionComparison.changes.added.map((item, index) => ( + + + + + + + + {item.item.original_description} + {item.item.size_spec || '-'} + {item.item.material_grade || '-'} + - + {item.item.quantity} + + + {item.item.quantity} + + + + ))} + + {/* 삭제된 항목 */} + {revisionComparison.changes.removed.map((item, index) => ( + + + + + + + + {item.item.original_description} + {item.item.size_spec || '-'} + {item.item.material_grade || '-'} + {item.item.quantity} + - + + + - - {material.size_spec || '-'} - {material.material_grade || '-'} - - - {material.totalQuantity} - - - {material.unit || 'EA'} - + + ))} + + {/* 수량 변경된 항목 */} + {revisionComparison.changes.changed.map((item, index) => ( + 0 ? '#FFF3E0' : '#FFEBEE' + }}> + 0 ? '수량 증가' : '수량 감소'} + color={item.quantity_change > 0 ? 'warning' : 'error'} size="small" /> + + + {item.new_item.original_description} + {item.new_item.size_spec || '-'} + {item.new_item.material_grade || '-'} + {item.old_item.quantity} + {item.new_item.quantity} + 0.7 ? 'success.main' : 'warning.main'} - > - {Math.round((material.classification_confidence || 0) * 100)}% + color={item.quantity_change > 0 ? 'warning.main' : 'error.main'} + sx={{ fontWeight: 'bold' }} + > + {item.quantity_change > 0 ? `+${item.quantity_change}` : '-'} + + + + ))} + + )} + + {/* 발주 필요 항목만 표시 */} + {showOnlyPurchaseRequired && ( + <> + {/* 신규 추가 항목 */} + {getPurchaseRequiredItems().added.map((item, index) => ( + + + + + + + + {item.item.original_description} + {item.item.size_spec || '-'} + {item.item.material_grade || '-'} + - + {item.item.quantity} + + + {item.item.quantity} + + + + ))} + + {/* 수량 증가 항목 */} + {getPurchaseRequiredItems().changed.map((item, index) => ( + + + + + + + + {item.new_item.original_description} + {item.new_item.size_spec || '-'} + {item.new_item.material_grade || '-'} + {item.old_item.quantity} + {item.new_item.quantity} + + + +{item.quantity_change} ))} + + )}
+
+
+
+
+ )} +
)} {!loading && materials.length === 0 && fileId && ( diff --git a/frontend/src/pages/ProjectSelectionPage.jsx b/frontend/src/pages/ProjectSelectionPage.jsx index 9ccabd7..15c920f 100644 --- a/frontend/src/pages/ProjectSelectionPage.jsx +++ b/frontend/src/pages/ProjectSelectionPage.jsx @@ -14,13 +14,13 @@ const ProjectSelectionPage = () => { async function loadJobs() { setLoading(true); setError(''); - try { + try { const res = await fetchJobs({}); if (res.data && Array.isArray(res.data.jobs)) { setJobs(res.data.jobs); } else { setJobs([]); - } + } } catch (e) { setError('프로젝트 목록을 불러오지 못했습니다.'); } finally { @@ -37,8 +37,8 @@ const ProjectSelectionPage = () => { {error && {error}} Job No - - - {selectedJob && ( + + + {selectedJob && ( 선택된 Job No: {selectedJob} )} - + > + 확인 +
); };