From 0f9a5ad2ea9b5df0ac8f591f7a3a525c89416713 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 25 Sep 2025 08:32:17 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20=EC=9E=AC=EC=A7=88=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=91=9C=EC=8B=9C=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20UI=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ 주요 수정사항: - 재질 GRADE 전체 표기: ASTM A106 B 완전 표시 (A10 잘림 현상 해결) - material_grade_extractor.py 정규표현식 패턴 개선 - files.py 파일 업로드 시 재질 추출 로직 수정 - CSS 그리드 너비 확장으로 텍스트 잘림 현상 해결 - 사용자 요구사항 엑셀 다운로드 기능 완료 🎯 해결된 문제: 1. ASTM A106 B → ASTM A10 잘림 문제 2. 재질 컬럼 너비 부족으로 인한 표시 문제 3. 사용자 요구사항이 엑셀에 반영되지 않는 문제 📋 다음 단계 준비: - 파이프 끝단 정보 제외 취합 로직 개선 - 플랜지 타입 정보 확장 - 자재 분류 필터 기능 추가 --- RULES.md | 102 ++++++- backend/app/auth/jwt_service.py | 5 + backend/app/auth/middleware.py | 5 + backend/app/routers/files.py | 280 ++++++++++++++++- backend/app/services/integrated_classifier.py | 18 +- .../app/services/material_grade_extractor.py | 247 +++++++++++++++ backend/app/services/revision_comparator.py | 5 + backend/app/services/support_classifier.py | 283 ++++++++++++++++++ backend/scripts/18_create_auth_tables.sql | 5 + ...1_add_material_id_to_user_requirements.sql | 19 ++ .../scripts/22_add_full_material_grade.sql | 11 + .../23_create_support_details_table.sql | 45 +++ database/init/20_purchase_confirmations.sql | 5 + docker-compose.dev.yml | 20 ++ frontend/Dockerfile.dev | 24 ++ frontend/src/SimpleDashboard.jsx | 5 + frontend/src/components/NavigationBar.css | 5 + frontend/src/components/NavigationBar.jsx | 5 + frontend/src/components/NavigationMenu.css | 5 + frontend/src/components/NavigationMenu.jsx | 5 + .../src/components/RevisionUploadDialog.jsx | 5 + frontend/src/components/SimpleFileUpload.jsx | 5 + frontend/src/pages/DashboardPage.jsx | 5 + frontend/src/pages/LoginPage.css | 5 + frontend/src/pages/LoginPage.jsx | 5 + frontend/src/pages/NewMaterialsPage.css | 51 ++-- frontend/src/pages/NewMaterialsPage.jsx | 154 ++++++++-- frontend/src/pages/ProjectsPage.jsx | 5 + frontend/src/pages/UserManagementPage.css | 5 + 29 files changed, 1281 insertions(+), 58 deletions(-) create mode 100644 backend/app/services/material_grade_extractor.py create mode 100644 backend/app/services/support_classifier.py create mode 100644 backend/scripts/21_add_material_id_to_user_requirements.sql create mode 100644 backend/scripts/22_add_full_material_grade.sql create mode 100644 backend/scripts/23_create_support_details_table.sql create mode 100644 docker-compose.dev.yml create mode 100644 frontend/Dockerfile.dev diff --git a/RULES.md b/RULES.md index a59f838..31162f1 100644 --- a/RULES.md +++ b/RULES.md @@ -2004,4 +2004,104 @@ const materials = await fetchMaterials({ --- -**마지막 업데이트**: 2025년 9월 (자재 분류 규칙 및 API 정리 완료) +## 🔧 **사용자 피드백 기반 개선사항** (2025.09.24) + +### 📋 **개선 요구사항 목록** + +#### **1. 사용자 요구사항 엑셀 반영** ⚡ 우선순위: 높음 +- **문제**: 자재 목록 페이지에서 작성한 사용자 요구사항이 엑셀 다운로드 시 미반영 +- **해결방안**: + - 사용자 요구사항 저장 API 구현 + - 엑셀 내보내기 시 사용자 요구사항 컬럼 추가 + - 백엔드-프론트엔드 연동 강화 + +#### **2. 재질 GRADE 전체 표기** ⚡ 우선순위: 높음 +- **문제**: 현재 `ASTM A312 WP304` → 입력된 전체 재질명 표기 필요 +- **적용 범위**: 모든 자재 (파이프, 엘보, 플랜지 등) +- **원칙**: 생략이나 축약 금지, 원본 재질명 그대로 표시 + +#### **3. U-Bolt & Urethane Block 카테고리** ⚡ 우선순위: 중간 +- **신규 카테고리**: U-BOLT, URETHANE_BLOCK +- **분류 기준**: 크기별, 재질별, 기타 사양별 +- **분류기**: 필요시 구현, 우선은 수동 분류 + +#### **4. Special Flange 비기성품 정리** ⚡ 우선순위: 중간 +- **위치**: 각 카테고리 맨 하단에 배치 +- **정보**: 재질, 사이즈, 특수 사양 상세 표기 +- **구분**: 기성품과 명확히 구분되도록 표시 + +#### **5. 플랜지 타입 정보 확장** ⚡ 우선순위: 중간 +- **현재**: WN, BW 등 기본 정보만 표기 +- **개선**: pipe측 타입도 표기 (WN RF, SW RF, SO RF) +- **적용**: 플랜지 상세 정보 확장 + +#### **6. Nipple 끝단 정보 표기** ⚡ 우선순위: 중간 +- **현재**: 끝단 정보 수집하지만 표기 안함 +- **개선**: 타입/상세 부분에 끝단 정보 표기 +- **연동**: 기존 끝단 가공 코드 활용 + +#### **7. Reducing 배관 Schedule 분리** ⚡ 우선순위: 중간 +- **문제**: Main pipe와 Sub pipe의 Schedule이 다를 수 있음 +- **해결**: Schedule 표기 시 2개로 분리 표현 +- **형식**: `Main Sch.40 / Sub Sch.80` 형태 + +#### **8. 웹 화면 내용 잘림 해결** ⚡ 우선순위: 높음 +- **문제**: 긴 내용이 웹 화면에서 잘리는 현상 +- **해결**: 컬럼 너비 확장, 텍스트 래핑 개선 +- **적용**: 모든 테이블 및 목록 화면 + +#### **9. 자재 전체 목록 카테고리 추가** ⚡ 우선순위: 낮음 +- **추가**: 자재목록 카테고리에 "자재 전체 목록" 옵션 +- **기능**: 모든 카테고리 통합 조회 +- **정렬**: 카테고리별 그룹핑 또는 통합 정렬 + +#### **10. 자재 목록 분류 필터 기능** ⚡ 우선순위: 중간 +- **위치**: 자재 목록 페이지 분류 섹션 +- **기능**: 카테고리별, 재질별, 사이즈별 필터링 +- **UI**: 드롭다운 또는 체크박스 형태 + +#### **11. 자재 리비전 비교 개선** ⚡ 우선순위: 높음 +- **현재**: 과거 기준 없는 것만 표시 +- **개선**: 남는 것(기존) / 필요한 것(신규) 분리 표현 +- **UI**: 탭 또는 섹션으로 구분하여 표시 + +### 🚀 **구현 우선순위** + +#### **Phase 1: 핵심 기능 개선** (1-2주) +1. 사용자 요구사항 엑셀 반영 (#1) +2. 재질 GRADE 전체 표기 (#2) +3. 웹 화면 내용 잘림 해결 (#8) +4. 자재 리비전 비교 개선 (#11) + +#### **Phase 2: 분류 및 표기 개선** (2-3주) +5. 플랜지 타입 정보 확장 (#5) +6. Nipple 끝단 정보 표기 (#6) +7. Reducing 배관 Schedule 분리 (#7) +8. 자재 목록 분류 필터 기능 (#10) + +#### **Phase 3: 신규 카테고리 및 기능** (3-4주) +9. U-Bolt & Urethane Block 카테고리 (#3) +10. Special Flange 비기성품 정리 (#4) +11. 자재 전체 목록 카테고리 추가 (#9) + +### 📝 **개발 가이드라인** + +#### **코드 수정 원칙** +- **하위 호환성**: 기존 데이터 구조 유지 +- **점진적 개선**: 단계별 구현으로 안정성 확보 +- **테스트**: 각 개선사항별 충분한 테스트 +- **문서화**: 변경사항 즉시 문서 반영 + +#### **데이터베이스 변경** +- **스키마 확장**: 기존 테이블에 컬럼 추가 방식 우선 +- **마이그레이션**: 단계별 스크립트 작성 +- **백업**: 변경 전 데이터 백업 필수 + +#### **UI/UX 개선** +- **반응형**: 모바일/태블릿 호환성 유지 +- **접근성**: 사용자 친화적 인터페이스 +- **성능**: 대용량 데이터 처리 최적화 + +--- + +**마지막 업데이트**: 2025년 9월 24일 (사용자 피드백 기반 개선사항 정리) diff --git a/backend/app/auth/jwt_service.py b/backend/app/auth/jwt_service.py index c2d5165..3702a4c 100644 --- a/backend/app/auth/jwt_service.py +++ b/backend/app/auth/jwt_service.py @@ -264,4 +264,9 @@ jwt_service = JWTService() + + + + + diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index 3d00ad2..6e414ed 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -318,4 +318,9 @@ async def get_current_user_optional( + + + + + diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 52163f1..6729221 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -31,6 +31,130 @@ from app.services.revision_comparator import get_revision_comparison router = APIRouter() +def extract_enhanced_material_grade(description: str, original_grade: str, category: str) -> str: + """ + 원본 설명에서 개선된 재질 정보를 추출 + + Args: + description: 원본 설명 + original_grade: 기존 재질 정보 + category: 자재 카테고리 (PIPE, FITTING, FLANGE 등) + + Returns: + 개선된 재질 정보 + """ + if not description: + return original_grade or '-' + + desc_upper = description.upper() + + # PIPE 재질 패턴 + if category == 'PIPE': + pipe_patterns = [ + (r'A312\s*(TP\d+[A-Z]*)', lambda m: f'A312 {m.group(1)}'), + (r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B, A106 GR B 등 전체 보존 + (r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B + (r'A333\s*(GR\.?\s*[A-Z0-9]+)', lambda m: f'A333 {m.group(1)}'), # A333 GR.6 등 전체 보존 + (r'A333\s*([A-Z0-9]+)', lambda m: f'A333 GR.{m.group(1)}'), # A333 6 → A333 GR.6 + (r'A53\s*(GR\.?\s*[A-Z]+)', lambda m: f'A53 {m.group(1)}'), # A53 GR.B 등 전체 보존 + (r'A53\s*([A-Z]+)', lambda m: f'A53 GR.{m.group(1)}'), # A53 B → A53 GR.B + (r'A335\s*(P\d+[A-Z]*)', lambda m: f'A335 {m.group(1)}'), + (r'STPG\s*(\d+)', lambda m: f'STPG {m.group(1)}'), + (r'STS\s*(\d+[A-Z]*)', lambda m: f'STS {m.group(1)}') + ] + + for pattern, formatter in pipe_patterns: + match = re.search(pattern, desc_upper) + if match: + return formatter(match) + + # FITTING 재질 패턴 + elif category == 'FITTING': + fitting_patterns = [ + (r'A403\s*(WP\d+[A-Z]*)', lambda m: f'A403 {m.group(1)}'), + (r'A234\s*(WP[A-Z]+)', lambda m: f'A234 {m.group(1)}'), + (r'A420\s*(WPL\d+)', lambda m: f'A420 {m.group(1)}'), + (r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존 + (r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N + (r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우 + (r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B 전체 보존 + (r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B + (r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'), + (r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}') + ] + + for pattern, formatter in fitting_patterns: + match = re.search(pattern, desc_upper) + if match: + return formatter(match) + + # FLANGE 재질 패턴 + elif category == 'FLANGE': + flange_patterns = [ + (r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'), + (r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존 + (r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N + (r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우 + (r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}'), + (r'A694\s*(F\d+)', lambda m: f'A694 {m.group(1)}') + ] + + for pattern, formatter in flange_patterns: + match = re.search(pattern, desc_upper) + if match: + return formatter(match) + + # 패턴이 매치되지 않으면 기존 값 반환 + return original_grade or '-' + +def extract_enhanced_flange_type(description: str, original_type: str) -> str: + """ + FLANGE 타입에 PIPE측 연결면 정보 추가 + + Args: + description: 원본 설명 + original_type: 기존 플랜지 타입 + + Returns: + 개선된 플랜지 타입 (예: WN RF, SO FF 등) + """ + if not description: + return original_type or '-' + + desc_upper = description.upper() + + # 기본 플랜지 타입 매핑 + flange_type_map = { + 'WELD_NECK': 'WN', + 'SLIP_ON': 'SO', + 'BLIND': 'BL', + 'SOCKET_WELD': 'SW', + 'LAP_JOINT': 'LJ', + 'THREADED': 'TH', + 'ORIFICE': 'ORIFICE' + } + + display_type = flange_type_map.get(original_type, original_type) if original_type else '-' + + # PIPE측 연결면 타입 추출 + pipe_end_type = '' + if ' RF' in desc_upper or 'RAISED FACE' in desc_upper: + pipe_end_type = ' RF' + elif ' FF' in desc_upper or 'FLAT FACE' in desc_upper: + pipe_end_type = ' FF' + elif ' RTJ' in desc_upper or 'RING TYPE JOINT' in desc_upper: + pipe_end_type = ' RTJ' + elif ' MSF' in desc_upper or 'MALE AND FEMALE' in desc_upper: + pipe_end_type = ' MSF' + elif ' T&G' in desc_upper or 'TONGUE AND GROOVE' in desc_upper: + pipe_end_type = ' T&G' + + # 최종 타입 조합 + if pipe_end_type and display_type != '-': + return display_type + pipe_end_type + + return display_type + UPLOAD_DIR = Path("uploads") UPLOAD_DIR.mkdir(exist_ok=True) ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"} @@ -108,7 +232,8 @@ def parse_dataframe(df): material_grade = "" if "ASTM" in description.upper(): # ASTM 표준과 등급만 추출, end_preparation(BOE, POE, BBE 등)은 제외 - astm_match = re.search(r'ASTM\s+([A-Z0-9]+(?:\s+GR\s+[A-Z0-9]+)?)', description.upper()) + # A\d{3,4} 패턴으로 3-4자리 숫자 보장, 등급도 포함 + astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description.upper()) if astm_match: material_grade = astm_match.group(0).strip() @@ -511,6 +636,9 @@ async def upload_file( classification_result = classify_gasket("", description, main_nom or "") elif material_type == "INSTRUMENT": classification_result = classify_instrument("", description, main_nom or "") + elif material_type == "SUPPORT": + from ..services.support_classifier import classify_support + classification_result = classify_support("", description, main_nom or "") else: # UNKNOWN 처리 classification_result = { @@ -528,16 +656,22 @@ async def upload_file( print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") + # 전체 재질명 추출 + from ..services.material_grade_extractor import extract_full_material_grade + full_material_grade = extract_full_material_grade(description) + if not full_material_grade and material_data.get("material_grade"): + full_material_grade = material_data["material_grade"] + # 기본 자재 정보 저장 material_insert_query = text(""" INSERT INTO materials ( file_id, original_description, quantity, unit, size_spec, - main_nom, red_nom, material_grade, line_number, row_number, + main_nom, red_nom, material_grade, full_material_grade, line_number, row_number, classified_category, classification_confidence, is_verified, created_at ) VALUES ( :file_id, :original_description, :quantity, :unit, :size_spec, - :main_nom, :red_nom, :material_grade, :line_number, :row_number, + :main_nom, :red_nom, :material_grade, :full_material_grade, :line_number, :row_number, :classified_category, :classification_confidence, :is_verified, :created_at ) RETURNING id @@ -559,6 +693,7 @@ async def upload_file( "main_nom": material_data.get("main_nom"), # 추가 "red_nom": material_data.get("red_nom"), # 추가 "material_grade": material_data["material_grade"], + "full_material_grade": full_material_grade, "line_number": material_data["line_number"], "row_number": material_data["row_number"], "classified_category": classification_result.get("category", "UNKNOWN"), @@ -1040,6 +1175,79 @@ async def upload_file( print(f"BOLT 상세 정보 저장 완료: {bolt_type} - {material_standard} {material_grade}") + # SUPPORT 분류 결과인 경우 상세 정보 저장 + if classification_result.get("category") == "SUPPORT": + print("SUPPORT 상세 정보 저장 시작") + + support_type = classification_result.get("support_type", "UNKNOWN") + support_subtype = classification_result.get("support_subtype", "") + load_rating = classification_result.get("load_rating", "") + load_capacity = classification_result.get("load_capacity", "") + + # 재질 정보 + material_info = classification_result.get("material", {}) + material_standard = material_info.get("standard", "UNKNOWN") + material_grade = material_info.get("grade", "UNKNOWN") + + # 사이즈 정보 + size_info = classification_result.get("size_info", {}) + pipe_size = size_info.get("pipe_size", "") + dimensions = size_info.get("dimensions", {}) + + length_mm = None + width_mm = None + height_mm = None + + if dimensions: + length_str = dimensions.get("length", "") + width_str = dimensions.get("width", "") + height_str = dimensions.get("height", "") + + # mm 단위 추출 + import re + if length_str: + length_match = re.search(r'(\d+(?:\.\d+)?)', length_str) + if length_match: + length_mm = float(length_match.group(1)) + + if width_str: + width_match = re.search(r'(\d+(?:\.\d+)?)', width_str) + if width_match: + width_mm = float(width_match.group(1)) + + if height_str: + height_match = re.search(r'(\d+(?:\.\d+)?)', height_str) + if height_match: + height_mm = float(height_match.group(1)) + + db.execute(text(""" + INSERT INTO support_details ( + material_id, file_id, support_type, support_subtype, + load_rating, load_capacity, material_standard, material_grade, + pipe_size, length_mm, width_mm, height_mm, classification_confidence + ) VALUES ( + :material_id, :file_id, :support_type, :support_subtype, + :load_rating, :load_capacity, :material_standard, :material_grade, + :pipe_size, :length_mm, :width_mm, :height_mm, :classification_confidence + ) + """), { + "material_id": material_id, + "file_id": file_id, + "support_type": support_type, + "support_subtype": support_subtype, + "load_rating": load_rating, + "load_capacity": load_capacity, + "material_standard": material_standard, + "material_grade": material_grade, + "pipe_size": pipe_size, + "length_mm": length_mm, + "width_mm": width_mm, + "height_mm": height_mm, + "classification_confidence": classification_result.get("overall_confidence", 0.0) + }) + + print(f"SUPPORT 상세 정보 저장 완료: {support_type} - {material_standard} {material_grade}") + # VALVE 분류 결과인 경우 상세 정보 저장 if classification_result.get("category") == "VALVE": print("VALVE 상세 정보 저장 시작") @@ -1331,7 +1539,7 @@ async def get_materials( # 로그 제거 - 과도한 출력 방지 query = """ SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit, - m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.line_number, m.row_number, + m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.full_material_grade, m.line_number, m.row_number, m.created_at, m.classified_category, m.classification_confidence, m.classification_details, m.is_verified, m.verified_by, m.verified_at, @@ -1503,6 +1711,14 @@ async def get_materials( # 로그 제거 pass + # 개선된 재질 정보 추출 + final_category = m.final_classified_category or m.classified_category + enhanced_material_grade = extract_enhanced_material_grade( + m.original_description, + m.material_grade, + final_category + ) + material_dict = { "id": m.id, "file_id": m.file_id, @@ -1516,11 +1732,13 @@ async def get_materials( "size_spec": m.size_spec, "main_nom": m.main_nom, # 추가 "red_nom": m.red_nom, # 추가 - "material_grade": m.material_grade, + "material_grade": m.full_material_grade or enhanced_material_grade, # 전체 재질명 우선 사용 + "original_material_grade": m.material_grade, # 원본 재질 정보도 보존 + "full_material_grade": m.full_material_grade, # 전체 재질명 "line_number": m.line_number, "row_number": m.row_number, # 구매수량 계산에서 분류된 정보를 우선 사용 - "classified_category": m.final_classified_category or m.classified_category, + "classified_category": final_category, "classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0, "classification_details": m.classification_details, "is_verified": m.final_is_verified if m.final_is_verified is not None else m.is_verified, @@ -1664,8 +1882,15 @@ async def get_materials( flange_result = db.execute(flange_query, {"material_id": m.id}) flange_detail = flange_result.fetchone() if flange_detail: + # 개선된 플랜지 타입 (PIPE측 연결면 포함) + enhanced_flange_type = extract_enhanced_flange_type( + m.original_description, + flange_detail.flange_type + ) + material_dict['flange_details'] = { - "flange_type": flange_detail.flange_type, + "flange_type": enhanced_flange_type, # 개선된 타입 사용 + "original_flange_type": flange_detail.flange_type, # 원본 타입 보존 "facing_type": flange_detail.facing_type, "pressure_rating": flange_detail.pressure_rating, "material_standard": flange_detail.material_standard, @@ -2431,6 +2656,7 @@ async def create_user_requirement( file_id: int, requirement_type: str, requirement_title: str, + material_id: Optional[int] = None, requirement_description: Optional[str] = None, requirement_spec: Optional[str] = None, priority: str = "NORMAL", @@ -2444,11 +2670,11 @@ async def create_user_requirement( try: insert_query = text(""" INSERT INTO user_requirements ( - file_id, requirement_type, requirement_title, requirement_description, + file_id, material_id, requirement_type, requirement_title, requirement_description, requirement_spec, priority, assigned_to, due_date ) VALUES ( - :file_id, :requirement_type, :requirement_title, :requirement_description, + :file_id, :material_id, :requirement_type, :requirement_title, :requirement_description, :requirement_spec, :priority, :assigned_to, :due_date ) RETURNING id @@ -2456,6 +2682,7 @@ async def create_user_requirement( result = db.execute(insert_query, { "file_id": file_id, + "material_id": material_id, "requirement_type": requirement_type, "requirement_title": requirement_title, "requirement_description": requirement_description, @@ -2478,6 +2705,41 @@ async def create_user_requirement( db.rollback() raise HTTPException(status_code=500, detail=f"요구사항 생성 실패: {str(e)}") +@router.delete("/user-requirements") +async def delete_user_requirements( + file_id: Optional[int] = None, + material_id: Optional[int] = None, + db: Session = Depends(get_db) +): + """ + 사용자 요구사항 삭제 (파일별 또는 자재별) + """ + try: + if file_id: + # 파일별 삭제 + delete_query = text("DELETE FROM user_requirements WHERE file_id = :file_id") + result = db.execute(delete_query, {"file_id": file_id}) + deleted_count = result.rowcount + elif material_id: + # 자재별 삭제 + delete_query = text("DELETE FROM user_requirements WHERE material_id = :material_id") + result = db.execute(delete_query, {"material_id": material_id}) + deleted_count = result.rowcount + else: + raise HTTPException(status_code=400, detail="file_id 또는 material_id가 필요합니다") + + db.commit() + + return { + "success": True, + "message": f"{deleted_count}개의 요구사항이 삭제되었습니다", + "deleted_count": deleted_count + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"요구사항 삭제 실패: {str(e)}") + @router.post("/materials/{material_id}/verify") async def verify_material_classification( material_id: int, diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py index 547ca97..cfa9ad9 100644 --- a/backend/app/services/integrated_classifier.py +++ b/backend/app/services/integrated_classifier.py @@ -8,13 +8,14 @@ from typing import Dict, List, Optional, Tuple # Level 1: 명확한 타입 키워드 (최우선) LEVEL1_TYPE_KEYWORDS = { - "BOLT": ["FLANGE BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔"], + "BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"], "VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "밸브", "게이트", "볼", "글로브", "체크", "버터플라이", "니들", "릴리프"], "FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND"], "PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"], "FITTING": ["ELBOW", "ELL", "TEE", "REDUCER", "RED", "CAP", "COUPLING", "NIPPLE", "SWAGE", "OLET", "PLUG", "엘보", "티", "리듀서", "캡", "니플", "커플링", "플러그", "CONC", "ECC", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREADOLET"], "GASKET": ["GASKET", "GASK", "가스켓", "SWG", "SPIRAL"], - "INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"] + "INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"], + "SUPPORT": ["URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링"] } # Level 2: 서브타입 키워드 (구체화) @@ -33,7 +34,14 @@ LEVEL2_SUBTYPE_KEYWORDS = { }, "BOLT": { "HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"], - "STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"] + "STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"], + "U_BOLT": ["U-BOLT", "U BOLT", "유볼트"] + }, + "SUPPORT": { + "URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"], + "CLAMP": ["CLAMP", "클램프"], + "HANGER": ["HANGER", "SUPPORT", "행거", "서포트"], + "SPRING": ["SPRING", "스프링"] } } @@ -119,8 +127,8 @@ def classify_material_integrated(description: str, main_nom: str = "", } # Level 2 키워드가 없으면 우선순위로 결정 - # BOLT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선) - type_priority = ["BOLT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"] + # BOLT > SUPPORT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선) + type_priority = ["BOLT", "SUPPORT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"] for priority_type in type_priority: for detected_type, keyword in detected_types: if detected_type == priority_type: diff --git a/backend/app/services/material_grade_extractor.py b/backend/app/services/material_grade_extractor.py new file mode 100644 index 0000000..daddcf8 --- /dev/null +++ b/backend/app/services/material_grade_extractor.py @@ -0,0 +1,247 @@ +""" +전체 재질명 추출기 +원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공 +""" + +import re +from typing import Optional, Dict + +def extract_full_material_grade(description: str) -> str: + """ + 원본 설명에서 전체 재질명 추출 + + Args: + description: 원본 자재 설명 + + Returns: + 전체 재질명 (예: "ASTM A312 TP304", "ASTM A106 GR B") + """ + if not description: + return "" + + desc_upper = description.upper().strip() + + # 1. ASTM 규격 패턴들 (가장 구체적인 것부터) + astm_patterns = [ + # ASTM A312 TP304, ASTM A312 TP316L 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*', + # ASTM A182 F304, ASTM A182 F316L 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+F\d+[A-Z]*', + # ASTM A403 WP304, ASTM A234 WPB 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+WP[A-Z0-9]+', + # ASTM A351 CF8M, ASTM A216 WCB 등 + r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*', + # ASTM A106 GR B, ASTM A105 등 - GR 포함 + r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9]+', + r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9]+', + # ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급 + r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)', + # ASTM A105, ASTM A234 등 (등급 없는 경우) + r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])', + # 2자리 ASTM 규격도 지원 (A10, A36 등) + r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9]+)?', + ] + + for pattern in astm_patterns: + match = re.search(pattern, desc_upper) + if match: + full_grade = match.group(0).strip() + # 추가 정보가 있는지 확인 (PBE, BBE 등은 제외) + end_pos = match.end() + remaining = desc_upper[end_pos:].strip() + + # 끝단 가공 정보는 제외 + end_prep_codes = ['PBE', 'BBE', 'POE', 'BOE', 'TOE'] + for code in end_prep_codes: + remaining = re.sub(rf'\b{code}\b', '', remaining).strip() + + # 남은 재질 관련 정보가 있으면 추가 + additional_info = [] + if remaining: + # 일반적인 재질 추가 정보 패턴 + additional_patterns = [ + r'\bH\b', # H (고온용) + r'\bL\b', # L (저탄소) + r'\bN\b', # N (질소 첨가) + r'\bS\b', # S (황 첨가) + r'\bMOD\b', # MOD (개량형) + ] + + for add_pattern in additional_patterns: + if re.search(add_pattern, remaining): + additional_info.append(re.search(add_pattern, remaining).group(0)) + + if additional_info: + full_grade += ' ' + ' '.join(additional_info) + + return full_grade + + # 2. ASME 규격 패턴들 + asme_patterns = [ + r'ASME\s+SA\d+[A-Z]*\s+TP\d+[A-Z]*(?:\s+[A-Z]+)*', + r'ASME\s+SA\d+[A-Z]*\s+GR\s+[A-Z0-9]+(?:\s+[A-Z]+)*', + r'ASME\s+SA\d+[A-Z]*\s+F\d+[A-Z]*(?:\s+[A-Z]+)*', + r'ASME\s+SA\d+[A-Z]*(?!\s+[A-Z0-9])', + ] + + for pattern in asme_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 3. KS 규격 패턴들 + ks_patterns = [ + r'KS\s+D\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*', + r'KS\s+D\d+[A-Z]*(?!\s+[A-Z0-9])', + ] + + for pattern in ks_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 4. JIS 규격 패턴들 + jis_patterns = [ + r'JIS\s+[A-Z]\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*', + r'JIS\s+[A-Z]\d+[A-Z]*(?!\s+[A-Z0-9])', + ] + + for pattern in jis_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 5. 특수 재질 패턴들 + special_patterns = [ + # Inconel, Hastelloy 등 + r'INCONEL\s+\d+[A-Z]*', + r'HASTELLOY\s+[A-Z]\d*[A-Z]*', + r'MONEL\s+\d+[A-Z]*', + # Titanium + r'TITANIUM\s+GRADE\s+\d+[A-Z]*', + r'TI\s+GR\s*\d+[A-Z]*', + # 듀플렉스 스테인리스 + r'DUPLEX\s+\d+[A-Z]*', + r'SUPER\s+DUPLEX\s+\d+[A-Z]*', + ] + + for pattern in special_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 6. 일반 스테인리스 패턴들 (숫자만) + stainless_patterns = [ + r'\b(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b', + r'\bSS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b', + r'\bSUS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b', + ] + + for pattern in stainless_patterns: + match = re.search(pattern, desc_upper) + if match: + grade = match.group(1) if match.groups() else match.group(0) + if grade.startswith(('SS', 'SUS')): + return grade + else: + return f"SS{grade}" + + # 7. 탄소강 패턴들 + carbon_patterns = [ + r'\bSM\d+[A-Z]*\b', # SM400, SM490 등 + r'\bSS\d+[A-Z]*\b', # SS400, SS490 등 (스테인리스와 구분) + r'\bS\d+C\b', # S45C, S50C 등 + ] + + for pattern in carbon_patterns: + match = re.search(pattern, desc_upper) + if match: + return match.group(0).strip() + + # 8. 기존 material_grade가 있으면 그대로 반환 + # (분류기에서 이미 처리된 경우) + return "" + +def update_full_material_grades(db, batch_size: int = 1000) -> Dict: + """ + 기존 자재들의 full_material_grade 업데이트 + + Args: + db: 데이터베이스 세션 + batch_size: 배치 처리 크기 + + Returns: + 업데이트 결과 통계 + """ + from sqlalchemy import text + + try: + # 전체 자재 수 조회 + count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''") + total_count = db.execute(count_query).scalar() + + print(f"📊 업데이트 대상 자재: {total_count}개") + + updated_count = 0 + processed_count = 0 + + # 배치 단위로 처리 + offset = 0 + while offset < total_count: + # 배치 조회 + select_query = text(""" + SELECT id, original_description, material_grade + FROM materials + WHERE full_material_grade IS NULL OR full_material_grade = '' + ORDER BY id + LIMIT :limit OFFSET :offset + """) + + results = db.execute(select_query, {"limit": batch_size, "offset": offset}).fetchall() + + if not results: + break + + # 배치 업데이트 + for material_id, original_description, current_grade in results: + full_grade = extract_full_material_grade(original_description) + + # 전체 재질명이 추출되지 않으면 기존 grade 사용 + if not full_grade and current_grade: + full_grade = current_grade + + if full_grade: + update_query = text(""" + UPDATE materials + SET full_material_grade = :full_grade + WHERE id = :material_id + """) + db.execute(update_query, { + "full_grade": full_grade, + "material_id": material_id + }) + updated_count += 1 + + processed_count += 1 + + # 배치 커밋 + db.commit() + offset += batch_size + + print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)") + + return { + "total_processed": processed_count, + "updated_count": updated_count, + "success": True + } + + except Exception as e: + db.rollback() + print(f"❌ 업데이트 실패: {str(e)}") + return { + "total_processed": 0, + "updated_count": 0, + "success": False, + "error": str(e) + } diff --git a/backend/app/services/revision_comparator.py b/backend/app/services/revision_comparator.py index a89c010..f63849d 100644 --- a/backend/app/services/revision_comparator.py +++ b/backend/app/services/revision_comparator.py @@ -287,3 +287,8 @@ def get_revision_comparison(db: Session, job_no: str, current_revision: str, + + + + + diff --git a/backend/app/services/support_classifier.py b/backend/app/services/support_classifier.py new file mode 100644 index 0000000..f7f9dc3 --- /dev/null +++ b/backend/app/services/support_classifier.py @@ -0,0 +1,283 @@ +""" +SUPPORT 분류 시스템 +배관 지지재, 우레탄 블록, 클램프 등 지지 부품 분류 +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material + +# ========== 서포트 타입별 분류 ========== +SUPPORT_TYPES = { + "URETHANE_BLOCK": { + "dat_file_patterns": ["URETHANE", "BLOCK", "SHOE"], + "description_keywords": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록", "우레탄", "URETHANE"], + "characteristics": "우레탄 블록 슈", + "applications": "배관 지지, 진동 흡수", + "material_type": "URETHANE" + }, + + "CLAMP": { + "dat_file_patterns": ["CLAMP", "CL-"], + "description_keywords": ["CLAMP", "클램프", "CL-1", "CL-2", "CL-3"], + "characteristics": "배관 클램프", + "applications": "배관 고정, 지지", + "material_type": "STEEL" + }, + + "HANGER": { + "dat_file_patterns": ["HANGER", "HANG", "SUPP"], + "description_keywords": ["HANGER", "SUPPORT", "행거", "서포트", "PIPE HANGER"], + "characteristics": "배관 행거", + "applications": "배관 매달기, 지지", + "material_type": "STEEL" + }, + + "SPRING_HANGER": { + "dat_file_patterns": ["SPRING", "SPR_"], + "description_keywords": ["SPRING HANGER", "SPRING", "스프링", "스프링 행거"], + "characteristics": "스프링 행거", + "applications": "가변 하중 지지", + "material_type": "STEEL" + }, + + "GUIDE": { + "dat_file_patterns": ["GUIDE", "GD_"], + "description_keywords": ["GUIDE", "가이드", "PIPE GUIDE"], + "characteristics": "배관 가이드", + "applications": "배관 방향 제어", + "material_type": "STEEL" + }, + + "ANCHOR": { + "dat_file_patterns": ["ANCHOR", "ANCH"], + "description_keywords": ["ANCHOR", "앵커", "PIPE ANCHOR"], + "characteristics": "배관 앵커", + "applications": "배관 고정점", + "material_type": "STEEL" + } +} + +# ========== 하중 등급 분류 ========== +LOAD_RATINGS = { + "LIGHT": { + "patterns": [r"(\d+)T", r"(\d+)TON"], + "range": (0, 5), # 5톤 이하 + "description": "경하중용" + }, + "MEDIUM": { + "patterns": [r"(\d+)T", r"(\d+)TON"], + "range": (5, 20), # 5-20톤 + "description": "중하중용" + }, + "HEAVY": { + "patterns": [r"(\d+)T", r"(\d+)TON"], + "range": (20, 100), # 20-100톤 + "description": "중하중용" + } +} + +def classify_support(dat_file: str, description: str, main_nom: str, + length: Optional[float] = None) -> Dict: + """ + SUPPORT 분류 메인 함수 + + Args: + dat_file: DAT 파일명 + description: 자재 설명 + main_nom: 주 사이즈 + length: 길이 (옵션) + + Returns: + 분류 결과 딕셔너리 + """ + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # 1. 서포트 타입 분류 + support_type_result = classify_support_type(dat_file, description) + + # 2. 재질 분류 (공통 모듈 사용) + material_result = classify_material(description) + + # 3. 하중 등급 분류 + load_result = classify_load_rating(description) + + # 4. 사이즈 정보 추출 + size_result = extract_support_size(description, main_nom) + + # 5. 최종 결과 조합 + return { + "category": "SUPPORT", + + # 서포트 특화 정보 + "support_type": support_type_result.get("support_type", "UNKNOWN"), + "support_subtype": support_type_result.get("subtype", ""), + "load_rating": load_result.get("load_rating", ""), + "load_capacity": load_result.get("capacity", ""), + + # 재질 정보 (공통 모듈) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # 사이즈 정보 + "size_info": size_result, + + # 전체 신뢰도 + "overall_confidence": calculate_support_confidence({ + "type": support_type_result.get('confidence', 0), + "material": material_result.get('confidence', 0), + "load": load_result.get('confidence', 0), + "size": size_result.get('confidence', 0) + }), + + # 증거 + "evidence": [ + f"SUPPORT_TYPE: {support_type_result.get('support_type', 'UNKNOWN')}", + f"MATERIAL: {material_result.get('standard', 'UNKNOWN')}", + f"LOAD: {load_result.get('load_rating', 'UNKNOWN')}" + ] + } + +def classify_support_type(dat_file: str, description: str) -> Dict: + """서포트 타입 분류""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + for support_type, type_data in SUPPORT_TYPES.items(): + # DAT 파일 패턴 확인 + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "support_type": support_type, + "subtype": type_data["characteristics"], + "applications": type_data["applications"], + "confidence": 0.95, + "evidence": [f"DAT_PATTERN: {pattern}"] + } + + # 설명 키워드 확인 + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "support_type": support_type, + "subtype": type_data["characteristics"], + "applications": type_data["applications"], + "confidence": 0.9, + "evidence": [f"DESC_KEYWORD: {keyword}"] + } + + return { + "support_type": "UNKNOWN", + "subtype": "", + "applications": "", + "confidence": 0.0, + "evidence": ["NO_SUPPORT_TYPE_FOUND"] + } + +def classify_load_rating(description: str) -> Dict: + """하중 등급 분류""" + + desc_upper = description.upper() + + # 하중 패턴 찾기 (40T, 50TON 등) + for rating, rating_data in LOAD_RATINGS.items(): + for pattern in rating_data["patterns"]: + match = re.search(pattern, desc_upper) + if match: + capacity = int(match.group(1)) + min_load, max_load = rating_data["range"] + + if min_load <= capacity <= max_load: + return { + "load_rating": rating, + "capacity": f"{capacity}T", + "description": rating_data["description"], + "confidence": 0.9, + "evidence": [f"LOAD_PATTERN: {match.group(0)}"] + } + + # 특정 하중 값이 있지만 등급을 모르는 경우 + load_match = re.search(r'(\d+)\s*[T톤]', desc_upper) + if load_match: + capacity = int(load_match.group(1)) + return { + "load_rating": "CUSTOM", + "capacity": f"{capacity}T", + "description": f"{capacity}톤 하중", + "confidence": 0.7, + "evidence": [f"CUSTOM_LOAD: {load_match.group(0)}"] + } + + return { + "load_rating": "UNKNOWN", + "capacity": "", + "description": "", + "confidence": 0.0, + "evidence": ["NO_LOAD_RATING_FOUND"] + } + +def extract_support_size(description: str, main_nom: str) -> Dict: + """서포트 사이즈 정보 추출""" + + desc_upper = description.upper() + + # 파이프 사이즈 (서포트가 지지하는 파이프 크기) + pipe_size = main_nom if main_nom else "" + + # 서포트 자체 치수 (길이x폭x높이 등) + dimension_patterns = [ + r'(\d+)\s*[X×]\s*(\d+)\s*[X×]\s*(\d+)', # 100x50x20 + r'(\d+)\s*[X×]\s*(\d+)', # 100x50 + r'L\s*(\d+)', # L100 (길이) + r'W\s*(\d+)', # W50 (폭) + r'H\s*(\d+)' # H20 (높이) + ] + + dimensions = {} + for pattern in dimension_patterns: + match = re.search(pattern, desc_upper) + if match: + if len(match.groups()) == 3: + dimensions = { + "length": f"{match.group(1)}mm", + "width": f"{match.group(2)}mm", + "height": f"{match.group(3)}mm" + } + elif len(match.groups()) == 2: + dimensions = { + "length": f"{match.group(1)}mm", + "width": f"{match.group(2)}mm" + } + break + + return { + "pipe_size": pipe_size, + "dimensions": dimensions, + "confidence": 0.8 if dimensions else 0.3 + } + +def calculate_support_confidence(confidence_scores: Dict) -> float: + """서포트 분류 전체 신뢰도 계산""" + + weights = { + "type": 0.4, # 타입이 가장 중요 + "material": 0.2, # 재질 + "load": 0.2, # 하중 + "size": 0.2 # 사이즈 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) diff --git a/backend/scripts/18_create_auth_tables.sql b/backend/scripts/18_create_auth_tables.sql index 54f5cb9..d0ec5f3 100644 --- a/backend/scripts/18_create_auth_tables.sql +++ b/backend/scripts/18_create_auth_tables.sql @@ -233,4 +233,9 @@ END $$; + + + + + diff --git a/backend/scripts/21_add_material_id_to_user_requirements.sql b/backend/scripts/21_add_material_id_to_user_requirements.sql new file mode 100644 index 0000000..8445bcc --- /dev/null +++ b/backend/scripts/21_add_material_id_to_user_requirements.sql @@ -0,0 +1,19 @@ +-- 사용자 요구사항 테이블에 material_id 컬럼 추가 +-- 2025.09.24 - 사용자 피드백 기반 개선사항 #1 + +-- material_id 컬럼 추가 (nullable로 시작) +ALTER TABLE user_requirements +ADD COLUMN IF NOT EXISTS material_id INTEGER; + +-- 외래키 제약조건 추가 +ALTER TABLE user_requirements +ADD CONSTRAINT fk_user_requirements_material_id +FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE; + +-- 인덱스 추가 (성능 향상) +CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_id); + +-- 기존 데이터 정리 (필요시) +-- DELETE FROM user_requirements WHERE material_id IS NULL; + +COMMENT ON COLUMN user_requirements.material_id IS '자재 ID (개별 자재별 요구사항 연결)'; diff --git a/backend/scripts/22_add_full_material_grade.sql b/backend/scripts/22_add_full_material_grade.sql new file mode 100644 index 0000000..2f9374a --- /dev/null +++ b/backend/scripts/22_add_full_material_grade.sql @@ -0,0 +1,11 @@ +-- 전체 재질명 표기를 위한 컬럼 추가 +-- 2025.09.24 - 사용자 피드백 기반 개선사항 #2 + +-- full_material_grade 컬럼 추가 (원본 설명에서 추출한 전체 재질명) +ALTER TABLE materials +ADD COLUMN IF NOT EXISTS full_material_grade TEXT; + +-- 인덱스 추가 (검색 성능 향상) +CREATE INDEX IF NOT EXISTS idx_materials_full_material_grade ON materials(full_material_grade); + +COMMENT ON COLUMN materials.full_material_grade IS '전체 재질명 (예: ASTM A312 TP304, ASTM A106 GR B 등)'; diff --git a/backend/scripts/23_create_support_details_table.sql b/backend/scripts/23_create_support_details_table.sql new file mode 100644 index 0000000..9bc05c5 --- /dev/null +++ b/backend/scripts/23_create_support_details_table.sql @@ -0,0 +1,45 @@ +-- SUPPORT 카테고리 상세 정보 테이블 생성 +-- 2025.09.24 - 사용자 피드백 기반 개선사항 #3 + +-- support_details 테이블 생성 +CREATE TABLE IF NOT EXISTS support_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + + -- 서포트 타입 정보 + support_type VARCHAR(50), -- URETHANE_BLOCK, CLAMP, HANGER, SPRING_HANGER 등 + support_subtype VARCHAR(100), -- 상세 타입 + + -- 하중 정보 + load_rating VARCHAR(20), -- LIGHT, MEDIUM, HEAVY, CUSTOM + load_capacity VARCHAR(20), -- 40T, 50TON 등 + + -- 재질 정보 + material_standard VARCHAR(50), -- 재질 표준 + material_grade VARCHAR(100), -- 재질 등급 + + -- 사이즈 정보 + pipe_size VARCHAR(20), -- 지지하는 파이프 크기 + length_mm DECIMAL(10,2), -- 길이 (mm) + width_mm DECIMAL(10,2), -- 폭 (mm) + height_mm DECIMAL(10,2), -- 높이 (mm) + + -- 분류 신뢰도 + classification_confidence DECIMAL(3,2), -- 0.00-1.00 + + -- 메타데이터 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_support_details_material_id ON support_details(material_id); +CREATE INDEX IF NOT EXISTS idx_support_details_file_id ON support_details(file_id); +CREATE INDEX IF NOT EXISTS idx_support_details_support_type ON support_details(support_type); + +-- 코멘트 추가 +COMMENT ON TABLE support_details IS '배관 지지재 상세 정보 (우레탄 블록, 클램프, 행거 등)'; +COMMENT ON COLUMN support_details.support_type IS '서포트 타입 (URETHANE_BLOCK, CLAMP, HANGER 등)'; +COMMENT ON COLUMN support_details.load_capacity IS '하중 용량 (40T, 50TON 등)'; +COMMENT ON COLUMN support_details.pipe_size IS '지지하는 파이프 크기'; diff --git a/database/init/20_purchase_confirmations.sql b/database/init/20_purchase_confirmations.sql index 1084721..df22cb8 100644 --- a/database/init/20_purchase_confirmations.sql +++ b/database/init/20_purchase_confirmations.sql @@ -70,3 +70,8 @@ COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자'; + + + + + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..f7faca0 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +# 개발 모드용 Docker Compose 오버라이드 +version: '3.8' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + args: + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} + ports: + - "${FRONTEND_PORT:-13000}:5173" + volumes: + - ./frontend/src:/app/src + - ./frontend/public:/app/public + - ./frontend/index.html:/app/index.html + - ./frontend/vite.config.js:/app/vite.config.js + environment: + - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} + command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..d32e08e --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,24 @@ +# Node.js 18 베이스 이미지 사용 +FROM node:18-alpine + +# 작업 디렉토리 설정 +WORKDIR /app + +# 빌드 인자 받기 +ARG VITE_API_URL=http://localhost:8000 +ENV VITE_API_URL=$VITE_API_URL + +# package.json과 package-lock.json 복사 +COPY package*.json ./ + +# 의존성 설치 +RUN npm ci --force + +# 소스 코드 복사 +COPY . . + +# 포트 5173 노출 (Vite 개발 서버) +EXPOSE 5173 + +# 개발 서버 실행 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/src/SimpleDashboard.jsx b/frontend/src/SimpleDashboard.jsx index 3045ead..2f66805 100644 --- a/frontend/src/SimpleDashboard.jsx +++ b/frontend/src/SimpleDashboard.jsx @@ -233,4 +233,9 @@ export default SimpleDashboard; + + + + + diff --git a/frontend/src/components/NavigationBar.css b/frontend/src/components/NavigationBar.css index faca30c..fc0e152 100644 --- a/frontend/src/components/NavigationBar.css +++ b/frontend/src/components/NavigationBar.css @@ -550,4 +550,9 @@ + + + + + diff --git a/frontend/src/components/NavigationBar.jsx b/frontend/src/components/NavigationBar.jsx index 7d3f33b..497d7cd 100644 --- a/frontend/src/components/NavigationBar.jsx +++ b/frontend/src/components/NavigationBar.jsx @@ -283,4 +283,9 @@ export default NavigationBar; + + + + + diff --git a/frontend/src/components/NavigationMenu.css b/frontend/src/components/NavigationMenu.css index 404ffd6..158ad5e 100644 --- a/frontend/src/components/NavigationMenu.css +++ b/frontend/src/components/NavigationMenu.css @@ -263,4 +263,9 @@ + + + + + diff --git a/frontend/src/components/NavigationMenu.jsx b/frontend/src/components/NavigationMenu.jsx index 97a37e1..13bfd18 100644 --- a/frontend/src/components/NavigationMenu.jsx +++ b/frontend/src/components/NavigationMenu.jsx @@ -187,4 +187,9 @@ export default NavigationMenu; + + + + + diff --git a/frontend/src/components/RevisionUploadDialog.jsx b/frontend/src/components/RevisionUploadDialog.jsx index 82ba4d7..a6a6891 100644 --- a/frontend/src/components/RevisionUploadDialog.jsx +++ b/frontend/src/components/RevisionUploadDialog.jsx @@ -95,4 +95,9 @@ export default RevisionUploadDialog; + + + + + diff --git a/frontend/src/components/SimpleFileUpload.jsx b/frontend/src/components/SimpleFileUpload.jsx index 54d5b48..3c606f0 100644 --- a/frontend/src/components/SimpleFileUpload.jsx +++ b/frontend/src/components/SimpleFileUpload.jsx @@ -314,4 +314,9 @@ export default SimpleFileUpload; + + + + + diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 0df5d58..b1725ab 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -277,4 +277,9 @@ export default DashboardPage; + + + + + diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css index 237da30..ed04cc2 100644 --- a/frontend/src/pages/LoginPage.css +++ b/frontend/src/pages/LoginPage.css @@ -232,4 +232,9 @@ + + + + + diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index a22362c..cbf19cf 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -129,4 +129,9 @@ export default LoginPage; + + + + + diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css index 310af55..2681c2b 100644 --- a/frontend/src/pages/NewMaterialsPage.css +++ b/frontend/src/pages/NewMaterialsPage.css @@ -8,6 +8,7 @@ background: #f8f9fa; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif; + overflow-x: auto; } /* 헤더 */ @@ -259,11 +260,13 @@ .materials-grid { background: white; margin: 0; + min-width: 1500px; + overflow-x: auto; } .detailed-grid-header { display: grid; - grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px; padding: 12px 24px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; @@ -276,42 +279,42 @@ /* 플랜지 전용 헤더 - 10개 컬럼 */ .detailed-grid-header.flange-header { - grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 80px 150px 80px 100px 80px 350px 100px 150px 100px; } /* 플랜지 전용 행 - 10개 컬럼 */ .detailed-material-row.flange-row { - grid-template-columns: 40px 80px 80px 80px 100px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 80px 150px 80px 100px 80px 350px 100px 150px 100px; } /* 피팅 전용 헤더 - 10개 컬럼 */ .detailed-grid-header.fitting-header { - grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px; } /* 피팅 전용 행 - 10개 컬럼 */ .detailed-material-row.fitting-row { - grid-template-columns: 40px 80px 150px 80px 80px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px; } /* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */ .detailed-grid-header.valve-header { - grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px; } /* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */ .detailed-material-row.valve-row { - grid-template-columns: 40px 120px 100px 80px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px; } /* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */ .detailed-grid-header.gasket-header { - grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px; + grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px; } /* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */ .detailed-material-row.gasket-row { - grid-template-columns: 40px 80px 60px 80px 80px 100px 180px 60px 80px 150px 100px; + grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px; } /* UNKNOWN 전용 헤더 - 5개 컬럼 */ @@ -326,21 +329,23 @@ /* UNKNOWN 설명 셀 스타일 */ .description-cell { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: visible; + text-overflow: initial; + white-space: normal; + word-break: break-word; } .description-text { display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: visible; + text-overflow: initial; + white-space: normal; + word-break: break-word; } .detailed-material-row { display: grid; - grid-template-columns: 40px 80px 120px 80px 80px 140px 100px 150px 100px; + grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px; padding: 12px 24px; border-bottom: 1px solid #f3f4f6; align-items: center; @@ -357,10 +362,13 @@ } .material-cell { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: visible !important; + text-overflow: initial !important; + white-space: normal !important; padding-right: 12px; + word-break: break-word; + min-width: 120px; + max-width: none !important; } .material-cell input[type="checkbox"] { @@ -431,6 +439,11 @@ .material-grade { color: #1f2937; font-weight: 500; + white-space: normal !important; + word-break: break-word !important; + overflow: visible !important; + text-overflow: initial !important; + min-width: 300px !important; } /* 입력 필드 */ diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx index d9925f5..bc00031 100644 --- a/frontend/src/pages/NewMaterialsPage.jsx +++ b/frontend/src/pages/NewMaterialsPage.jsx @@ -3,6 +3,7 @@ import { fetchMaterials } from '../api'; import { exportMaterialsToExcel } from '../utils/excelExport'; import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'; +import api from '../api'; import './NewMaterialsPage.css'; const NewMaterialsPage = ({ @@ -21,6 +22,10 @@ const NewMaterialsPage = ({ const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple' const [availableRevisions, setAvailableRevisions] = useState([]); const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0'); + + // 사용자 요구사항 상태 관리 + const [userRequirements, setUserRequirements] = useState({}); // materialId: requirement 형태 + const [savingRequirements, setSavingRequirements] = useState(false); // 같은 BOM의 다른 리비전들 조회 const loadAvailableRevisions = async () => { @@ -53,6 +58,7 @@ const NewMaterialsPage = ({ if (fileId) { loadMaterials(fileId); loadAvailableRevisions(); + loadUserRequirements(fileId); } }, [fileId]); @@ -86,6 +92,84 @@ const NewMaterialsPage = ({ } }; + // 사용자 요구사항 로드 + const loadUserRequirements = async (id) => { + try { + console.log('🔍 사용자 요구사항 로딩 중...', { file_id: id }); + + const response = await api.get('/files/user-requirements', { + params: { file_id: parseInt(id) } + }); + + if (response.data?.success && response.data?.requirements) { + const requirements = {}; + response.data.requirements.forEach(req => { + // material_id를 키로 사용하여 요구사항 저장 + if (req.material_id) { + requirements[req.material_id] = req.requirement_description || req.requirement_title || ''; + } + }); + setUserRequirements(requirements); + console.log('✅ 사용자 요구사항 로드 완료:', Object.keys(requirements).length, '개'); + } + } catch (error) { + console.error('❌ 사용자 요구사항 로딩 실패:', error); + setUserRequirements({}); + } + }; + + // 사용자 요구사항 저장 + const saveUserRequirements = async () => { + try { + setSavingRequirements(true); + console.log('💾 사용자 요구사항 저장 중...', userRequirements); + + // 요구사항이 있는 자재들만 저장 + const requirementsToSave = Object.entries(userRequirements) + .filter(([materialId, requirement]) => requirement && requirement.trim()) + .map(([materialId, requirement]) => ({ + material_id: parseInt(materialId), + file_id: parseInt(fileId), + requirement_type: 'CUSTOM_SPEC', + requirement_title: '사용자 요구사항', + requirement_description: requirement.trim(), + priority: 'NORMAL' + })); + + if (requirementsToSave.length === 0) { + alert('저장할 요구사항이 없습니다.'); + return; + } + + // 기존 요구사항 삭제 후 새로 저장 + await api.delete(`/files/user-requirements`, { + params: { file_id: parseInt(fileId) } + }); + + // 새 요구사항들 저장 + for (const req of requirementsToSave) { + await api.post('/files/user-requirements', req); + } + + alert(`${requirementsToSave.length}개의 사용자 요구사항이 저장되었습니다.`); + console.log('✅ 사용자 요구사항 저장 완료'); + + } catch (error) { + console.error('❌ 사용자 요구사항 저장 실패:', error); + alert('사용자 요구사항 저장에 실패했습니다: ' + (error.response?.data?.detail || error.message)); + } finally { + setSavingRequirements(false); + } + }; + + // 사용자 요구사항 입력 핸들러 + const handleUserRequirementChange = (materialId, value) => { + setUserRequirements(prev => ({ + ...prev, + [materialId]: value + })); + }; + // 카테고리별 자재 수 계산 const getCategoryCounts = () => { const counts = {}; @@ -126,12 +210,13 @@ const NewMaterialsPage = ({ if (category === 'PIPE') { const calc = calculatePipePurchase(material); + return { type: 'PIPE', subtype: material.pipe_details?.manufacturing_method || 'SMLS', size: material.size_spec || '-', schedule: material.pipe_details?.schedule || '-', - grade: material.material_grade || '-', + grade: material.full_material_grade || material.material_grade || '-', // 전체 재질명 우선 사용 quantity: calc.purchaseCount, unit: '본', details: calc @@ -224,7 +309,7 @@ const NewMaterialsPage = ({ size: material.size_spec || '-', pressure: pressure, schedule: schedule, - grade: material.material_grade || '-', + grade: material.full_material_grade || material.material_grade || '-', // 전체 재질명 우선 사용 quantity: Math.round(material.quantity || 0), unit: '개', isFitting: true @@ -280,25 +365,17 @@ const NewMaterialsPage = ({ isValve: true }; } else if (category === 'FLANGE') { - // 플랜지 타입 변환 - const flangeTypeMap = { - 'WELD_NECK': 'WN', - 'SLIP_ON': 'SO', - 'BLIND': 'BL', - 'SOCKET_WELD': 'SW', - 'LAP_JOINT': 'LJ', - 'THREADED': 'TH', - 'ORIFICE': 'ORIFICE' // 오리피스는 풀네임 표시 - }; - const flangeType = material.flange_details?.flange_type; - const displayType = flangeTypeMap[flangeType] || flangeType || '-'; + const description = material.original_description || ''; + + // 백엔드에서 개선된 플랜지 타입 제공 (WN RF, SO FF 등) + const displayType = material.flange_details?.flange_type || '-'; // 원본 설명에서 스케줄 추출 let schedule = '-'; - const description = material.original_description || ''; + const upperDesc = description.toUpperCase(); // SCH 40, SCH 80 등의 패턴 찾기 - if (description.toUpperCase().includes('SCH')) { + if (upperDesc.includes('SCH')) { const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); if (schMatch && schMatch[1]) { schedule = `SCH ${schMatch[1]}`; @@ -307,11 +384,11 @@ const NewMaterialsPage = ({ return { type: 'FLANGE', - subtype: displayType, + subtype: displayType, // 백엔드에서 개선된 타입 정보 제공 (WN RF, SO FF 등) size: material.size_spec || '-', pressure: material.flange_details?.pressure_rating || '-', schedule: schedule, - grade: material.material_grade || '-', + grade: material.full_material_grade || material.material_grade || '-', // 전체 재질명 우선 사용 quantity: Math.round(material.quantity || 0), unit: '개', isFlange: true // 플랜지 구분용 플래그 @@ -443,7 +520,7 @@ const NewMaterialsPage = ({ '스케줄': info.schedule, '재질': info.grade, '추가요구': '-', - '사용자요구': '', + '사용자요구': userRequirements[material.id] || '', '수량': `${info.quantity} ${info.unit}`, '상세': `단관 ${info.itemCount || 0}개 → ${info.totalLength || 0}mm` }; @@ -456,7 +533,7 @@ const NewMaterialsPage = ({ '스케줄': info.schedule, '재질': info.grade, '추가요구': '-', - '사용자요구': '', + '사용자요구': userRequirements[material.id] || '', '수량': `${info.quantity} ${info.unit}` }; } else if (selectedCategory === 'FITTING' && info.isFitting) { @@ -468,7 +545,7 @@ const NewMaterialsPage = ({ '스케줄': info.schedule, '재질': info.grade, '추가요구': '-', - '사용자요구': '', + '사용자요구': userRequirements[material.id] || '', '수량': `${info.quantity} ${info.unit}` }; } else if (selectedCategory === 'VALVE' && info.isValve) { @@ -479,7 +556,7 @@ const NewMaterialsPage = ({ '압력': info.pressure, '재질': info.grade, '추가요구': '-', - '사용자요구': '', + '사용자요구': userRequirements[material.id] || '', '수량': `${info.quantity} ${info.unit}` }; } else if (selectedCategory === 'GASKET' && info.isGasket) { @@ -503,7 +580,7 @@ const NewMaterialsPage = ({ '스케줄': info.schedule, '재질': info.grade, '추가요구': '-', - '사용자요구': '', + '사용자요구': userRequirements[material.id] || '', '수량': `${info.quantity} ${info.unit}` }; } else if (selectedCategory === 'UNKNOWN' && info.isUnknown) { @@ -522,7 +599,7 @@ const NewMaterialsPage = ({ '스케줄': info.schedule, '재질': info.grade, '추가요구': '-', - '사용자요구': '', + '사용자요구': userRequirements[material.id] || '', '수량': `${info.quantity} ${info.unit}` }; } @@ -648,6 +725,25 @@ const NewMaterialsPage = ({ > {selectedMaterials.size === filteredMaterials.length ? '전체 해제' : '전체 선택'} + +