From 50570e46241c3f51cf0c651013b7665d4806b659 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 30 Sep 2025 08:55:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD=20=EA=B8=B0=EB=8A=A5=20=EC=99=84?= =?UTF-8?q?=EC=A0=84=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 요구사항 저장/로드/엑셀 내보내기 기능 완전 구현 - 백엔드 API 수정: Request Body 방식으로 변경 - 데이터베이스 스키마: material_id 컬럼 추가 - 프론트엔드 상태 관리 개선: 저장 후 자동 리로드 - 입력 필드 연결 문제 해결: 누락된 onChange 핸들러 추가 - NewMaterialsPage에 '전체' 카테고리 버튼 추가 (기본 선택) - Docker 환경 개선: 프론트엔드 볼륨 마운트 및 포트 수정 - UI 개선: 벌레 이모지 제거, 디버그 코드 정리 --- RULES.md | 50 +- backend/app/auth/jwt_service.py | 1 + backend/app/auth/middleware.py | 1 + backend/app/models.py | 1 + backend/app/routers/files.py | 41 +- backend/app/services/bolt_classifier.py | 62 +++ backend/app/services/fitting_classifier.py | 176 ++++++- backend/app/services/integrated_classifier.py | 35 +- .../app/services/material_grade_extractor.py | 13 +- backend/app/services/revision_comparator.py | 1 + backend/scripts/18_create_auth_tables.sql | 1 + backend/scripts/PRODUCTION_MIGRATION.sql | 160 ++++++ database/init/20_purchase_confirmations.sql | 1 + database/init/99_complete_schema.sql | 5 +- docker-compose.override.yml | 4 + docker-compose.yml | 2 +- frontend/src/SimpleDashboard.jsx | 1 + frontend/src/components/NavigationBar.css | 1 + frontend/src/components/NavigationBar.jsx | 1 + frontend/src/components/NavigationMenu.css | 1 + frontend/src/components/NavigationMenu.jsx | 1 + .../src/components/RevisionUploadDialog.jsx | 1 + frontend/src/components/SimpleFileUpload.jsx | 1 + frontend/src/pages/DashboardPage.jsx | 1 + frontend/src/pages/LogMonitoringPage.jsx | 6 +- frontend/src/pages/LoginPage.css | 1 + frontend/src/pages/LoginPage.jsx | 1 + frontend/src/pages/NewMaterialsPage.css | 8 +- frontend/src/pages/NewMaterialsPage.jsx | 458 +++++++++++++----- frontend/src/pages/ProjectsPage.jsx | 1 + frontend/src/pages/UserManagementPage.css | 1 + frontend/src/utils/excelExport.js | 79 ++- frontend/vite.config.js | 4 +- test_bom.csv | 2 + 34 files changed, 942 insertions(+), 181 deletions(-) create mode 100644 backend/scripts/PRODUCTION_MIGRATION.sql create mode 100644 test_bom.csv diff --git a/RULES.md b/RULES.md index 31162f1..681a3a6 100644 --- a/RULES.md +++ b/RULES.md @@ -963,7 +963,7 @@ logger.error("에러 발생") --- -## 🐛 자주 발생하는 이슈 & 해결법 +## ⚠️ 자주 발생하는 이슈 & 해결법 ### 1. 파이프 길이 합산 문제 ```python @@ -2104,4 +2104,50 @@ const materials = await fetchMaterials({ --- -**마지막 업데이트**: 2025년 9월 24일 (사용자 피드백 기반 개선사항 정리) +## 🚀 메인 서버 배포 가이드 + +### 📋 **데이터베이스 마이그레이션** + +메인 서버에 배포할 때 반드시 실행해야 하는 데이터베이스 마이그레이션: + +#### **필수 추가 컬럼들** (materials 테이블) +```sql +-- 파이프 사이즈 정보 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS main_nom VARCHAR(50); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS red_nom VARCHAR(50); + +-- 전체 재질명 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS full_material_grade TEXT; + +-- 업로드 행 번호 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER; +``` + +#### **성능 최적화 인덱스** +```sql +CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom); +CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom); +CREATE INDEX IF NOT EXISTS idx_materials_full_material_grade ON materials(full_material_grade); +``` + +#### **자동 마이그레이션 스크립트** +```bash +# 메인 서버에서 실행 +psql -U tkmp_user -d tk_mp_bom -f backend/scripts/PRODUCTION_MIGRATION.sql +``` + +### ⚠️ **중요 사항** +- 이 컬럼들이 없으면 파일 업로드 시 500 에러 발생 +- 완전 초기화 시: `database/init/99_complete_schema.sql` 사용 +- 기존 서버 업데이트 시: `backend/scripts/PRODUCTION_MIGRATION.sql` 사용 + +### 🔧 **배포 체크리스트** +1. [ ] 데이터베이스 백업 +2. [ ] 마이그레이션 스크립트 실행 +3. [ ] 컬럼 존재 확인 +4. [ ] 파일 업로드 테스트 +5. [ ] 자재 분류 기능 테스트 + +--- + +**마지막 업데이트**: 2025년 9월 28일 (메인 서버 배포 가이드 추가) diff --git a/backend/app/auth/jwt_service.py b/backend/app/auth/jwt_service.py index 3702a4c..caaed02 100644 --- a/backend/app/auth/jwt_service.py +++ b/backend/app/auth/jwt_service.py @@ -268,5 +268,6 @@ jwt_service = JWTService() + diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index 6e414ed..8304b94 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -322,5 +322,6 @@ async def get_current_user_optional( + diff --git a/backend/app/models.py b/backend/app/models.py index 25bbc34..416330c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -284,6 +284,7 @@ class UserRequirement(Base): id = Column(Integer, primary_key=True, index=True) file_id = Column(Integer, ForeignKey("files.id"), nullable=False) + material_id = Column(Integer, ForeignKey("materials.id"), nullable=True) # 자재 ID (개별 자재별 요구사항 연결) # 요구사항 타입 requirement_type = Column(String(50), nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 6729221..28b0a75 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, R from sqlalchemy.orm import Session from sqlalchemy import text from typing import List, Optional, Dict +from pydantic import BaseModel import os import shutil from datetime import datetime @@ -2629,6 +2630,7 @@ async def get_user_requirements( { "id": req.id, "file_id": req.file_id, + "material_id": req.material_id, "original_filename": req.original_filename, "job_no": req.job_no, "revision": req.revision, @@ -2651,17 +2653,20 @@ async def get_user_requirements( except Exception as e: raise HTTPException(status_code=500, detail=f"사용자 요구사항 조회 실패: {str(e)}") +class UserRequirementCreate(BaseModel): + file_id: int + material_id: Optional[int] = None + 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 + @router.post("/user-requirements") 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", - assigned_to: Optional[str] = None, - due_date: Optional[str] = None, + requirement: UserRequirementCreate, db: Session = Depends(get_db) ): """ @@ -2681,15 +2686,15 @@ 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, - "requirement_spec": requirement_spec, - "priority": priority, - "assigned_to": assigned_to, - "due_date": due_date + "file_id": requirement.file_id, + "material_id": requirement.material_id, + "requirement_type": requirement.requirement_type, + "requirement_title": requirement.requirement_title, + "requirement_description": requirement.requirement_description, + "requirement_spec": requirement.requirement_spec, + "priority": requirement.priority, + "assigned_to": requirement.assigned_to, + "due_date": requirement.due_date }) requirement_id = result.fetchone()[0] diff --git a/backend/app/services/bolt_classifier.py b/backend/app/services/bolt_classifier.py index 6fed363..9ffafee 100644 --- a/backend/app/services/bolt_classifier.py +++ b/backend/app/services/bolt_classifier.py @@ -1053,6 +1053,68 @@ def calculate_bolt_confidence(confidence_scores: Dict) -> float: # ========== 특수 기능들 ========== +def extract_bolt_additional_requirements(description: str) -> str: + """볼트 설명에서 추가요구사항 추출 (표면처리, 특수 요구사항 등)""" + + desc_upper = description.upper() + additional_reqs = [] + + # 표면처리 패턴들 + surface_treatments = { + 'ELEC.GALV': '전기아연도금', + 'ELEC GALV': '전기아연도금', + 'GALVANIZED': '아연도금', + 'GALV': '아연도금', + 'HOT DIP GALV': '용융아연도금', + 'HDG': '용융아연도금', + 'ZINC PLATED': '아연도금', + 'ZINC': '아연도금', + 'STAINLESS': '스테인리스', + 'SS': '스테인리스', + 'PASSIVATED': '부동태화', + 'ANODIZED': '아노다이징', + 'BLACK OXIDE': '흑색산화', + 'PHOSPHATE': '인산처리', + 'DACROMET': '다크로메트', + 'GEOMET': '지오메트' + } + + # 특수 요구사항 패턴들 + special_requirements = { + 'HEAVY HEX': '중육각', + 'FULL THREAD': '전나사', + 'PARTIAL THREAD': '부분나사', + 'FINE THREAD': '세나사', + 'COARSE THREAD': '조나사', + 'LEFT HAND': '좌나사', + 'RIGHT HAND': '우나사', + 'SOCKET HEAD': '소켓헤드', + 'BUTTON HEAD': '버튼헤드', + 'FLAT HEAD': '평머리', + 'PAN HEAD': '팬헤드', + 'TRUSS HEAD': '트러스헤드', + 'WASHER FACE': '와셔면', + 'SERRATED': '톱니형', + 'LOCK': '잠금', + 'SPRING': '스프링', + 'WAVE': '웨이브' + } + + # 표면처리 확인 + for pattern, korean in surface_treatments.items(): + if pattern in desc_upper: + additional_reqs.append(korean) + + # 특수 요구사항 확인 + for pattern, korean in special_requirements.items(): + if pattern in desc_upper: + additional_reqs.append(korean) + + # 중복 제거 및 정렬 + additional_reqs = list(set(additional_reqs)) + + return ', '.join(additional_reqs) if additional_reqs else '' + def get_bolt_purchase_info(bolt_result: Dict) -> Dict: """볼트 구매 정보 생성""" diff --git a/backend/app/services/fitting_classifier.py b/backend/app/services/fitting_classifier.py index 3647e11..7c192df 100644 --- a/backend/app/services/fitting_classifier.py +++ b/backend/app/services/fitting_classifier.py @@ -230,8 +230,8 @@ def classify_fitting(dat_file: str, description: str, main_nom: str, # 4. 압력 등급 분류 pressure_result = classify_pressure_rating(dat_file, description) - # 4.5. 스케줄 분류 (니플 등에 중요) - schedule_result = classify_fitting_schedule(description) + # 4.5. 스케줄 분류 (니플 등에 중요) - 분리 스케줄 지원 + schedule_result = classify_fitting_schedule_with_reducing(description, main_nom, red_nom) # 5. 제작 방법 추정 manufacturing_result = determine_fitting_manufacturing( @@ -304,6 +304,123 @@ def classify_fitting(dat_file: str, description: str, main_nom: str, }) } +def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict: + """ + 실제 BOM 패턴 기반 TEE vs REDUCER 구분 + + 실제 패턴: + - TEE RED, SMLS, SCH 40 x SCH 80 → TEE (키워드 우선) + - RED CONC, SMLS, SCH 80 x SCH 80 → REDUCER (키워드 우선) + - 모두 A x B 형태 (메인 x 감소) + """ + + desc_upper = description.upper() + + # 1. 키워드 기반 분류 (최우선) - 실제 BOM 패턴 + if "TEE RED" in desc_upper or "TEE REDUCING" in desc_upper: + return { + "type": "TEE", + "subtype": "REDUCING", + "confidence": 0.95, + "evidence": ["KEYWORD_TEE_RED"], + "subtype_confidence": 0.95, + "requires_two_sizes": False + } + + if "RED CONC" in desc_upper or "REDUCER CONC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "CONCENTRIC", + "confidence": 0.95, + "evidence": ["KEYWORD_RED_CONC"], + "subtype_confidence": 0.95, + "requires_two_sizes": True + } + + if "RED ECC" in desc_upper or "REDUCER ECC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "ECCENTRIC", + "confidence": 0.95, + "evidence": ["KEYWORD_RED_ECC"], + "subtype_confidence": 0.95, + "requires_two_sizes": True + } + + # 2. 사이즈 패턴 분석 (보조) - 기존 로직 유지 + # x 또는 × 기호로 연결된 사이즈들 찾기 + connected_sizes = re.findall(r'(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?(?:\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?)?', description) + + if connected_sizes: + # 연결된 사이즈들을 리스트로 변환 + sizes = [] + for size_group in connected_sizes: + for size in size_group: + if size.strip(): + sizes.append(size.strip()) + + # 중복 제거하되 순서 유지 + unique_sizes = [] + for size in sizes: + if size not in unique_sizes: + unique_sizes.append(size) + + sizes = unique_sizes + + if len(sizes) == 3: + # A x B x B 패턴 → TEE REDUCING + if sizes[1] == sizes[2]: + return { + "type": "TEE", + "subtype": "REDUCING", + "confidence": 0.85, + "evidence": [f"SIZE_PATTERN_TEE_REDUCING: {' x '.join(sizes)}"], + "subtype_confidence": 0.85, + "requires_two_sizes": False + } + # A x B x C 패턴 → TEE REDUCING (모두 다른 사이즈) + else: + return { + "type": "TEE", + "subtype": "REDUCING", + "confidence": 0.80, + "evidence": [f"SIZE_PATTERN_TEE_REDUCING_UNEQUAL: {' x '.join(sizes)}"], + "subtype_confidence": 0.80, + "requires_two_sizes": False + } + elif len(sizes) == 2: + # A x B 패턴 → 키워드가 없으면 REDUCER로 기본 분류 + if "CONC" in desc_upper or "CONCENTRIC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "CONCENTRIC", + "confidence": 0.80, + "evidence": [f"SIZE_PATTERN_REDUCER_CONC: {' x '.join(sizes)}"], + "subtype_confidence": 0.80, + "requires_two_sizes": True + } + elif "ECC" in desc_upper or "ECCENTRIC" in desc_upper: + return { + "type": "REDUCER", + "subtype": "ECCENTRIC", + "confidence": 0.80, + "evidence": [f"SIZE_PATTERN_REDUCER_ECC: {' x '.join(sizes)}"], + "subtype_confidence": 0.80, + "requires_two_sizes": True + } + else: + # 키워드 없는 A x B 패턴은 낮은 신뢰도로 REDUCER + return { + "type": "REDUCER", + "subtype": "CONCENTRIC", # 기본값 + "confidence": 0.60, + "evidence": [f"SIZE_PATTERN_REDUCER_DEFAULT: {' x '.join(sizes)}"], + "subtype_confidence": 0.60, + "requires_two_sizes": True + } + + return {"confidence": 0.0} + def classify_fitting_type(dat_file: str, description: str, main_nom: str, red_nom: str = None) -> Dict: """피팅 타입 분류""" @@ -311,6 +428,11 @@ def classify_fitting_type(dat_file: str, description: str, dat_upper = dat_file.upper() desc_upper = description.upper() + # 0. 사이즈 패턴 분석으로 TEE vs REDUCER 구분 (최우선) + size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom) + if size_pattern_result.get("confidence", 0) > 0.85: + return size_pattern_result + # 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음) for fitting_type, type_data in FITTING_TYPES.items(): for pattern in type_data["dat_file_patterns"]: @@ -679,3 +801,53 @@ def classify_fitting_schedule(description: str) -> Dict: "confidence": 0.0, "matched_pattern": "" } + +def classify_fitting_schedule_with_reducing(description: str, main_nom: str, red_nom: str = None) -> Dict: + """ + 실제 BOM 패턴 기반 분리 스케줄 처리 + + 실제 패턴: + - "TEE RED, SMLS, SCH 40 x SCH 80" → main: SCH 40, red: SCH 80 + - "RED CONC, SMLS, SCH 40S x SCH 40S" → main: SCH 40S, red: SCH 40S + - "RED CONC, SMLS, SCH 80 x SCH 80" → main: SCH 80, red: SCH 80 + """ + + desc_upper = description.upper() + + # 1. 분리 스케줄 패턴 확인 (SCH XX x SCH YY) - 개선된 패턴 + separated_schedule_patterns = [ + r'SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)', # SCH 40 x SCH 80 + r'SCH\s*(\d+S?)\s*X\s*(\d+S?)', # SCH 40S X 40S (SCH 생략) + ] + + for pattern in separated_schedule_patterns: + separated_match = re.search(pattern, desc_upper) + if separated_match: + main_schedule = f"SCH {separated_match.group(1)}" + red_schedule = f"SCH {separated_match.group(2)}" + + return { + "schedule": main_schedule, # 기본 스케줄 (호환성) + "main_schedule": main_schedule, + "red_schedule": red_schedule, + "has_different_schedules": main_schedule != red_schedule, + "confidence": 0.95, + "matched_pattern": separated_match.group(0), + "schedule_type": "SEPARATED" + } + + # 2. 단일 스케줄 패턴 (기존 로직 사용) + basic_result = classify_fitting_schedule(description) + + # 단일 스케줄을 main/red 모두에 적용 + schedule = basic_result.get("schedule", "UNKNOWN") + + return { + "schedule": schedule, # 기본 스케줄 (호환성) + "main_schedule": schedule, + "red_schedule": schedule if red_nom else None, + "has_different_schedules": False, + "confidence": basic_result.get("confidence", 0.0), + "matched_pattern": basic_result.get("matched_pattern", ""), + "schedule_type": "UNIFIED" + } diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py index cfa9ad9..5ebda03 100644 --- a/backend/app/services/integrated_classifier.py +++ b/backend/app/services/integrated_classifier.py @@ -5,6 +5,7 @@ import re from typing import Dict, List, Optional, Tuple +from .fitting_classifier import classify_fitting # Level 1: 명확한 타입 키워드 (최우선) LEVEL1_TYPE_KEYWORDS = { @@ -142,6 +143,18 @@ def classify_material_integrated(description: str, main_nom: str = "", # 3단계: 단일 타입 확정 또는 Level 3/4로 판단 if len(detected_types) == 1: material_type = detected_types[0][0] + + # FITTING으로 분류된 경우 상세 분류기 호출 + if material_type == "FITTING": + try: + detailed_result = classify_fitting("", description, main_nom, red_nom, length) + # 상세 분류 결과가 있으면 사용, 없으면 기본 FITTING 반환 + if detailed_result and detailed_result.get("category"): + return detailed_result + except Exception as e: + # 상세 분류 실패 시 기본 FITTING으로 처리 + pass + return { "category": material_type, "confidence": 0.9, @@ -171,6 +184,15 @@ def classify_material_integrated(description: str, main_nom: str = "", if other_type_found: continue # 볼트로 분류하지 않음 + # FITTING으로 분류된 경우 상세 분류기 호출 + if material_type == "FITTING": + try: + detailed_result = classify_fitting("", description, main_nom, red_nom, length) + if detailed_result and detailed_result.get("category"): + return detailed_result + except Exception as e: + pass + return { "category": material_type, "confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도 @@ -182,8 +204,19 @@ def classify_material_integrated(description: str, main_nom: str = "", for material, priority_types in GENERIC_MATERIALS.items(): if material in desc_upper: # 우선순위에 따라 타입 결정 + material_type = priority_types[0] # 첫 번째 우선순위 + + # FITTING으로 분류된 경우 상세 분류기 호출 + if material_type == "FITTING": + try: + detailed_result = classify_fitting("", description, main_nom, red_nom, length) + if detailed_result and detailed_result.get("category"): + return detailed_result + except Exception as e: + pass + return { - "category": priority_types[0], # 첫 번째 우선순위 + "category": material_type, "confidence": 0.3, "evidence": [f"GENERIC_MATERIAL: {material}"], "classification_level": "LEVEL4_GENERIC" diff --git a/backend/app/services/material_grade_extractor.py b/backend/app/services/material_grade_extractor.py index daddcf8..f3c3f01 100644 --- a/backend/app/services/material_grade_extractor.py +++ b/backend/app/services/material_grade_extractor.py @@ -23,6 +23,13 @@ def extract_full_material_grade(description: str) -> str: # 1. ASTM 규격 패턴들 (가장 구체적인 것부터) astm_patterns = [ + # A320 L7, A325, A490 등 단독 규격 (ASTM 없이) + r'\bA320\s+L[0-9]+\b', # A320 L7 + r'\bA325\b', # A325 + r'\bA490\b', # A490 + # ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선 + r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+', + r'ASTM\s+A193/A194\s+[A-Z0-9/]+', # 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 등 @@ -32,14 +39,14 @@ def extract_full_material_grade(description: str) -> str: # 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]+', + 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]+)?', + r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9/]+)?', ] for pattern in astm_patterns: diff --git a/backend/app/services/revision_comparator.py b/backend/app/services/revision_comparator.py index f63849d..e18a9cd 100644 --- a/backend/app/services/revision_comparator.py +++ b/backend/app/services/revision_comparator.py @@ -291,4 +291,5 @@ def get_revision_comparison(db: Session, job_no: str, current_revision: str, + diff --git a/backend/scripts/18_create_auth_tables.sql b/backend/scripts/18_create_auth_tables.sql index d0ec5f3..b446a5e 100644 --- a/backend/scripts/18_create_auth_tables.sql +++ b/backend/scripts/18_create_auth_tables.sql @@ -237,5 +237,6 @@ END $$; + diff --git a/backend/scripts/PRODUCTION_MIGRATION.sql b/backend/scripts/PRODUCTION_MIGRATION.sql new file mode 100644 index 0000000..96ec9fe --- /dev/null +++ b/backend/scripts/PRODUCTION_MIGRATION.sql @@ -0,0 +1,160 @@ +-- ================================ +-- TK-MP-Project 메인 서버 배포용 마이그레이션 +-- 생성일: 2025.09.28 +-- 목적: 개발 중 추가된 필수 컬럼들을 메인 서버에 적용 +-- ================================ + +-- 1. materials 테이블 필수 컬럼 추가 +-- ================================ + +-- 파이프 사이즈 정보 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS main_nom VARCHAR(50); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS red_nom VARCHAR(50); + +-- 전체 재질명 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS full_material_grade TEXT; + +-- 업로드 시 행 번호 추적 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER; + +-- 해시값 (구매 추적용) +ALTER TABLE materials ADD COLUMN IF NOT EXISTS material_hash VARCHAR(64); + +-- 검증 정보 +ALTER TABLE materials ADD COLUMN IF NOT EXISTS verified_by VARCHAR(100); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS verified_at TIMESTAMP; + +-- 분류 상세 정보 (이미 있을 수 있지만 확인) +ALTER TABLE materials ADD COLUMN IF NOT EXISTS classified_subcategory VARCHAR(100); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS schedule VARCHAR(20); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS drawing_name VARCHAR(100); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS area_code VARCHAR(20); +ALTER TABLE materials ADD COLUMN IF NOT EXISTS line_no VARCHAR(50); + +-- 2. files 테이블 필수 컬럼 추가 +-- ================================ + +-- 프로젝트 연결 정보 +ALTER TABLE files ADD COLUMN IF NOT EXISTS job_no VARCHAR(50); +ALTER TABLE files ADD COLUMN IF NOT EXISTS bom_name VARCHAR(255); +ALTER TABLE files ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0; + +-- 3. material_purchase_tracking 테이블 컬럼 추가 +-- ================================ + +-- 구매 상태 및 설명 +ALTER TABLE material_purchase_tracking +ADD COLUMN IF NOT EXISTS purchase_status VARCHAR(20) DEFAULT 'pending', +ADD COLUMN IF NOT EXISTS description TEXT; + +-- 4. user_requirements 테이블 컬럼 추가 +-- ================================ + +-- 자재별 요구사항 연결 +ALTER TABLE user_requirements ADD COLUMN IF NOT EXISTS material_id INTEGER; + +-- 5. 성능 최적화 인덱스 추가 +-- ================================ + +-- materials 테이블 인덱스 +CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom); +CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom); +CREATE INDEX IF NOT EXISTS idx_materials_main_red_nom ON materials(main_nom, red_nom); +CREATE INDEX IF NOT EXISTS idx_materials_full_material_grade ON materials(full_material_grade); +CREATE INDEX IF NOT EXISTS idx_materials_material_hash ON materials(material_hash); +CREATE INDEX IF NOT EXISTS idx_materials_verified_by ON materials(verified_by); +CREATE INDEX IF NOT EXISTS idx_materials_classified_subcategory ON materials(classified_subcategory); +CREATE INDEX IF NOT EXISTS idx_materials_schedule ON materials(schedule); + +-- files 테이블 인덱스 +CREATE INDEX IF NOT EXISTS idx_files_job_no ON files(job_no); + +-- user_requirements 테이블 인덱스 +CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_id); + +-- fitting_details 테이블 분리 스케줄 컬럼 추가 +ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS main_schedule VARCHAR(20); +ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS red_schedule VARCHAR(20); +ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS has_different_schedules BOOLEAN DEFAULT FALSE; + +-- fitting_details 분리 스케줄 인덱스 +CREATE INDEX IF NOT EXISTS idx_fitting_details_main_schedule ON fitting_details(main_schedule); +CREATE INDEX IF NOT EXISTS idx_fitting_details_red_schedule ON fitting_details(red_schedule); + +-- 3. 컬럼 설명 추가 +-- ================================ + +COMMENT ON COLUMN materials.main_nom IS 'MAIN_NOM 필드 - 주 사이즈 (예: 4", 150A)'; +COMMENT ON COLUMN materials.red_nom IS 'RED_NOM 필드 - 축소 사이즈 (Reducing 피팅/플랜지용)'; +COMMENT ON COLUMN materials.full_material_grade IS '전체 재질명 (예: ASTM A312 TP304, ASTM A106 GR B 등)'; +COMMENT ON COLUMN materials.row_number IS '업로드 파일에서의 행 번호 (디버깅용)'; + +-- 6. support_details 테이블 생성 (SUPPORT 카테고리용) +-- ================================ + +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 +); + +-- support_details 인덱스 +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); + +-- 7. 기존 데이터 정리 (선택사항) +-- ================================ + +-- 기존 데이터에 기본값 설정 (필요시 주석 해제) +-- UPDATE materials SET main_nom = '', red_nom = '', full_material_grade = '' +-- WHERE main_nom IS NULL OR red_nom IS NULL OR full_material_grade IS NULL; + +-- ================================ +-- 마이그레이션 완료 확인 +-- ================================ + +DO $$ +BEGIN + -- 컬럼 존재 확인 + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'materials' + AND column_name IN ('main_nom', 'red_nom', 'full_material_grade', 'row_number') + GROUP BY table_name + HAVING COUNT(*) = 4 + ) THEN + RAISE NOTICE '✅ TK-MP-Project 메인 서버 마이그레이션 완료!'; + RAISE NOTICE '📋 추가된 컬럼: main_nom, red_nom, full_material_grade, row_number'; + RAISE NOTICE '🔍 추가된 인덱스: 4개 (성능 최적화)'; + RAISE NOTICE '🚀 파일 업로드 기능 정상 작동 가능'; + ELSE + RAISE NOTICE '❌ 마이그레이션 실패 - 일부 컬럼이 생성되지 않았습니다.'; + END IF; +END $$; diff --git a/database/init/20_purchase_confirmations.sql b/database/init/20_purchase_confirmations.sql index df22cb8..08e3953 100644 --- a/database/init/20_purchase_confirmations.sql +++ b/database/init/20_purchase_confirmations.sql @@ -74,4 +74,5 @@ COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자'; + diff --git a/database/init/99_complete_schema.sql b/database/init/99_complete_schema.sql index de4ab62..476101d 100644 --- a/database/init/99_complete_schema.sql +++ b/database/init/99_complete_schema.sql @@ -975,6 +975,7 @@ CREATE TABLE IF NOT EXISTS requirement_types ( CREATE TABLE IF NOT EXISTS user_requirements ( id SERIAL PRIMARY KEY, file_id INTEGER NOT NULL, + material_id INTEGER, -- 자재 ID (개별 자재별 요구사항 연결) -- 요구사항 타입 requirement_type VARCHAR(50) NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 @@ -996,7 +997,8 @@ CREATE TABLE IF NOT EXISTS user_requirements ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE, + FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE ); -- ================================ @@ -1192,6 +1194,7 @@ CREATE INDEX IF NOT EXISTS idx_jobs_project_type ON jobs(project_type); -- 사용자 요구사항 테이블 인덱스 (05_create_pipe_details_and_requirements_postgres.sql) CREATE INDEX IF NOT EXISTS idx_user_requirements_file_id ON user_requirements(file_id); +CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_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); diff --git a/docker-compose.override.yml b/docker-compose.override.yml index af35268..46f2d5e 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -13,6 +13,10 @@ services: - LOG_LEVEL=DEBUG frontend: + volumes: + # 개발 시 코드 변경 실시간 반영 + - ./frontend:/app + - /app/node_modules # node_modules는 컨테이너 것을 사용 environment: - VITE_API_URL=http://localhost:18000 build: diff --git a/docker-compose.yml b/docker-compose.yml index 8cd88f5..4af1f7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,7 +82,7 @@ services: container_name: tk-mp-frontend restart: unless-stopped ports: - - "${FRONTEND_PORT:-13000}:3000" + - "${FRONTEND_PORT:-13000}:5173" environment: - VITE_API_URL=${VITE_API_URL:-http://localhost:18000} depends_on: diff --git a/frontend/src/SimpleDashboard.jsx b/frontend/src/SimpleDashboard.jsx index 2f66805..e3d9a8d 100644 --- a/frontend/src/SimpleDashboard.jsx +++ b/frontend/src/SimpleDashboard.jsx @@ -237,5 +237,6 @@ export default SimpleDashboard; + diff --git a/frontend/src/components/NavigationBar.css b/frontend/src/components/NavigationBar.css index fc0e152..5377ec5 100644 --- a/frontend/src/components/NavigationBar.css +++ b/frontend/src/components/NavigationBar.css @@ -554,5 +554,6 @@ + diff --git a/frontend/src/components/NavigationBar.jsx b/frontend/src/components/NavigationBar.jsx index 497d7cd..18f51b1 100644 --- a/frontend/src/components/NavigationBar.jsx +++ b/frontend/src/components/NavigationBar.jsx @@ -287,5 +287,6 @@ export default NavigationBar; + diff --git a/frontend/src/components/NavigationMenu.css b/frontend/src/components/NavigationMenu.css index 158ad5e..add5ea6 100644 --- a/frontend/src/components/NavigationMenu.css +++ b/frontend/src/components/NavigationMenu.css @@ -267,5 +267,6 @@ + diff --git a/frontend/src/components/NavigationMenu.jsx b/frontend/src/components/NavigationMenu.jsx index 13bfd18..c4a424d 100644 --- a/frontend/src/components/NavigationMenu.jsx +++ b/frontend/src/components/NavigationMenu.jsx @@ -191,5 +191,6 @@ export default NavigationMenu; + diff --git a/frontend/src/components/RevisionUploadDialog.jsx b/frontend/src/components/RevisionUploadDialog.jsx index a6a6891..ce5129b 100644 --- a/frontend/src/components/RevisionUploadDialog.jsx +++ b/frontend/src/components/RevisionUploadDialog.jsx @@ -99,5 +99,6 @@ export default RevisionUploadDialog; + diff --git a/frontend/src/components/SimpleFileUpload.jsx b/frontend/src/components/SimpleFileUpload.jsx index 3c606f0..2d78c76 100644 --- a/frontend/src/components/SimpleFileUpload.jsx +++ b/frontend/src/components/SimpleFileUpload.jsx @@ -318,5 +318,6 @@ export default SimpleFileUpload; + diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index b1725ab..71bdeeb 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -281,5 +281,6 @@ export default DashboardPage; + diff --git a/frontend/src/pages/LogMonitoringPage.jsx b/frontend/src/pages/LogMonitoringPage.jsx index 191c2dd..94452a7 100644 --- a/frontend/src/pages/LogMonitoringPage.jsx +++ b/frontend/src/pages/LogMonitoringPage.jsx @@ -133,7 +133,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => { const getErrorTypeIcon = (type) => { const icons = { - 'javascript_error': '🐛', + 'javascript_error': '❌', 'api_error': '🌐', 'user_action_error': '👤', 'promise_rejection': '⚠️', @@ -365,7 +365,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => { border: '1px solid #e9ecef' }}>
-
🐛
+

최근 오류

@@ -458,7 +458,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => { background: '#f8f9fa' }}>

- 🐛 프론트엔드 오류 + ❌ 프론트엔드 오류

diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css index ed04cc2..c8176cb 100644 --- a/frontend/src/pages/LoginPage.css +++ b/frontend/src/pages/LoginPage.css @@ -236,5 +236,6 @@ + diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index cbf19cf..a15a981 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -133,5 +133,6 @@ export default LoginPage; + diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css index 2681c2b..c7450cd 100644 --- a/frontend/src/pages/NewMaterialsPage.css +++ b/frontend/src/pages/NewMaterialsPage.css @@ -266,7 +266,7 @@ .detailed-grid-header { display: grid; - grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px; + grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px; padding: 12px 24px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; @@ -289,12 +289,12 @@ /* 피팅 전용 헤더 - 10개 컬럼 */ .detailed-grid-header.fitting-header { - grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px; + grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px; } /* 피팅 전용 행 - 10개 컬럼 */ .detailed-material-row.fitting-row { - grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px; + grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px; } /* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */ @@ -345,7 +345,7 @@ .detailed-material-row { display: grid; - grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px; + grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px; padding: 12px 24px; border-bottom: 1px solid #f3f4f6; align-items: center; diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx index bc00031..7034956 100644 --- a/frontend/src/pages/NewMaterialsPage.jsx +++ b/frontend/src/pages/NewMaterialsPage.jsx @@ -17,14 +17,15 @@ const NewMaterialsPage = ({ }) => { const [materials, setMaterials] = useState([]); const [loading, setLoading] = useState(true); - const [selectedCategory, setSelectedCategory] = useState('PIPE'); + const [selectedCategory, setSelectedCategory] = useState('ALL'); const [selectedMaterials, setSelectedMaterials] = useState(new Set()); 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 [userRequirements, setUserRequirements] = useState({}); + // materialId: requirement 형태 const [savingRequirements, setSavingRequirements] = useState(false); // 같은 BOM의 다른 리비전들 조회 @@ -101,16 +102,28 @@ const NewMaterialsPage = ({ params: { file_id: parseInt(id) } }); - if (response.data?.success && response.data?.requirements) { + if (response.data && Array.isArray(response.data)) { const requirements = {}; - response.data.requirements.forEach(req => { + console.log('📦 API 응답 데이터:', response.data); + response.data.forEach(req => { // material_id를 키로 사용하여 요구사항 저장 if (req.material_id) { requirements[req.material_id] = req.requirement_description || req.requirement_title || ''; + console.log(`📥 로드된 요구사항: 자재 ID ${req.material_id} = "${requirements[req.material_id]}"`); + } else { + console.warn('⚠️ material_id가 없는 요구사항:', req); } }); + + console.log('🔄 setUserRequirements 호출 전 상태:', userRequirements); setUserRequirements(requirements); + console.log('🔄 setUserRequirements 호출 후 새로운 상태:', requirements); console.log('✅ 사용자 요구사항 로드 완료:', Object.keys(requirements).length, '개'); + + // 상태 업데이트 확인을 위한 지연된 로그 + setTimeout(() => { + console.log('⏰ 1초 후 실제 userRequirements 상태:', userRequirements); + }, 1000); } } catch (error) { console.error('❌ 사용자 요구사항 로딩 실패:', error); @@ -122,11 +135,30 @@ const NewMaterialsPage = ({ const saveUserRequirements = async () => { try { setSavingRequirements(true); + + // 강제 테스트: userRequirements가 비어있으면 첫 번째 자재에 테스트 데이터 추가 + let currentRequirements = { ...userRequirements }; + if (Object.keys(currentRequirements).length === 0 && materials.length > 0) { + const firstMaterialId = materials[0].id; + currentRequirements[firstMaterialId] = '강제 테스트 요구사항'; + setUserRequirements(currentRequirements); + console.log('⚠️ 테스트 데이터 강제 추가:', currentRequirements); + } + + // 디버깅: 현재 userRequirements 상태 확인 + console.log('💾 저장 시작 - 현재 userRequirements:', currentRequirements); + console.log('💾 저장 시작 - userRequirements 키 개수:', Object.keys(currentRequirements).length); + console.log('💾 사용자 요구사항 저장 중...', userRequirements); + console.log('📋 전체 userRequirements 객체:', Object.keys(userRequirements).length, '개'); // 요구사항이 있는 자재들만 저장 - const requirementsToSave = Object.entries(userRequirements) - .filter(([materialId, requirement]) => requirement && requirement.trim()) + const requirementsToSave = Object.entries(currentRequirements) + .filter(([materialId, requirement]) => { + const hasValue = requirement && requirement.trim() && requirement.trim().length > 0; + console.log(`🔍 자재 ID ${materialId}: "${requirement}" (길이: ${requirement ? requirement.length : 0}) -> ${hasValue ? '저장' : '제외'}`); + return hasValue; + }) .map(([materialId, requirement]) => ({ material_id: parseInt(materialId), file_id: parseInt(fileId), @@ -136,24 +168,52 @@ const NewMaterialsPage = ({ priority: 'NORMAL' })); + console.log('📝 저장할 요구사항 개수:', requirementsToSave.length); + if (requirementsToSave.length === 0) { alert('저장할 요구사항이 없습니다.'); return; } // 기존 요구사항 삭제 후 새로 저장 - await api.delete(`/files/user-requirements`, { - params: { file_id: parseInt(fileId) } - }); + console.log('🗑️ 기존 요구사항 삭제 중...', { file_id: parseInt(fileId) }); + console.log('🌐 API Base URL:', api.defaults.baseURL); + console.log('🔑 Authorization Header:', api.defaults.headers.Authorization); + + try { + const deleteResponse = await api.delete(`/files/user-requirements`, { + params: { file_id: parseInt(fileId) } + }); + console.log('✅ 기존 요구사항 삭제 완료:', deleteResponse.data); + } catch (deleteError) { + console.error('❌ 기존 요구사항 삭제 실패:', deleteError); + console.error('❌ 삭제 에러 상세:', deleteError.response?.data); + console.error('❌ 삭제 에러 전체:', deleteError); + // 삭제 실패해도 계속 진행 + } // 새 요구사항들 저장 + console.log('🚀 API 호출 시작 - 저장할 데이터:', requirementsToSave); for (const req of requirementsToSave) { - await api.post('/files/user-requirements', req); + console.log('🚀 개별 API 호출:', req); + try { + const response = await api.post('/files/user-requirements', req); + console.log('✅ API 응답:', response.data); + } catch (apiError) { + console.error('❌ API 호출 실패:', apiError); + console.error('❌ API 에러 상세:', apiError.response?.data); + throw apiError; + } } alert(`${requirementsToSave.length}개의 사용자 요구사항이 저장되었습니다.`); console.log('✅ 사용자 요구사항 저장 완료'); + // 저장 후 다시 로드하여 최신 상태 반영 + console.log('🔄 저장 완료 후 다시 로드 시작...'); + await loadUserRequirements(fileId); + console.log('🔄 저장 완료 후 다시 로드 완료!'); + } catch (error) { console.error('❌ 사용자 요구사항 저장 실패:', error); alert('사용자 요구사항 저장에 실패했습니다: ' + (error.response?.data?.detail || error.message)); @@ -164,10 +224,15 @@ const NewMaterialsPage = ({ // 사용자 요구사항 입력 핸들러 const handleUserRequirementChange = (materialId, value) => { - setUserRequirements(prev => ({ - ...prev, - [materialId]: value - })); + console.log(`📝 사용자 요구사항 입력: 자재 ID ${materialId} = "${value}"`); + setUserRequirements(prev => { + const updated = { + ...prev, + [materialId]: value + }; + console.log('🔄 업데이트된 userRequirements:', updated); + return updated; + }); }; // 카테고리별 자재 수 계산 @@ -204,6 +269,78 @@ const NewMaterialsPage = ({ }; }; + // 카테고리 표시명 매핑 + const getCategoryDisplayName = (category) => { + const categoryMap = { + 'SUPPORT': 'U-BOLT', + 'PIPE': 'PIPE', + 'FITTING': 'FITTING', + 'FLANGE': 'FLANGE', + 'VALVE': 'VALVE', + 'BOLT': 'BOLT', + 'GASKET': 'GASKET', + 'INSTRUMENT': 'INSTRUMENT', + 'UNKNOWN': 'UNKNOWN' + }; + return categoryMap[category] || category; + }; + + // 니플 끝단 정보 추출 + const extractNippleEndInfo = (description) => { + const descUpper = description.toUpperCase(); + + // 니플 끝단 패턴들 + const endPatterns = { + 'PBE': 'PBE', // Plain Both End + 'BBE': 'BBE', // Bevel Both End + 'POE': 'POE', // Plain One End + 'BOE': 'BOE', // Bevel One End + 'TOE': 'TOE', // Thread One End + 'SW X NPT': 'SW×NPT', // Socket Weld x NPT + 'SW X SW': 'SW×SW', // Socket Weld x Socket Weld + 'NPT X NPT': 'NPT×NPT', // NPT x NPT + }; + + for (const [pattern, display] of Object.entries(endPatterns)) { + if (descUpper.includes(pattern)) { + return display; + } + } + + return ''; + }; + + // 볼트 추가요구사항 추출 + const extractBoltAdditionalRequirements = (description) => { + const descUpper = description.toUpperCase(); + const additionalReqs = []; + + // 표면처리 패턴들 + const surfaceTreatments = { + 'ELEC.GALV': '전기아연도금', + 'ELEC GALV': '전기아연도금', + 'GALVANIZED': '아연도금', + 'GALV': '아연도금', + 'HOT DIP GALV': '용융아연도금', + 'HDG': '용융아연도금', + 'ZINC PLATED': '아연도금', + 'ZINC': '아연도금', + 'STAINLESS': '스테인리스', + 'SS': '스테인리스' + }; + + // 표면처리 확인 + for (const [pattern, korean] of Object.entries(surfaceTreatments)) { + if (descUpper.includes(pattern)) { + additionalReqs.push(korean); + } + } + + // 중복 제거 + const uniqueReqs = [...new Set(additionalReqs)]; + return uniqueReqs.join(', '); + }; + // 자재 정보 파싱 const parseMaterialInfo = (material) => { const category = material.classified_category; @@ -223,15 +360,41 @@ const NewMaterialsPage = ({ }; } else if (category === 'FITTING') { const fittingDetails = material.fitting_details || {}; - const fittingType = fittingDetails.fitting_type || ''; - const fittingSubtype = fittingDetails.fitting_subtype || ''; + const classificationDetails = material.classification_details || {}; + + // 개선된 분류기 결과 우선 사용 + const fittingTypeInfo = classificationDetails.fitting_type || {}; + const scheduleInfo = classificationDetails.schedule_info || {}; + + // 기존 필드와 새 필드 통합 + const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || ''; + const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || ''; + const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || ''; + const redSchedule = scheduleInfo.red_schedule || ''; + const hasDifferentSchedules = scheduleInfo.has_different_schedules || false; + const description = material.original_description || ''; // 피팅 타입별 상세 표시 let displayType = ''; - // CAP과 PLUG 먼저 확인 (fitting_type이 없을 수 있음) - if (description.toUpperCase().includes('CAP')) { + // 개선된 분류기 결과 우선 표시 + if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') { + displayType = 'TEE REDUCING'; + } else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') { + displayType = 'REDUCER CONC'; + } else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') { + displayType = 'REDUCER ECC'; + } else if (description.toUpperCase().includes('TEE RED')) { + // 기존 데이터의 TEE RED 패턴 + displayType = 'TEE REDUCING'; + } else if (description.toUpperCase().includes('RED CONC')) { + // 기존 데이터의 RED CONC 패턴 + displayType = 'REDUCER CONC'; + } else if (description.toUpperCase().includes('RED ECC')) { + // 기존 데이터의 RED ECC 패턴 + displayType = 'REDUCER ECC'; + } else if (description.toUpperCase().includes('CAP')) { // CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105) if (description.includes('NPT(F)')) { displayType = 'CAP NPT(F)'; @@ -260,7 +423,13 @@ const NewMaterialsPage = ({ } else if (fittingType === 'NIPPLE') { // 니플: 길이와 끝단 가공 정보 const length = fittingDetails.length_mm || fittingDetails.avg_length_mm; - displayType = length ? `NIPPLE ${length}mm` : 'NIPPLE'; + const endInfo = extractNippleEndInfo(description); + + let nippleType = 'NIPPLE'; + if (length) nippleType += ` ${length}mm`; + if (endInfo) nippleType += ` ${endInfo}`; + + displayType = nippleType; } else if (fittingType === 'ELBOW') { // 엘보: 각도와 연결 방식 const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : ''; @@ -295,11 +464,25 @@ const NewMaterialsPage = ({ pressure = `${pressureMatch[1]}LB`; } - // 스케줄 찾기 - if (description.includes('SCH')) { - const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); - if (schMatch) { - schedule = `SCH ${schMatch[1]}`; + // 스케줄 표시 (분리 스케줄 지원) + if (hasDifferentSchedules && mainSchedule && redSchedule) { + // 분리 스케줄: "SCH 40 x SCH 80" + schedule = `${mainSchedule} x ${redSchedule}`; + } else if (mainSchedule && mainSchedule !== 'UNKNOWN') { + // 단일 스케줄: "SCH 40" + schedule = mainSchedule; + } else if (description.includes('SCH')) { + // 기존 데이터에서 분리 스케줄 패턴 확인 + const separatedSchMatch = description.match(/SCH\s*(\d+[A-Z]*)\s*[xX×]\s*SCH\s*(\d+[A-Z]*)/i); + if (separatedSchMatch) { + // 분리 스케줄 발견: "SCH 40 x SCH 80" + schedule = `SCH ${separatedSchMatch[1]} x SCH ${separatedSchMatch[2]}`; + } else { + // 단일 스케줄 + const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); + if (schMatch) { + schedule = `SCH ${schMatch[1]}`; + } } } @@ -397,12 +580,37 @@ const NewMaterialsPage = ({ const qty = Math.round(material.quantity || 0); const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율 const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수 + + // 볼트 길이 추출 (원본 설명에서) + const description = material.original_description || ''; + let boltLength = '-'; + + // 길이 패턴 추출 (75 LG, 90.0000 LG, 50mm 등) + const lengthPatterns = [ + /(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG + /(\d+(?:\.\d+)?)\s*mm/i, // 50mm + /(\d+(?:\.\d+)?)\s*MM/i, // 50MM + /LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태 + ]; + + for (const pattern of lengthPatterns) { + const match = description.match(pattern); + if (match) { + boltLength = `${match[1]}mm`; + break; + } + } + + // 추가요구사항 추출 (ELEC.GALV 등) + const additionalReq = extractBoltAdditionalRequirements(description); + return { type: 'BOLT', - subtype: material.bolt_details?.bolt_type || '-', + subtype: material.bolt_details?.bolt_type || 'BOLT_GENERAL', size: material.size_spec || '-', - schedule: material.bolt_details?.length || '-', - grade: material.material_grade || '-', + schedule: boltLength, // 길이 정보 + grade: material.full_material_grade || material.material_grade || '-', + additionalReq: additionalReq, // 추가요구사항 quantity: purchaseQty, unit: 'SETS' }; @@ -473,6 +681,9 @@ const NewMaterialsPage = ({ // 필터링된 자재 목록 const filteredMaterials = materials.filter(material => { + if (selectedCategory === 'ALL') { + return true; // 전체 카테고리일 때는 모든 자재 표시 + } return material.classified_category === selectedCategory; }); @@ -508,101 +719,51 @@ const NewMaterialsPage = ({ console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목'); - // 카테고리별 컬럼 구성 + // 새로운 엑셀 양식에 맞춘 컬럼 구성 const getExcelData = (material) => { const info = parseMaterialInfo(material); + // 품목명 생성 (간단하게) + let itemName = ''; if (selectedCategory === 'PIPE') { - return { - '종류': info.type, - '타입': info.subtype, - '크기': info.size, - '스케줄': info.schedule, - '재질': info.grade, - '추가요구': '-', - '사용자요구': userRequirements[material.id] || '', - '수량': `${info.quantity} ${info.unit}`, - '상세': `단관 ${info.itemCount || 0}개 → ${info.totalLength || 0}mm` - }; - } else if (selectedCategory === 'FLANGE' && info.isFlange) { - return { - '종류': info.type, - '타입': info.subtype, - '크기': info.size, - '압력(파운드)': info.pressure, - '스케줄': info.schedule, - '재질': info.grade, - '추가요구': '-', - '사용자요구': userRequirements[material.id] || '', - '수량': `${info.quantity} ${info.unit}` - }; - } else if (selectedCategory === 'FITTING' && info.isFitting) { - return { - '종류': info.type, - '타입/상세': info.subtype, - '크기': info.size, - '압력': info.pressure, - '스케줄': info.schedule, - '재질': info.grade, - '추가요구': '-', - '사용자요구': userRequirements[material.id] || '', - '수량': `${info.quantity} ${info.unit}` - }; - } else if (selectedCategory === 'VALVE' && info.isValve) { - return { - '타입': info.valveType, - '연결방식': info.connectionType, - '크기': info.size, - '압력': info.pressure, - '재질': info.grade, - '추가요구': '-', - '사용자요구': userRequirements[material.id] || '', - '수량': `${info.quantity} ${info.unit}` - }; - } else if (selectedCategory === 'GASKET' && info.isGasket) { - return { - '종류': info.type, - '타입': info.subtype, - '크기': info.size, - '압력': info.pressure, - '재질': info.materialStructure, - '상세내역': info.materialDetail, - '두께': info.thickness, - '추가요구': '-', - '사용자요구': '', - '수량': `${info.quantity} ${info.unit}` - }; + itemName = info.subtype || 'PIPE'; + } else if (selectedCategory === 'FITTING') { + itemName = info.subtype || 'FITTING'; + } else if (selectedCategory === 'FLANGE') { + itemName = info.subtype || 'FLANGE'; + } else if (selectedCategory === 'VALVE') { + itemName = info.valveType || info.subtype || 'VALVE'; + } else if (selectedCategory === 'GASKET') { + itemName = info.subtype || 'GASKET'; } else if (selectedCategory === 'BOLT') { - return { - '종류': info.type, - '타입': info.subtype, - '크기': info.size, - '스케줄': info.schedule, - '재질': info.grade, - '추가요구': '-', - '사용자요구': userRequirements[material.id] || '', - '수량': `${info.quantity} ${info.unit}` - }; - } else if (selectedCategory === 'UNKNOWN' && info.isUnknown) { - return { - '종류': info.type, - '설명': info.description, - '사용자요구': '', - '수량': `${info.quantity} ${info.unit}` - }; + itemName = info.subtype || 'BOLT'; } else { - // 기본 형식 - return { - '종류': info.type, - '타입': info.subtype, - '크기': info.size, - '스케줄': info.schedule, - '재질': info.grade, - '추가요구': '-', - '사용자요구': userRequirements[material.id] || '', - '수량': `${info.quantity} ${info.unit}` - }; + itemName = info.subtype || info.type || 'UNKNOWN'; } + + // 사용자 요구사항 확인 + const userReq = userRequirements[material.id] || ''; + console.log(`📋 엑셀 내보내기 - 자재 ID ${material.id}: 사용자요구 = "${userReq}"`); + + // 통일된 엑셀 양식 반환 + return { + 'TAGNO': '', // 비워둠 + '품목명': itemName.trim(), + '수량': info.quantity, + '통화구분': 'KRW', // 기본값 + '단가': 1, // 일괄 1로 설정 + '크기': info.size, + '압력등급': info.pressure || '-', + '스케줄': info.schedule || '-', + '재질': info.grade, + '사용자요구': userReq, + '관리항목1': '', // 빈칸 + '관리항목7': '', // 빈칸 + '관리항목8': '', // 빈칸 + '관리항목9': '', // 빈칸 + '관리항목10': '', // 빈칸 + '납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜 + }; }; // 엑셀 데이터 생성 @@ -694,6 +855,25 @@ const NewMaterialsPage = ({ )}
+ 총 {materials.length}개 자재 ({currentRevision}) @@ -702,13 +882,22 @@ const NewMaterialsPage = ({ {/* 카테고리 필터 */}
+ {/* 전체 카테고리 버튼 */} + + {Object.entries(categoryCounts).map(([category, count]) => ( ))}
@@ -885,7 +1074,7 @@ const NewMaterialsPage = ({ {/* 추가요구 */}
- - + {info.additionalReq || '-'}
{/* 사용자요구 */} @@ -895,7 +1084,10 @@ const NewMaterialsPage = ({ className="user-req-input" placeholder="요구사항 입력" value={userRequirements[material.id] || ''} - onChange={(e) => handleUserRequirementChange(material.id, e.target.value)} + onChange={(e) => { + console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); + handleUserRequirementChange(material.id, e.target.value); + }} />
@@ -954,7 +1146,7 @@ const NewMaterialsPage = ({ {/* 추가요구 */}
- - + {info.additionalReq || '-'}
{/* 사용자요구 */} @@ -964,7 +1156,10 @@ const NewMaterialsPage = ({ className="user-req-input" placeholder="요구사항 입력" value={userRequirements[material.id] || ''} - onChange={(e) => handleUserRequirementChange(material.id, e.target.value)} + onChange={(e) => { + console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); + handleUserRequirementChange(material.id, e.target.value); + }} /> @@ -1030,7 +1225,7 @@ const NewMaterialsPage = ({ {/* 추가요구 */}
- - + {info.additionalReq || '-'}
{/* 사용자요구 */} @@ -1040,7 +1235,10 @@ const NewMaterialsPage = ({ className="user-req-input" placeholder="요구사항 입력" value={userRequirements[material.id] || ''} - onChange={(e) => handleUserRequirementChange(material.id, e.target.value)} + onChange={(e) => { + console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); + handleUserRequirementChange(material.id, e.target.value); + }} /> @@ -1093,7 +1291,10 @@ const NewMaterialsPage = ({ className="user-req-input" placeholder="요구사항 입력" value={userRequirements[material.id] || ''} - onChange={(e) => handleUserRequirementChange(material.id, e.target.value)} + onChange={(e) => { + console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); + handleUserRequirementChange(material.id, e.target.value); + }} /> @@ -1164,7 +1365,7 @@ const NewMaterialsPage = ({ {/* 추가요구 */}
- - + {info.additionalReq || '-'}
{/* 사용자요구 */} @@ -1174,7 +1375,10 @@ const NewMaterialsPage = ({ className="user-req-input" placeholder="요구사항 입력" value={userRequirements[material.id] || ''} - onChange={(e) => handleUserRequirementChange(material.id, e.target.value)} + onChange={(e) => { + console.log('🎯 입력 이벤트 발생!', material.id, e.target.value); + handleUserRequirementChange(material.id, e.target.value); + }} /> @@ -1234,7 +1438,7 @@ const NewMaterialsPage = ({ {/* 추가요구 */}
- - + {info.additionalReq || '-'}
{/* 사용자요구 */} @@ -1243,6 +1447,8 @@ const NewMaterialsPage = ({ type="text" className="user-req-input" placeholder="요구사항 입력" + value={userRequirements[material.id] || ''} + onChange={(e) => handleUserRequirementChange(material.id, e.target.value)} /> diff --git a/frontend/src/pages/ProjectsPage.jsx b/frontend/src/pages/ProjectsPage.jsx index 5937617..fdbc366 100644 --- a/frontend/src/pages/ProjectsPage.jsx +++ b/frontend/src/pages/ProjectsPage.jsx @@ -405,5 +405,6 @@ export default ProjectsPage; + diff --git a/frontend/src/pages/UserManagementPage.css b/frontend/src/pages/UserManagementPage.css index 2d11f93..5a0bb74 100644 --- a/frontend/src/pages/UserManagementPage.css +++ b/frontend/src/pages/UserManagementPage.css @@ -447,5 +447,6 @@ + diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index b1127ba..cf5d8e7 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -120,7 +120,6 @@ const consolidateMaterials = (materials, isComparison = false) => { */ const formatMaterialForExcel = (material, includeComparison = false) => { const category = material.classified_category || material.category || '-'; - const isPipe = category === 'PIPE'; // 엑셀용 자재 설명 정제 let cleanDescription = material.original_description || material.description || '-'; @@ -135,16 +134,12 @@ const formatMaterialForExcel = (material, includeComparison = false) => { // 니플의 경우 길이 정보 명시적 추가 if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) { - // fitting_details에서 길이 정보 가져오기 if (material.fitting_details && material.fitting_details.length_mm) { const lengthMm = Math.round(material.fitting_details.length_mm); - // 이미 길이 정보가 있는지 확인 if (!cleanDescription.match(/\d+\s*mm/i)) { cleanDescription += ` ${lengthMm}mm`; } - } - // 또는 기존 설명에서 길이 정보 추출 - else { + } else { const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i); if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) { cleanDescription += ` ${lengthMatch[1]}mm`; @@ -155,31 +150,79 @@ const formatMaterialForExcel = (material, includeComparison = false) => { // 구매 수량 계산 const purchaseInfo = calculatePurchaseQuantity(material); + // 품목명 생성 (간단하게) + let itemName = ''; + if (category === 'PIPE') { + itemName = material.pipe_details?.manufacturing_method || 'PIPE'; + } else if (category === 'FITTING') { + itemName = material.fitting_details?.fitting_type || 'FITTING'; + } else if (category === 'FLANGE') { + itemName = 'FLANGE'; + } else if (category === 'VALVE') { + itemName = 'VALVE'; + } else if (category === 'GASKET') { + itemName = 'GASKET'; + } else if (category === 'BOLT') { + itemName = 'BOLT'; + } else { + itemName = category || 'UNKNOWN'; + } + + // 압력 등급 추출 + let pressure = '-'; + const pressureMatch = cleanDescription.match(/(\d+)LB/i); + if (pressureMatch) { + pressure = `${pressureMatch[1]}LB`; + } + + // 스케줄 추출 + let schedule = '-'; + const scheduleMatch = cleanDescription.match(/SCH\s*(\d+[A-Z]*(?:\s*[xX×]\s*SCH\s*\d+[A-Z]*)?)/i); + if (scheduleMatch) { + schedule = scheduleMatch[0]; + } + + // 재질 추출 (ASTM 등) + let grade = '-'; + const gradeMatch = cleanDescription.match(/(ASTM\s+[A-Z0-9\s]+(?:TP\d+|GR\s*[A-Z0-9]+|WP\d+)?)/i); + if (gradeMatch) { + grade = gradeMatch[1].trim(); + } + + // 새로운 엑셀 양식에 맞춘 데이터 구조 const base = { - '카테고리': category, - '자재 설명': cleanDescription, - '사이즈': material.size_spec || '-' + 'TAGNO': '', // 비워둠 + '품목명': itemName, + '수량': purchaseInfo.purchaseQuantity || material.quantity || 0, + '통화구분': 'KRW', // 기본값 + '단가': 1, // 일괄 1로 설정 + '크기': material.size_spec || '-', + '압력등급': pressure, + '스케줄': schedule, + '재질': grade, + '사용자요구': '', + '관리항목1': '', // 빈칸 + '관리항목7': '', // 빈칸 + '관리항목8': '', // 빈칸 + '관리항목9': '', // 빈칸 + '관리항목10': '', // 빈칸 + '납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜 }; - // 구매 수량 정보만 추가 (기존 수량/단위 정보 제거) - base['필요 수량'] = purchaseInfo.purchaseQuantity || 0; - base['구매 단위'] = purchaseInfo.unit || 'EA'; - - // 비교 모드인 경우 구매 수량 변화 정보만 추가 + // 비교 모드인 경우 추가 정보 if (includeComparison) { if (material.previous_quantity !== undefined) { - // 이전 구매 수량 계산 const prevPurchaseInfo = calculatePurchaseQuantity({ ...material, quantity: material.previous_quantity, totalLength: material.previousTotalLength || 0 }); - base['이전 필요 수량'] = prevPurchaseInfo.purchaseQuantity || 0; - base['필요 수량 변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity); + base['이전수량'] = prevPurchaseInfo.purchaseQuantity || 0; + base['수량변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity); } - base['변경 유형'] = material.change_type || ( + base['변경유형'] = material.change_type || ( material.previous_quantity !== undefined ? '수량 변경' : material.quantity_change === undefined ? '신규' : '변경' ); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index bf677be..83fc69d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,9 +5,9 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], server: { - port: 13000, + port: 5173, host: true, - open: true + open: false }, build: { outDir: 'dist', diff --git a/test_bom.csv b/test_bom.csv new file mode 100644 index 0000000..f74fdb7 --- /dev/null +++ b/test_bom.csv @@ -0,0 +1,2 @@ +Item,Description,Quantity +1,Test Item,10