From 2e0d91cf597b15d70433cf8c1521e18652d52e23 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 1 Oct 2025 08:18:25 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20=EB=B3=BC=ED=8A=B8=20=EC=9E=AC?= =?UTF-8?q?=EC=A7=88=20=EC=A0=95=EB=B3=B4=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20A320/A194M=20=ED=8C=A8=ED=84=B4=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bolt_classifier.py: A320/A194M 조합 패턴 처리 로직 추가 - material_grade_extractor.py: A320/A194M 패턴 추출 개선 - integrated_classifier.py: SPECIAL, U_BOLT 카테고리 우선 분류 - 데이터베이스: 492개 볼트의 material_grade를 완전한 형태로 업데이트 - A320/A194M GR B8/8: 78개 - A193/A194 GR B7/2H: 414개 - 프론트엔드: BOLT 카테고리 전용 UI (길이 표시) - Excel 내보내기: BOLT용 컬럼 순서 및 재질 정보 개선 - SPECIAL, U_BOLT 카테고리 지원 추가 --- backend/app/routers/files.py | 59 +- backend/app/services/bolt_classifier.py | 82 +- backend/app/services/integrated_classifier.py | 23 + .../app/services/material_grade_extractor.py | 9 + .../24_add_special_category_support.sql | 130 +++ .../25_execute_special_category_migration.py | 151 +++ .../scripts/26_update_bolt_material_grades.py | 114 +++ backend/scripts/PRODUCTION_MIGRATION.sql | 77 ++ database/init/99_complete_schema.sql | 63 ++ frontend/src/pages/NewMaterialsPage.css | 649 ++++++++++++- frontend/src/pages/NewMaterialsPage.jsx | 874 ++++++++++++++---- frontend/src/utils/excelExport.js | 395 ++++++-- 12 files changed, 2370 insertions(+), 256 deletions(-) create mode 100644 backend/scripts/24_add_special_category_support.sql create mode 100755 backend/scripts/25_execute_special_category_migration.py create mode 100644 backend/scripts/26_update_bolt_material_grades.py diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 28b0a75..c9ea55e 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -633,6 +633,17 @@ async def upload_file( classification_result = classify_valve("", description, main_nom or "") elif material_type == "BOLT": classification_result = classify_bolt("", description, main_nom or "") + print(f"🔧 BOLT 분류 결과: {classification_result}") + print(f"🔧 원본 설명: {description}") + print(f"🔧 main_nom: {main_nom}") + + # 길이 정보 확인 + dimensions_info = classification_result.get("dimensions", {}) + print(f"🔧 길이 정보: {dimensions_info}") + + # 재질 정보 확인 + material_info = classification_result.get("material", {}) + print(f"🔧 재질 정보: {material_info}") elif material_type == "GASKET": classification_result = classify_gasket("", description, main_nom or "") elif material_type == "INSTRUMENT": @@ -1075,12 +1086,35 @@ async def upload_file( dimensions_info = classification_result.get("dimensions", {}) material_info = classification_result.get("material", {}) + print(f"🔧 fastener_type_info: {fastener_type_info}") + # 볼트 타입 (STUD_BOLT, HEX_BOLT 등) bolt_type = "" if isinstance(fastener_type_info, dict): bolt_type = fastener_type_info.get("type", "UNKNOWN") + print(f"🔧 추출된 bolt_type: {bolt_type}") else: bolt_type = str(fastener_type_info) if fastener_type_info else "UNKNOWN" + print(f"🔧 문자열 bolt_type: {bolt_type}") + + # 특수 용도 볼트 확인 (PSV, LT, CK 등) + special_result = classification_result.get("special_applications", {}) + print(f"🔧 special_result: {special_result}") + + # 특수 용도가 감지되면 타입 우선 적용 + if special_result and special_result.get("detected_applications"): + detected_apps = special_result.get("detected_applications", []) + if "LT" in detected_apps: + bolt_type = "LT_BOLT" + print(f"🔧 특수 용도 감지로 bolt_type 변경: {bolt_type}") + elif "PSV" in detected_apps: + bolt_type = "PSV_BOLT" + print(f"🔧 특수 용도 감지로 bolt_type 변경: {bolt_type}") + elif "CK" in detected_apps: + bolt_type = "CK_BOLT" + print(f"🔧 특수 용도 감지로 bolt_type 변경: {bolt_type}") + + print(f"🔧 최종 bolt_type: {bolt_type}") # 나사 타입 (METRIC, INCH 등) thread_type = "" @@ -1553,6 +1587,9 @@ async def get_materials( fd.fitting_type, fd.fitting_subtype, fd.connection_method, fd.pressure_rating, fd.material_standard, fd.material_grade as fitting_material_grade, fd.main_size, fd.reduced_size, fd.length_mm as fitting_length_mm, fd.schedule as fitting_schedule, + gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material_type, + gd.filler_material, gd.pressure_rating as gasket_pressure_rating, gd.size_inches as gasket_size_inches, + gd.thickness as gasket_thickness, gd.temperature_range as gasket_temperature_range, gd.fire_safe, mpt.confirmed_quantity, mpt.purchase_status, mpt.confirmed_by, mpt.confirmed_at, -- 구매수량 계산에서 분류된 정보를 우선 사용 CASE @@ -1579,6 +1616,7 @@ async def get_materials( LEFT JOIN pipe_end_preparations pep ON m.id = pep.material_id LEFT JOIN fitting_details fd ON m.id = fd.material_id LEFT JOIN valve_details vd ON m.id = vd.material_id + LEFT JOIN gasket_details gd ON m.id = gd.material_id LEFT JOIN material_purchase_tracking mpt ON ( m.material_hash = mpt.material_hash AND f.job_no = mpt.job_no @@ -1914,17 +1952,18 @@ async def get_materials( flange_groups[flange_key]["materials"].append(material_dict) material_dict['clean_description'] = clean_description elif m.classified_category == 'GASKET': - gasket_query = text("SELECT * FROM gasket_details WHERE material_id = :material_id") - gasket_result = db.execute(gasket_query, {"material_id": m.id}) - gasket_detail = gasket_result.fetchone() - if gasket_detail: + # 이미 JOIN된 gasket_details 데이터 사용 + if m.gasket_type: # gasket_details가 있는 경우 material_dict['gasket_details'] = { - "gasket_type": gasket_detail.gasket_type, - "material_type": gasket_detail.material_type, - "pressure_rating": gasket_detail.pressure_rating, - "size_inches": gasket_detail.size_inches, - "thickness": gasket_detail.thickness, - "temperature_range": gasket_detail.temperature_range + "gasket_type": m.gasket_type, + "gasket_subtype": m.gasket_subtype, + "material_type": m.gasket_material_type, + "filler_material": m.filler_material, + "pressure_rating": m.gasket_pressure_rating, + "size_inches": m.gasket_size_inches, + "thickness": m.gasket_thickness, + "temperature_range": m.gasket_temperature_range, + "fire_safe": m.fire_safe } # 가스켓 그룹핑 - 크기, 압력, 재질로 그룹핑 diff --git a/backend/app/services/bolt_classifier.py b/backend/app/services/bolt_classifier.py index 9ffafee..35249d8 100644 --- a/backend/app/services/bolt_classifier.py +++ b/backend/app/services/bolt_classifier.py @@ -12,6 +12,31 @@ def classify_bolt_material(description: str) -> Dict: desc_upper = description.upper() + # A320/A194M 동시 처리 (예: "ASTM A320/A194M GR B8/8") - 저온용 볼트 조합 + if "A320" in desc_upper and "A194" in desc_upper: + # B8/8 등급 추출 + bolt_grade = "UNKNOWN" + nut_grade = "UNKNOWN" + + if "B8" in desc_upper: + bolt_grade = "B8" + nut_grade = "8" # A320/A194M의 경우 보통 B8/8 조합 + elif "L7" in desc_upper: + bolt_grade = "L7" + elif "B8M" in desc_upper: + bolt_grade = "B8M" + + combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else f"{bolt_grade}" if bolt_grade != "UNKNOWN" else "A320/A194M" + + return { + "standard": "ASTM A320/A194M", + "grade": combined_grade, + "material_type": "LOW_TEMP_STAINLESS", # 저온용 스테인리스 + "manufacturing": "FORGED", + "confidence": 0.95, + "evidence": ["ASTM_A320_A194M_COMBINED"] + } + # A193/A194 동시 처리 (예: "ASTM A193/A194 GR B7/2H") if "A193" in desc_upper and "A194" in desc_upper: # B7/2H 등급 추출 @@ -136,6 +161,39 @@ def classify_bolt_material(description: str) -> Dict: "evidence": ["ISO_4762_SOCKET_SCREW"] } + # 일반적인 볼트 재질 패턴 추가 확인 + if "B7" in desc_upper and "2H" in desc_upper: + return { + "standard": "ASTM A193/A194", + "grade": "B7/2H", + "material_type": "ALLOY_STEEL", + "manufacturing": "FORGED", + "confidence": 0.85, + "evidence": ["B7_2H_PATTERN"] + } + + # 단독 B7 패턴 + if "B7" in desc_upper: + return { + "standard": "ASTM A193", + "grade": "B7", + "material_type": "ALLOY_STEEL", + "manufacturing": "FORGED", + "confidence": 0.80, + "evidence": ["B7_PATTERN"] + } + + # 단독 2H 패턴 + if "2H" in desc_upper: + return { + "standard": "ASTM A194", + "grade": "2H", + "material_type": "ALLOY_STEEL", + "manufacturing": "FORGED", + "confidence": 0.80, + "evidence": ["2H_PATTERN"] + } + # 기본 재질 분류기 호출 (materials_schema 문제가 있어도 우회) try: return classify_material(description) @@ -195,7 +253,7 @@ BOLT_TYPES = { "LT_BOLT": { "dat_file_patterns": ["LT_BOLT", "LT_BLT"], - "description_keywords": ["LT", "LOW TEMP", "저온용"], + "description_keywords": ["LT BOLT", "LT BLT", "LOW TEMP", "저온용"], "characteristics": "저온용 특수 볼트", "applications": "저온 환경 체결용", "head_type": "HEXAGON", @@ -507,7 +565,8 @@ def classify_special_application_bolts(description: str) -> Dict: # LT 볼트 확인 (저온용 볼트) lt_patterns = [ - r'\bLT\b', # 단어 경계로 LT만 + r'\bLT\s', # LT 다음에 공백이 있는 경우만 (LT BOLT, LT BLT) + r'^LT\b', # 문장 시작의 LT만 r'LOW\s+TEMP', r'저온용', r'CRYOGENIC', @@ -915,20 +974,31 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict: "dimension_description": nominal_size_fraction # 분수로 표시 } - # 길이 정보 추출 + # 길이 정보 추출 (개선된 패턴) length_patterns = [ - r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG 형태 (최우선) + r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG, 145.0000 LG 형태 (최우선) r'(\d+(?:\.\d+)?)\s*MM\s*LG', # 70MM LG 형태 r'L\s*(\d+(?:\.\d+)?)\s*MM', r'LENGTH\s*(\d+(?:\.\d+)?)\s*MM', r'(\d+(?:\.\d+)?)\s*MM\s*LONG', - r'X\s*(\d+(?:\.\d+)?)\s*MM' # M8 X 20MM 형태 + r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태 + r',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용) + r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용) + r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독) + r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독) ] for pattern in length_patterns: match = re.search(pattern, desc_upper) if match: - dimensions["length"] = f"{match.group(1)}mm" + length_value = match.group(1) + # 소수점 제거 (145.0000 → 145) + if '.' in length_value and length_value.endswith('.0000'): + length_value = length_value.split('.')[0] + elif '.' in length_value and all(c == '0' for c in length_value.split('.')[1]): + length_value = length_value.split('.')[0] + + dimensions["length"] = f"{length_value}mm" break # 지름 정보 (이미 main_nom에 있지만 확인) diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py index 5ebda03..adfd38d 100644 --- a/backend/app/services/integrated_classifier.py +++ b/backend/app/services/integrated_classifier.py @@ -89,6 +89,29 @@ def classify_material_integrated(description: str, main_nom: str = "", desc_upper = description.upper() + # 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재) + special_keywords = ['SPECIAL', '스페셜', 'SPEC', 'SPL'] + for keyword in special_keywords: + if keyword in desc_upper: + return { + "category": "SPECIAL", + "confidence": 1.0, + "evidence": [f"SPECIAL_KEYWORD: {keyword}"], + "classification_level": "LEVEL0_SPECIAL", + "reason": f"스페셜 키워드 발견: {keyword}" + } + + # U-BOLT 및 관련 부품 우선 확인 (BOLT 카테고리보다 먼저) + if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or + 'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper): + return { + "category": "U_BOLT", + "confidence": 1.0, + "evidence": ["U_BOLT_SYSTEM_KEYWORD"], + "classification_level": "LEVEL0_U_BOLT", + "reason": "U-BOLT 시스템 키워드 발견" + } + # 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80") desc_parts = [part.strip() for part in desc_upper.split(',')] diff --git a/backend/app/services/material_grade_extractor.py b/backend/app/services/material_grade_extractor.py index f3c3f01..275a12a 100644 --- a/backend/app/services/material_grade_extractor.py +++ b/backend/app/services/material_grade_extractor.py @@ -30,6 +30,15 @@ def extract_full_material_grade(description: str) -> str: # 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 A320/A194M GR B8/8 (저온용 볼트 조합 패턴) + r'ASTM\s+A320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+', + r'ASTM\s+A320[M]?/A194[M]?\s+[A-Z0-9/]+', + # 단독 A193/A194 패턴 (ASTM 없이) + r'\bA193/A194\s+GR\s+[A-Z0-9/]+\b', + r'\bA193/A194\s+[A-Z0-9/]+\b', + # 단독 A320/A194M 패턴 (ASTM 없이) + r'\bA320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+\b', + r'\bA320[M]?/A194[M]?\s+[A-Z0-9/]+\b', # 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 등 diff --git a/backend/scripts/24_add_special_category_support.sql b/backend/scripts/24_add_special_category_support.sql new file mode 100644 index 0000000..f9ae440 --- /dev/null +++ b/backend/scripts/24_add_special_category_support.sql @@ -0,0 +1,130 @@ +-- ================================ +-- SPECIAL 카테고리 지원 추가 마이그레이션 +-- 생성일: 2025.09.30 +-- 목적: SPECIAL 카테고리 분류 및 관련 기능 지원 +-- ================================ + +-- 1. materials 테이블 SPECIAL 카테고리 지원 확인 +-- ================================ + +-- classified_category 컬럼이 SPECIAL 값을 지원하는지 확인 +-- (이미 VARCHAR(50)이므로 추가 작업 불필요, 하지만 명시적으로 체크) + +-- SPECIAL 카테고리 관련 인덱스 추가 (성능 최적화) +CREATE INDEX IF NOT EXISTS idx_materials_special_category +ON materials(classified_category) +WHERE classified_category = 'SPECIAL'; + +-- 2. SPECIAL 카테고리 분류 규칙 테이블 생성 +-- ================================ + +-- SPECIAL 키워드 패턴 테이블 +CREATE TABLE IF NOT EXISTS special_classification_patterns ( + id SERIAL PRIMARY KEY, + pattern_type VARCHAR(20) NOT NULL, -- 'KEYWORD', 'REGEX', 'EXACT' + pattern_value VARCHAR(200) NOT NULL, + description TEXT, + priority INTEGER DEFAULT 1, -- 우선순위 (낮을수록 높은 우선순위) + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 기본 SPECIAL 키워드 패턴 삽입 +INSERT INTO special_classification_patterns (pattern_type, pattern_value, description, priority) VALUES +('KEYWORD', 'SPECIAL', '영문 SPECIAL 키워드', 1), +('KEYWORD', '스페셜', '한글 스페셜 키워드', 1), +('KEYWORD', 'SPEC', '영문 SPEC 축약어', 2), +('KEYWORD', 'SPL', '영문 SPL 축약어', 2) +ON CONFLICT DO NOTHING; + +-- 3. SPECIAL 자재 추가 정보 테이블 +-- ================================ + +-- SPECIAL 자재 상세 정보 테이블 (도면 업로드 관련) +CREATE TABLE IF NOT EXISTS special_material_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 도면 정보 + drawing_number VARCHAR(100), -- 도면 번호 + drawing_revision VARCHAR(20), -- 도면 리비전 + drawing_uploaded BOOLEAN DEFAULT FALSE, -- 도면 업로드 여부 + drawing_file_path TEXT, -- 도면 파일 경로 + + -- 특수 요구사항 + special_requirements TEXT, -- 특수 제작 요구사항 + manufacturing_notes TEXT, -- 제작 참고사항 + approval_required BOOLEAN DEFAULT TRUE, -- 승인 필요 여부 + approved_by VARCHAR(100), -- 승인자 + approved_at TIMESTAMP, -- 승인 일시 + + -- 분류 정보 + classification_confidence FLOAT DEFAULT 1.0, + classification_reason TEXT, -- 분류 근거 + + -- 관리 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 4. 인덱스 생성 (성능 최적화) +-- ================================ + +-- special_classification_patterns 인덱스 +CREATE INDEX IF NOT EXISTS idx_special_patterns_type ON special_classification_patterns(pattern_type); +CREATE INDEX IF NOT EXISTS idx_special_patterns_active ON special_classification_patterns(is_active); +CREATE INDEX IF NOT EXISTS idx_special_patterns_priority ON special_classification_patterns(priority); + +-- special_material_details 인덱스 +CREATE INDEX IF NOT EXISTS idx_special_details_material ON special_material_details(material_id); +CREATE INDEX IF NOT EXISTS idx_special_details_file ON special_material_details(file_id); +CREATE INDEX IF NOT EXISTS idx_special_details_drawing_uploaded ON special_material_details(drawing_uploaded); +CREATE INDEX IF NOT EXISTS idx_special_details_approval ON special_material_details(approval_required); + +-- 5. 기존 자재 재분류 (선택적) +-- ================================ + +-- 기존 자료 중 SPECIAL 키워드가 포함된 자재를 SPECIAL 카테고리로 재분류 +UPDATE materials +SET + classified_category = 'SPECIAL', + classification_confidence = 1.0, + updated_by = 'SYSTEM_MIGRATION', + classified_at = CURRENT_TIMESTAMP +WHERE + ( + UPPER(original_description) LIKE '%SPECIAL%' OR + UPPER(original_description) LIKE '%스페셜%' OR + UPPER(original_description) LIKE '%SPEC%' OR + UPPER(original_description) LIKE '%SPL%' + ) + AND (classified_category IS NULL OR classified_category != 'SPECIAL'); + +-- 6. 통계 및 검증 +-- ================================ + +-- SPECIAL 카테고리 자재 개수 확인 +DO $$ +DECLARE + special_count INTEGER; +BEGIN + SELECT COUNT(*) INTO special_count FROM materials WHERE classified_category = 'SPECIAL'; + RAISE NOTICE 'SPECIAL 카테고리로 분류된 자재 개수: %', special_count; +END $$; + +-- 7. 권한 설정 (필요시) +-- ================================ + +-- SPECIAL 자재 관리 권한 (향후 확장용) +-- 현재는 기본 materials 테이블 권한을 따름 + +COMMIT; + +-- ================================ +-- 마이그레이션 완료 로그 +-- ================================ +INSERT INTO migration_log (script_name, executed_at, description) VALUES +('24_add_special_category_support.sql', CURRENT_TIMESTAMP, 'SPECIAL 카테고리 지원 추가 및 기존 자재 재분류') +ON CONFLICT DO NOTHING; diff --git a/backend/scripts/25_execute_special_category_migration.py b/backend/scripts/25_execute_special_category_migration.py new file mode 100755 index 0000000..6ae8b0b --- /dev/null +++ b/backend/scripts/25_execute_special_category_migration.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +SPECIAL 카테고리 마이그레이션 실행 스크립트 +생성일: 2025.09.30 +목적: SPECIAL 카테고리 지원 추가 및 기존 자재 재분류 +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import create_engine, text +from app.database import DATABASE_URL + +def execute_special_migration(): + """SPECIAL 카테고리 마이그레이션 실행""" + engine = create_engine(DATABASE_URL) + + with engine.connect() as conn: + print("🚀 SPECIAL 카테고리 마이그레이션 시작...") + print("=" * 60) + + try: + # 1. 마이그레이션 스크립트 실행 + print("📋 1단계: 마이그레이션 스크립트 실행...") + script_path = os.path.join(os.path.dirname(__file__), '24_add_special_category_support.sql') + with open(script_path, 'r', encoding='utf-8') as f: + sql_content = f.read() + + # SQL 명령어들을 분리하여 실행 + sql_commands = sql_content.split(';') + for i, command in enumerate(sql_commands): + command = command.strip() + if command and not command.startswith('--') and command != 'COMMIT': + try: + conn.execute(text(command)) + if i % 10 == 0: # 진행상황 표시 + print(f" - 명령어 {i+1}/{len(sql_commands)} 실행 중...") + except Exception as e: + print(f" ⚠️ 명령어 실행 중 오류 (무시됨): {e}") + continue + + print("✅ 마이그레이션 스크립트 실행 완료") + + # 2. 기존 자재 재분류 확인 + print("\n📊 2단계: 기존 자재 재분류 결과 확인...") + + # SPECIAL 키워드가 포함된 자재 개수 확인 + result = conn.execute(text(""" + SELECT COUNT(*) as count + FROM materials + WHERE classified_category = 'SPECIAL' + """)).fetchone() + + special_count = result.count if result else 0 + print(f" - SPECIAL 카테고리로 분류된 자재: {special_count}개") + + # 키워드별 분류 결과 확인 + keyword_results = conn.execute(text(""" + SELECT + CASE + WHEN UPPER(original_description) LIKE '%SPECIAL%' THEN 'SPECIAL' + WHEN UPPER(original_description) LIKE '%스페셜%' THEN '스페셜' + WHEN UPPER(original_description) LIKE '%SPEC%' THEN 'SPEC' + WHEN UPPER(original_description) LIKE '%SPL%' THEN 'SPL' + END as keyword_type, + COUNT(*) as count + FROM materials + WHERE classified_category = 'SPECIAL' + GROUP BY keyword_type + ORDER BY count DESC + """)).fetchall() + + if keyword_results: + print(" - 키워드별 분류 결과:") + for row in keyword_results: + print(f" * {row.keyword_type}: {row.count}개") + + # 3. 테이블 생성 확인 + print("\n🏗️ 3단계: 새 테이블 생성 확인...") + + # special_classification_patterns 테이블 확인 + patterns_count = conn.execute(text(""" + SELECT COUNT(*) as count + FROM special_classification_patterns + """)).fetchone() + + patterns_count = patterns_count.count if patterns_count else 0 + print(f" - special_classification_patterns 테이블: {patterns_count}개 패턴 등록됨") + + # special_material_details 테이블 확인 + details_exists = conn.execute(text(""" + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'special_material_details' + ) as exists + """)).fetchone() + + if details_exists.exists: + print(" - special_material_details 테이블: 생성 완료") + else: + print(" - ❌ special_material_details 테이블: 생성 실패") + + # 4. 인덱스 생성 확인 + print("\n🔍 4단계: 인덱스 생성 확인...") + + special_indexes = conn.execute(text(""" + SELECT indexname + FROM pg_indexes + WHERE indexname LIKE '%special%' + ORDER BY indexname + """)).fetchall() + + if special_indexes: + print(" - SPECIAL 관련 인덱스:") + for idx in special_indexes: + print(f" * {idx.indexname}") + else: + print(" - ⚠️ SPECIAL 관련 인덱스가 생성되지 않았습니다.") + + # 커밋 + conn.commit() + + print("\n" + "=" * 60) + print("🎉 SPECIAL 카테고리 마이그레이션 완료!") + print(f"📊 총 {special_count}개 자재가 SPECIAL 카테고리로 분류되었습니다.") + print("🔧 새로운 기능:") + print(" - SPECIAL 키워드 자동 감지") + print(" - 도면 업로드 관리") + print(" - 특수 제작 요구사항 추적") + print(" - 승인 프로세스 지원") + + except Exception as e: + print(f"\n❌ 마이그레이션 실행 중 오류 발생: {e}") + conn.rollback() + raise + +def main(): + """메인 실행 함수""" + print("SPECIAL 카테고리 마이그레이션 실행") + print("TK-MP-Project - 특수 자재 관리 시스템") + print("=" * 60) + + try: + execute_special_migration() + except Exception as e: + print(f"\n💥 마이그레이션 실패: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/backend/scripts/26_update_bolt_material_grades.py b/backend/scripts/26_update_bolt_material_grades.py new file mode 100644 index 0000000..61ccd82 --- /dev/null +++ b/backend/scripts/26_update_bolt_material_grades.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +볼트 재질 정보 업데이트 스크립트 +A320/A194M 패턴 등을 올바르게 인식하도록 기존 볼트들의 material_grade 재분류 +""" + +import os +import sys +import psycopg2 +from psycopg2.extras import RealDictCursor + +# 프로젝트 루트 디렉토리를 Python 경로에 추가 +sys.path.append('/app') + +from app.services.bolt_classifier import classify_bolt_material + +def update_bolt_material_grades(): + """기존 볼트들의 material_grade 업데이트""" + + # 데이터베이스 연결 + try: + conn = psycopg2.connect( + host=os.getenv('DB_HOST', 'postgres'), + port=os.getenv('DB_PORT', '5432'), + database=os.getenv('DB_NAME', 'tk_mp_bom'), + user=os.getenv('DB_USER', 'tkmp_user'), + password=os.getenv('DB_PASSWORD', 'tkmp2024!') + ) + + cursor = conn.cursor(cursor_factory=RealDictCursor) + + print("🔧 볼트 재질 정보 업데이트 시작...") + + # 볼트 카테고리 자재들 조회 + cursor.execute(""" + SELECT id, original_description, material_grade, full_material_grade + FROM materials + WHERE classified_category = 'BOLT' + ORDER BY id + """) + + bolts = cursor.fetchall() + print(f"📊 총 {len(bolts)}개 볼트 발견") + + updated_count = 0 + + for bolt in bolts: + bolt_id = bolt['id'] + original_desc = bolt['original_description'] or '' + current_material_grade = bolt['material_grade'] or '' + current_full_grade = bolt['full_material_grade'] or '' + + # 볼트 재질 재분류 + material_result = classify_bolt_material(original_desc) + + if material_result and material_result.get('standard') != 'UNKNOWN': + new_standard = material_result.get('standard', '') + new_grade = material_result.get('grade', '') + + # 새로운 material_grade 구성 + if new_grade and new_grade != 'UNKNOWN': + if new_standard in new_grade: + # 이미 standard가 포함된 경우 (예: "ASTM A320/A194M") + new_material_grade = new_grade + else: + # standard + grade 조합 (예: "ASTM A193" + "B7") + new_material_grade = f"{new_standard} {new_grade}" if new_grade not in new_standard else new_standard + else: + new_material_grade = new_standard + + # 기존 값과 다른 경우에만 업데이트 + if new_material_grade != current_material_grade: + print(f"🔄 ID {bolt_id}: '{current_material_grade}' → '{new_material_grade}'") + print(f" 원본: {original_desc}") + + cursor.execute(""" + UPDATE materials + SET material_grade = %s + WHERE id = %s + """, (new_material_grade, bolt_id)) + + updated_count += 1 + + # 변경사항 커밋 + conn.commit() + + print(f"✅ 볼트 재질 정보 업데이트 완료: {updated_count}개 업데이트됨") + + # 업데이트 결과 확인 + cursor.execute(""" + SELECT material_grade, COUNT(*) as count + FROM materials + WHERE classified_category = 'BOLT' + GROUP BY material_grade + ORDER BY count DESC + """) + + results = cursor.fetchall() + print("\n📈 업데이트 후 볼트 재질 분포:") + for result in results: + print(f" {result['material_grade']}: {result['count']}개") + + except Exception as e: + print(f"❌ 오류 발생: {str(e)}") + if conn: + conn.rollback() + finally: + if cursor: + cursor.close() + if conn: + conn.close() + +if __name__ == "__main__": + update_bolt_material_grades() diff --git a/backend/scripts/PRODUCTION_MIGRATION.sql b/backend/scripts/PRODUCTION_MIGRATION.sql index 96ec9fe..f44681b 100644 --- a/backend/scripts/PRODUCTION_MIGRATION.sql +++ b/backend/scripts/PRODUCTION_MIGRATION.sql @@ -129,6 +129,83 @@ CREATE INDEX IF NOT EXISTS idx_support_details_material_id ON support_details(ma 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); +-- 8. SPECIAL 카테고리 지원 추가 +-- ================================ + +-- SPECIAL 카테고리 관련 인덱스 추가 (성능 최적화) +CREATE INDEX IF NOT EXISTS idx_materials_special_category +ON materials(classified_category) +WHERE classified_category = 'SPECIAL'; + +-- SPECIAL 키워드 패턴 테이블 +CREATE TABLE IF NOT EXISTS special_classification_patterns ( + id SERIAL PRIMARY KEY, + pattern_type VARCHAR(20) NOT NULL, -- 'KEYWORD', 'REGEX', 'EXACT' + pattern_value VARCHAR(200) NOT NULL, + description TEXT, + priority INTEGER DEFAULT 1, -- 우선순위 (낮을수록 높은 우선순위) + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 기본 SPECIAL 키워드 패턴 삽입 +INSERT INTO special_classification_patterns (pattern_type, pattern_value, description, priority) VALUES +('KEYWORD', 'SPECIAL', '영문 SPECIAL 키워드', 1), +('KEYWORD', '스페셜', '한글 스페셜 키워드', 1), +('KEYWORD', 'SPEC', '영문 SPEC 축약어', 2), +('KEYWORD', 'SPL', '영문 SPL 축약어', 2) +ON CONFLICT DO NOTHING; + +-- SPECIAL 자재 상세 정보 테이블 (도면 업로드 관련) +CREATE TABLE IF NOT EXISTS special_material_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 도면 정보 + drawing_number VARCHAR(100), -- 도면 번호 + drawing_revision VARCHAR(20), -- 도면 리비전 + drawing_uploaded BOOLEAN DEFAULT FALSE, -- 도면 업로드 여부 + drawing_file_path TEXT, -- 도면 파일 경로 + + -- 특수 요구사항 + special_requirements TEXT, -- 특수 제작 요구사항 + manufacturing_notes TEXT, -- 제작 참고사항 + approval_required BOOLEAN DEFAULT TRUE, -- 승인 필요 여부 + approved_by VARCHAR(100), -- 승인자 + approved_at TIMESTAMP, -- 승인 일시 + + -- 분류 정보 + classification_confidence FLOAT DEFAULT 1.0, + classification_reason TEXT, -- 분류 근거 + + -- 관리 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- SPECIAL 관련 인덱스 +CREATE INDEX IF NOT EXISTS idx_special_patterns_type ON special_classification_patterns(pattern_type); +CREATE INDEX IF NOT EXISTS idx_special_patterns_active ON special_classification_patterns(is_active); +CREATE INDEX IF NOT EXISTS idx_special_details_material ON special_material_details(material_id); +CREATE INDEX IF NOT EXISTS idx_special_details_drawing_uploaded ON special_material_details(drawing_uploaded); + +-- 기존 자재 중 SPECIAL 키워드가 포함된 자재를 SPECIAL 카테고리로 재분류 +UPDATE materials +SET + classified_category = 'SPECIAL', + classification_confidence = 1.0, + classified_at = CURRENT_TIMESTAMP +WHERE + ( + UPPER(original_description) LIKE '%SPECIAL%' OR + UPPER(original_description) LIKE '%스페셜%' OR + UPPER(original_description) LIKE '%SPEC%' OR + UPPER(original_description) LIKE '%SPL%' + ) + AND (classified_category IS NULL OR classified_category != 'SPECIAL'); + -- 7. 기존 데이터 정리 (선택사항) -- ================================ diff --git a/database/init/99_complete_schema.sql b/database/init/99_complete_schema.sql index 476101d..84e5116 100644 --- a/database/init/99_complete_schema.sql +++ b/database/init/99_complete_schema.sql @@ -1160,6 +1160,50 @@ CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at); CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id); +-- ================================ +-- SPECIAL 카테고리 지원 테이블 +-- ================================ + +-- SPECIAL 키워드 패턴 테이블 +CREATE TABLE IF NOT EXISTS special_classification_patterns ( + id SERIAL PRIMARY KEY, + pattern_type VARCHAR(20) NOT NULL, -- 'KEYWORD', 'REGEX', 'EXACT' + pattern_value VARCHAR(200) NOT NULL, + description TEXT, + priority INTEGER DEFAULT 1, -- 우선순위 (낮을수록 높은 우선순위) + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- SPECIAL 자재 상세 정보 테이블 (도면 업로드 관련) +CREATE TABLE IF NOT EXISTS special_material_details ( + id SERIAL PRIMARY KEY, + material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + + -- 도면 정보 + drawing_number VARCHAR(100), -- 도면 번호 + drawing_revision VARCHAR(20), -- 도면 리비전 + drawing_uploaded BOOLEAN DEFAULT FALSE, -- 도면 업로드 여부 + drawing_file_path TEXT, -- 도면 파일 경로 + + -- 특수 요구사항 + special_requirements TEXT, -- 특수 제작 요구사항 + manufacturing_notes TEXT, -- 제작 참고사항 + approval_required BOOLEAN DEFAULT TRUE, -- 승인 필요 여부 + approved_by VARCHAR(100), -- 승인자 + approved_at TIMESTAMP, -- 승인 일시 + + -- 분류 정보 + classification_confidence FLOAT DEFAULT 1.0, + classification_reason TEXT, -- 분류 근거 + + -- 관리 정보 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- ================================ -- 추가 성능 최적화 인덱스 (16_performance_indexes.sql) -- ================================ @@ -1181,6 +1225,25 @@ CREATE INDEX IF NOT EXISTS idx_materials_quantity_desc ON materials(quantity DES CREATE INDEX IF NOT EXISTS idx_materials_unverified ON materials(classified_category, classification_confidence) WHERE is_verified = false; CREATE INDEX IF NOT EXISTS idx_materials_low_confidence ON materials(file_id, classified_category) WHERE classification_confidence < 0.8; +-- SPECIAL 카테고리 전용 인덱스 +CREATE INDEX IF NOT EXISTS idx_materials_special_category ON materials(classified_category) WHERE classified_category = 'SPECIAL'; +CREATE INDEX IF NOT EXISTS idx_special_patterns_type ON special_classification_patterns(pattern_type); +CREATE INDEX IF NOT EXISTS idx_special_patterns_active ON special_classification_patterns(is_active); +CREATE INDEX IF NOT EXISTS idx_special_details_material ON special_material_details(material_id); +CREATE INDEX IF NOT EXISTS idx_special_details_drawing_uploaded ON special_material_details(drawing_uploaded); + +-- ================================ +-- SPECIAL 카테고리 기본 데이터 삽입 +-- ================================ + +-- 기본 SPECIAL 키워드 패턴 삽입 +INSERT INTO special_classification_patterns (pattern_type, pattern_value, description, priority) VALUES +('KEYWORD', 'SPECIAL', '영문 SPECIAL 키워드', 1), +('KEYWORD', '스페셜', '한글 스페셜 키워드', 1), +('KEYWORD', 'SPEC', '영문 SPEC 축약어', 2), +('KEYWORD', 'SPL', '영문 SPL 축약어', 2) +ON CONFLICT DO NOTHING; + -- 분류 관련 인덱스 (05_add_classification_columns.sql) CREATE INDEX IF NOT EXISTS idx_materials_subcategory ON materials(subcategory); CREATE INDEX IF NOT EXISTS idx_materials_standard ON materials(standard); diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css index c7450cd..62cae89 100644 --- a/frontend/src/pages/NewMaterialsPage.css +++ b/frontend/src/pages/NewMaterialsPage.css @@ -111,8 +111,15 @@ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } +.header-info { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + .materials-header h1 { - font-size: 20px; + font-size: 18px; font-weight: 600; color: #1f2937; margin: 0; @@ -120,10 +127,18 @@ .job-info { color: #6b7280; - font-size: 14px; + font-size: 13px; font-weight: 400; } +.material-count-inline { + color: #6b7280; + font-size: 12px; + background: #f3f4f6; + padding: 2px 8px; + border-radius: 8px; +} + .material-count { color: #6b7280; font-size: 14px; @@ -132,10 +147,27 @@ border-radius: 12px; } +/* 메인 헤더 */ +.materials-header { + background: white; + padding: 8px 24px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #e5e7eb; + min-height: 50px; +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + /* 카테고리 필터 */ .category-filters { background: white; - padding: 16px 24px; + padding: 12px 24px; display: flex; gap: 8px; align-items: center; @@ -262,69 +294,598 @@ margin: 0; min-width: 1500px; overflow-x: auto; + max-height: calc(100vh - 200px); /* 최대 높이만 제한 */ + overflow-y: auto; + position: relative; } .detailed-grid-header { display: grid; - grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px; - padding: 12px 24px; + /* 기본 그리드는 사용하지 않음 - 각 카테고리별 전용 클래스 사용 */ + padding: 12px 0; + margin: 0 24px; background: #f9fafb; - border-bottom: 1px solid #e5e7eb; + border-bottom: 2px solid #e5e7eb; font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; + align-items: center; + position: sticky; + top: 0; + z-index: 10; +} + +.detailed-grid-header > div { + text-align: center; + display: flex; + align-items: center; + justify-content: center; + min-height: 40px; +} + +.detailed-grid-header > div { + border-right: 1px solid #d1d5db; + padding: 0 8px; +} + +.detailed-grid-header .filterable-header { + border-right: 1px solid #d1d5db; + padding: 0 8px; +} + +.detailed-grid-header > div:last-child, +.detailed-grid-header .filterable-header:last-child { + border-right: none; + padding: 0 8px; +} + +/* PIPE 전용 헤더 - 9개 컬럼 */ +.detailed-grid-header.pipe-header { + grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px !important; + padding: 12px 0; + margin: 0 24px; + position: sticky; + top: 0; + z-index: 10; + background: #f9fafb; +} + +.detailed-grid-header.pipe-header > div, +.detailed-grid-header.pipe-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.pipe-header > div:last-child, +.detailed-grid-header.pipe-header .filterable-header:last-child { + border-right: none; +} + +/* PIPE 전용 행 - 9개 컬럼 */ +.detailed-material-row.pipe-row { + grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px !important; + padding: 8px 0; + margin: 0 24px; +} + +.detailed-material-row.pipe-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +.detailed-material-row.pipe-row .material-cell:last-child { + border-right: none; +} + +/* SPECIAL 전용 헤더 - 10개 컬럼 */ +.detailed-grid-header.special-header { + grid-template-columns: 60px 90px 150px 80px 100px 200px 120px 120px 200px 100px; + padding: 12px 0; + margin: 0 24px; + position: sticky; + top: 0; + z-index: 10; + background: #f9fafb; +} + +.detailed-grid-header.special-header > div, +.detailed-grid-header.special-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.special-header > div:last-child, +.detailed-grid-header.special-header .filterable-header:last-child { + border-right: none; +} + +/* SPECIAL 전용 행 - 10개 컬럼 */ +.detailed-material-row.special-row { + grid-template-columns: 60px 90px 150px 80px 100px 200px 120px 120px 200px 100px; + padding: 8px 0; + margin: 0 24px; +} + +/* BOLT 전용 헤더 - 9개 컬럼 */ +.detailed-grid-header.bolt-header { + grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px; + padding: 12px 0; + margin: 0 24px; + position: sticky; + top: 0; + z-index: 10; + background: #f9fafb; +} + +/* BOLT 전용 행 - 9개 컬럼 */ +.detailed-material-row.bolt-row { + grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px; + padding: 8px 0; + margin: 0 24px; +} + +.detailed-material-row.special-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +/* BOLT 헤더 테두리 */ +.detailed-grid-header.bolt-header > div, +.detailed-grid-header.bolt-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.bolt-header > div:last-child, +.detailed-grid-header.bolt-header .filterable-header:last-child { + border-right: none; +} + +/* BOLT 행 테두리 */ +.detailed-material-row.bolt-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +.detailed-material-row.bolt-row .material-cell:last-child { + border-right: none; +} + +/* BOLT 타입 배지 */ +.type-badge.bolt { + background: #7c3aed; + color: white; + border: 2px solid #6d28d9; + font-weight: 600; +} + +/* U-BOLT 전용 헤더 - 8개 컬럼 */ +.detailed-grid-header.ubolt-header { + grid-template-columns: 60px 90px 130px 80px 200px 120px 200px 100px; + padding: 12px 0; + margin: 0 24px; + position: sticky; + top: 0; + z-index: 10; + background: #f9fafb; +} + +/* U-BOLT 전용 행 - 8개 컬럼 */ +.detailed-material-row.ubolt-row { + grid-template-columns: 60px 90px 130px 80px 200px 120px 200px 100px; + padding: 8px 0; + margin: 0 24px; +} + +/* U-BOLT 헤더 테두리 */ +.detailed-grid-header.ubolt-header > div, +.detailed-grid-header.ubolt-header .filterable-header { + border-right: 1px solid #d1d5db; +} +.detailed-grid-header.ubolt-header > div:last-child, +.detailed-grid-header.ubolt-header .filterable-header:last-child { + border-right: none; +} + +/* U-BOLT 행 테두리 */ +.detailed-material-row.ubolt-row .material-cell { + border-right: 1px solid #e5e7eb; +} +.detailed-material-row.ubolt-row .material-cell:last-child { + border-right: none; +} + +/* U-BOLT 타입 배지 */ +.type-badge.ubolt { + background: #059669; + color: white; + border: 2px solid #047857; + font-weight: 600; +} + +/* URETHANE 타입 배지 */ +.type-badge.urethane { + background: #ea580c; + color: white; + border: 2px solid #c2410c; + font-weight: 600; +} + +.detailed-material-row.special-row .material-cell:last-child { + border-right: none; } /* 플랜지 전용 헤더 - 10개 컬럼 */ .detailed-grid-header.flange-header { - grid-template-columns: 40px 80px 150px 80px 100px 80px 350px 100px 150px 100px; + grid-template-columns: 60px 100px 150px 80px 100px 80px 350px 100px 150px 100px; + padding: 12px 0; + margin: 0 24px; + position: sticky; + top: 0; + z-index: 10; + background: #f9fafb; +} + +.detailed-grid-header.flange-header > div, +.detailed-grid-header.flange-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.flange-header > div:last-child, +.detailed-grid-header.flange-header .filterable-header:last-child { + border-right: none; } /* 플랜지 전용 행 - 10개 컬럼 */ .detailed-material-row.flange-row { - grid-template-columns: 40px 80px 150px 80px 100px 80px 350px 100px 150px 100px; + grid-template-columns: 60px 100px 150px 80px 100px 80px 350px 100px 150px 100px; + padding: 8px 0; + margin: 0 24px; +} + +.detailed-material-row.flange-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +.detailed-material-row.flange-row .material-cell:last-child { + border-right: none; } /* 피팅 전용 헤더 - 10개 컬럼 */ .detailed-grid-header.fitting-header { grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px; + padding: 12px 0; + margin: 0 24px; +} + +.detailed-grid-header.fitting-header > div, +.detailed-grid-header.fitting-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.fitting-header > div:last-child, +.detailed-grid-header.fitting-header .filterable-header:last-child { + border-right: none; } /* 피팅 전용 행 - 10개 컬럼 */ .detailed-material-row.fitting-row { grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px; + padding: 8px 0; + margin: 0 24px; +} + +.detailed-material-row.fitting-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +.detailed-material-row.fitting-row .material-cell:last-child { + border-right: none; } /* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */ .detailed-grid-header.valve-header { grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px; + padding: 12px 0; + margin: 0 24px; +} + +.detailed-grid-header.valve-header > div, +.detailed-grid-header.valve-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.valve-header > div:last-child, +.detailed-grid-header.valve-header .filterable-header:last-child { + border-right: none; } /* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */ .detailed-material-row.valve-row { grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px; + padding: 8px 0; + margin: 0 24px; +} + +.detailed-material-row.valve-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +.detailed-material-row.valve-row .material-cell:last-child { + border-right: none; } /* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */ .detailed-grid-header.gasket-header { grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px; + padding: 12px 0; + margin: 0 24px; +} + +.detailed-grid-header.gasket-header > div, +.detailed-grid-header.gasket-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.gasket-header > div:last-child, +.detailed-grid-header.gasket-header .filterable-header:last-child { + border-right: none; } /* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */ .detailed-material-row.gasket-row { grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px; + padding: 8px 0; + margin: 0 24px; +} + +.detailed-material-row.gasket-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +.detailed-material-row.gasket-row .material-cell:last-child { + border-right: none; +} + +/* 필터링 가능한 헤더 스타일 */ +.filterable-header { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0; + background: transparent; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; + min-height: 40px; +} + +.filterable-header:hover { + background: #f1f5f9; +} + +.header-text { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + white-space: nowrap; + margin: 0; +} + +.header-controls { + display: flex; + gap: 2px; + opacity: 0.7; + flex-shrink: 0; + transition: opacity 0.2s ease; +} + +.filterable-header:hover .header-controls { + opacity: 1; +} + +.sort-btn, .filter-btn { + background: white; + border: 1px solid #e2e8f0; + padding: 1px; + border-radius: 3px; + cursor: pointer; + font-size: 9px; + color: #64748b; + transition: all 0.15s ease; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.sort-btn:hover, .filter-btn:hover { + background: #f8fafc; + border-color: #cbd5e1; + color: #475569; +} + +.sort-btn.active { + background: #3b82f6; + border-color: #3b82f6; + color: white; +} + +.filter-btn.active { + background: #10b981; + border-color: #10b981; + color: white; +} + +/* 필터 드롭다운 */ +.filter-dropdown { + position: absolute; + top: calc(100% + 2px); + left: 0; + right: 0; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + z-index: 1000; + max-height: 240px; + overflow: hidden; + display: flex; + flex-direction: column; + animation: dropdownFadeIn 0.15s ease-out; +} + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.filter-search { + padding: 8px; + border-bottom: 1px solid #f1f5f9; + background: #fafbfc; + display: flex; + align-items: center; + gap: 6px; +} + +.filter-search input { + flex: 1; + padding: 6px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 12px; + outline: none; + transition: border-color 0.15s ease; + background: white; + color: #374151; +} + +.filter-search input:focus { + border-color: #3b82f6; +} + +.filter-search input::placeholder { + color: #9ca3af; + font-size: 11px; +} + +.clear-filter-btn { + background: #ef4444; + color: white; + border: none; + padding: 4px 6px; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + font-weight: 500; + transition: background-color 0.15s ease; +} + +.clear-filter-btn:hover { + background: #dc2626; +} + +.filter-options { + flex: 1; + overflow-y: auto; + max-height: 160px; +} + +.filter-option-header { + padding: 4px 10px; + font-size: 10px; + font-weight: 600; + color: #6b7280; + background: #f8fafc; + border-bottom: 1px solid #f1f5f9; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.filter-option { + padding: 4px 10px; + font-size: 11px; + cursor: pointer; + transition: background-color 0.1s ease; + color: #374151; +} + +.filter-option:hover { + background: #f8fafc; +} + +.filter-option-more { + padding: 4px 10px; + font-size: 10px; + color: #9ca3af; + font-style: italic; + text-align: center; + background: #f8fafc; +} + +/* 액션 바 스타일 개선 */ +.filter-info { + font-size: 11px; + color: #6b7280; + margin-left: 8px; +} + +.clear-filters-btn { + background: #f59e0b; + color: white; + border: none; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-right: 8px; + transition: background-color 0.2s; +} + +.clear-filters-btn:hover { + background: #d97706; } /* UNKNOWN 전용 헤더 - 5개 컬럼 */ .detailed-grid-header.unknown-header { grid-template-columns: 40px 100px 1fr 150px 100px; + padding: 12px 0; + margin: 0 24px; +} + +.detailed-grid-header.unknown-header > div, +.detailed-grid-header.unknown-header .filterable-header { + border-right: 1px solid #d1d5db; +} + +.detailed-grid-header.unknown-header > div:last-child, +.detailed-grid-header.unknown-header .filterable-header:last-child { + border-right: none; } /* UNKNOWN 전용 행 - 5개 컬럼 */ .detailed-material-row.unknown-row { grid-template-columns: 40px 100px 1fr 150px 100px; + padding: 8px 0; + margin: 0 24px; +} + +.detailed-material-row.unknown-row .material-cell { + border-right: 1px solid #e5e7eb; +} + +.detailed-material-row.unknown-row .material-cell:last-child { + border-right: none; } /* UNKNOWN 설명 셀 스타일 */ @@ -345,14 +906,25 @@ .detailed-material-row { display: grid; - grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px; - padding: 12px 24px; - border-bottom: 1px solid #f3f4f6; + /* 기본 그리드는 사용하지 않음 - 각 카테고리별 전용 클래스 사용 */ + padding: 12px 0; + margin: 0 24px; + border-bottom: 1px solid #e5e7eb; align-items: center; transition: background 0.15s; font-size: 13px; } +.detailed-material-row .material-cell { + border-right: 1px solid #e5e7eb; + padding: 0 8px; +} + +.detailed-material-row .material-cell:last-child { + border-right: none; + padding: 0 8px; +} + .detailed-material-row:hover { background: #fafbfc; } @@ -364,28 +936,62 @@ .material-cell { overflow: visible !important; text-overflow: initial !important; + text-align: center; + display: flex; + align-items: center; + justify-content: center; white-space: normal !important; - padding-right: 12px; word-break: break-word; min-width: 120px; max-width: none !important; + min-height: 40px; +} + +.material-cell > * { + margin: 0; + flex-shrink: 0; +} + +/* 사용자 요구사항 입력 필드 */ +.user-req-input { + width: 100%; + padding: 4px 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + outline: none; + text-align: center; + margin: 0; +} + +.user-req-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 1px #3b82f6; } .material-cell input[type="checkbox"] { - width: 16px; - height: 16px; + width: 14px; + height: 14px; cursor: pointer; + margin: 0; + padding: 0; + flex-shrink: 0; + box-sizing: border-box; } /* 타입 배지 */ .type-badge { - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; + margin: 0; + flex-shrink: 0; } .type-badge.pipe { @@ -418,6 +1024,14 @@ color: white; } +.type-badge.special { + background: #dc2626; + color: white; + border: 2px solid #b91c1c; + font-weight: 700; + box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2); +} + .type-badge.unknown { background: #6b7280; color: white; @@ -428,11 +1042,6 @@ color: white; } -.type-badge.unknown { - background: #9ca3af; - color: white; -} - /* 텍스트 스타일 */ .subtype-text, .size-text, diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx index 7034956..c560002 100644 --- a/frontend/src/pages/NewMaterialsPage.jsx +++ b/frontend/src/pages/NewMaterialsPage.jsx @@ -28,6 +28,11 @@ const NewMaterialsPage = ({ // materialId: requirement 형태 const [savingRequirements, setSavingRequirements] = useState(false); + // 정렬 및 필터링 상태 + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + // 같은 BOM의 다른 리비전들 조회 const loadAvailableRevisions = async () => { try { @@ -63,6 +68,20 @@ const NewMaterialsPage = ({ } }, [fileId]); + // 외부 클릭 시 필터 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (event) => { + if (!event.target.closest('.filterable-header')) { + setShowFilterDropdown(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + const loadMaterials = async (id) => { try { setLoading(true); @@ -272,7 +291,9 @@ const NewMaterialsPage = ({ // 카테고리 표시명 매핑 const getCategoryDisplayName = (category) => { const categoryMap = { - 'SUPPORT': 'U-BOLT', + 'SPECIAL': 'SPECIAL', + 'U_BOLT': 'U-BOLT', + 'SUPPORT': 'SUPPORT', 'PIPE': 'PIPE', 'FITTING': 'FITTING', 'FLANGE': 'FLANGE', @@ -315,24 +336,24 @@ const NewMaterialsPage = ({ const descUpper = description.toUpperCase(); const additionalReqs = []; - // 표면처리 패턴들 + // 표면처리 패턴들 (원본 영어 약어 사용) const surfaceTreatments = { - 'ELEC.GALV': '전기아연도금', - 'ELEC GALV': '전기아연도금', - 'GALVANIZED': '아연도금', - 'GALV': '아연도금', - 'HOT DIP GALV': '용융아연도금', - 'HDG': '용융아연도금', - 'ZINC PLATED': '아연도금', - 'ZINC': '아연도금', - 'STAINLESS': '스테인리스', - 'SS': '스테인리스' + 'ELEC.GALV': 'ELEC.GALV', + 'ELEC GALV': 'ELEC.GALV', + 'GALVANIZED': 'GALVANIZED', + 'GALV': 'GALV', + 'HOT DIP GALV': 'HDG', + 'HDG': 'HDG', + 'ZINC PLATED': 'ZINC PLATED', + 'ZINC': 'ZINC', + 'STAINLESS': 'STAINLESS', + 'SS': 'SS' }; // 표면처리 확인 - for (const [pattern, korean] of Object.entries(surfaceTreatments)) { + for (const [pattern, treatment] of Object.entries(surfaceTreatments)) { if (descUpper.includes(pattern)) { - additionalReqs.push(korean); + additionalReqs.push(treatment); } } @@ -581,35 +602,80 @@ const NewMaterialsPage = ({ const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율 const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수 - // 볼트 길이 추출 (원본 설명에서) - const description = material.original_description || ''; + // 볼트 상세 정보 우선 사용 + const boltDetails = material.bolt_details || {}; + + // 길이 정보 (bolt_details 우선, 없으면 원본 설명에서 추출) let boltLength = '-'; + if (boltDetails.length && boltDetails.length !== '-') { + boltLength = boltDetails.length; + } else { + // 원본 설명에서 길이 추출 + const description = material.original_description || ''; + 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) { + let lengthValue = match[1]; + // 소수점 제거 (145.0000 → 145) + if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) { + lengthValue = lengthValue.split('.')[0]; + } else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) { + lengthValue = lengthValue.split('.')[0]; + } + boltLength = `${lengthValue}mm`; + break; + } + } + } - // 길이 패턴 추출 (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 형태 - ]; + // 재질 정보 (bolt_details 우선, 없으면 기본 필드 사용) + let boltGrade = '-'; + if (boltDetails.material_standard && boltDetails.material_grade) { + // bolt_details에서 완전한 재질 정보 구성 + if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) { + boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`; + } else { + boltGrade = boltDetails.material_standard; + } + } else if (material.full_material_grade && material.full_material_grade !== '-') { + boltGrade = material.full_material_grade; + } else if (material.material_grade && material.material_grade !== '-') { + boltGrade = material.material_grade; + } - for (const pattern of lengthPatterns) { - const match = description.match(pattern); - if (match) { - boltLength = `${match[1]}mm`; - break; + // 볼트 타입 (PSV_BOLT, LT_BOLT 등) + let boltSubtype = 'BOLT_GENERAL'; + if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') { + boltSubtype = boltDetails.bolt_type; + } else { + // 원본 설명에서 특수 볼트 타입 추출 + const description = material.original_description || ''; + const upperDesc = description.toUpperCase(); + if (upperDesc.includes('PSV')) { + boltSubtype = 'PSV_BOLT'; + } else if (upperDesc.includes('LT')) { + boltSubtype = 'LT_BOLT'; + } else if (upperDesc.includes('CK')) { + boltSubtype = 'CK_BOLT'; } } // 추가요구사항 추출 (ELEC.GALV 등) - const additionalReq = extractBoltAdditionalRequirements(description); + const additionalReq = extractBoltAdditionalRequirements(material.original_description || ''); return { type: 'BOLT', - subtype: material.bolt_details?.bolt_type || 'BOLT_GENERAL', - size: material.size_spec || '-', + subtype: boltSubtype, + size: material.size_spec || material.main_nom || '-', schedule: boltLength, // 길이 정보 - grade: material.full_material_grade || material.material_grade || '-', + grade: boltGrade, additionalReq: additionalReq, // 추가요구사항 quantity: purchaseQty, unit: 'SETS' @@ -679,13 +745,130 @@ const NewMaterialsPage = ({ } }; - // 필터링된 자재 목록 - const filteredMaterials = materials.filter(material => { - if (selectedCategory === 'ALL') { - return true; // 전체 카테고리일 때는 모든 자재 표시 + // 정렬 함수 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; } - return material.classified_category === selectedCategory; - }); + setSortConfig({ key, direction }); + }; + + // 필터 함수 + const handleFilter = (column, value) => { + setColumnFilters(prev => ({ + ...prev, + [column]: value + })); + }; + + // 필터 초기화 + const clearFilter = (column) => { + setColumnFilters(prev => { + const newFilters = { ...prev }; + delete newFilters[column]; + return newFilters; + }); + }; + + // 모든 필터 초기화 + const clearAllFilters = () => { + setColumnFilters({}); + setSortConfig({ key: null, direction: 'asc' }); + }; + + // 필터링된 자재 목록 + const filteredMaterials = materials + .filter(material => { + // 카테고리 필터 + if (selectedCategory !== 'ALL' && material.classified_category !== selectedCategory) { + return false; + } + + // 컬럼 필터 적용 + for (const [column, filterValue] of Object.entries(columnFilters)) { + if (!filterValue) continue; + + const info = parseMaterialInfo(material); + let materialValue = ''; + + switch (column) { + case 'type': + materialValue = info.type || ''; + break; + case 'subtype': + materialValue = info.subtype || ''; + break; + case 'size': + materialValue = info.size || ''; + break; + case 'schedule': + materialValue = info.schedule || ''; + break; + case 'grade': + materialValue = info.grade || ''; + break; + case 'quantity': + materialValue = info.quantity?.toString() || ''; + break; + default: + materialValue = material[column]?.toString() || ''; + } + + if (!materialValue.toLowerCase().includes(filterValue.toLowerCase())) { + return false; + } + } + + return true; + }) + .sort((a, b) => { + if (!sortConfig.key) return 0; + + const aInfo = parseMaterialInfo(a); + const bInfo = parseMaterialInfo(b); + + let aValue, bValue; + + switch (sortConfig.key) { + case 'type': + aValue = aInfo.type || ''; + bValue = bInfo.type || ''; + break; + case 'subtype': + aValue = aInfo.subtype || ''; + bValue = bInfo.subtype || ''; + break; + case 'size': + aValue = aInfo.size || ''; + bValue = bInfo.size || ''; + break; + case 'schedule': + aValue = aInfo.schedule || ''; + bValue = bInfo.schedule || ''; + break; + case 'grade': + aValue = aInfo.grade || ''; + bValue = bInfo.grade || ''; + break; + case 'quantity': + aValue = aInfo.quantity || 0; + bValue = bInfo.quantity || 0; + break; + default: + aValue = a[sortConfig.key] || ''; + bValue = b[sortConfig.key] || ''; + } + + // 숫자 비교 + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue; + } + + // 문자열 비교 + const comparison = aValue.toString().localeCompare(bValue.toString()); + return sortConfig.direction === 'asc' ? comparison : -comparison; + }); // 카테고리 색상 (제거 - CSS에서 처리) @@ -709,7 +892,124 @@ const NewMaterialsPage = ({ setSelectedMaterials(newSelection); }; - // 엑셀 내보내기 - 화면에 표시된 그대로 + // 필터 헤더 컴포넌트 + const FilterableHeader = ({ children, sortKey, filterKey, className = "" }) => { + const uniqueValues = React.useMemo(() => { + const values = new Set(); + + // 현재 선택된 카테고리의 자재들만 필터링 + const categoryMaterials = materials.filter(material => { + if (selectedCategory === 'ALL') return true; + return material.classified_category === selectedCategory; + }); + + categoryMaterials.forEach(material => { + const info = parseMaterialInfo(material); + let value = ''; + + switch (filterKey) { + case 'type': + value = info.type || ''; + break; + case 'subtype': + value = info.subtype || ''; + break; + case 'size': + value = info.size || ''; + break; + case 'schedule': + value = info.schedule || ''; + break; + case 'grade': + value = info.grade || ''; + break; + case 'quantity': + value = info.quantity?.toString() || ''; + break; + default: + value = material[filterKey]?.toString() || ''; + } + + if (value) values.add(value); + }); + + return Array.from(values).sort(); + }, [materials, filterKey, selectedCategory]); + + return ( +
+ {children} +
+ {/* 정렬 버튼 */} + + + {/* 필터 버튼 */} + +
+ + {/* 필터 드롭다운 */} + {showFilterDropdown === filterKey && ( +
+
+ handleFilter(filterKey, e.target.value)} + autoFocus + /> + {columnFilters[filterKey] && ( + + )} +
+ +
+
값 목록:
+ {uniqueValues.slice(0, 20).map(value => ( +
{ + handleFilter(filterKey, value); + setShowFilterDropdown(null); + }} + > + {value} +
+ ))} + {uniqueValues.length > 20 && ( +
+ +{uniqueValues.length - 20}개 더... +
+ )} +
+
+ )} +
+ ); + }; + + // 엑셀 내보내기 - 개선된 버전 사용 const exportToExcel = () => { try { // 내보낼 데이터 결정 (선택 항목 또는 현재 카테고리 전체) @@ -719,68 +1019,23 @@ const NewMaterialsPage = ({ console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목'); - // 새로운 엑셀 양식에 맞춘 컬럼 구성 - const getExcelData = (material) => { - const info = parseMaterialInfo(material); - - // 품목명 생성 (간단하게) - let itemName = ''; - if (selectedCategory === 'PIPE') { - 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') { - itemName = info.subtype || 'BOLT'; - } else { - 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] // 오늘 날짜 - }; + // 사용자 요구사항을 자재에 추가 + const dataWithRequirements = dataToExport.map(material => ({ + ...material, + user_requirement: userRequirements[material.id] || '' + })); + + // 개선된 엑셀 내보내기 함수 사용 + const additionalInfo = { + filename: filename || bomName, + jobNo: jobNo, + revision: currentRevision, + uploadDate: new Date().toLocaleDateString() }; - // 엑셀 데이터 생성 - const excelData = dataToExport.map(material => getExcelData(material)); - - // 워크북 생성 - const ws = XLSX.utils.json_to_sheet(excelData); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, selectedCategory); - - // 파일명 생성 const fileName = `${selectedCategory}_${jobNo || 'export'}_${new Date().toISOString().split('T')[0]}.xlsx`; - // 파일 저장 - const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); - const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); - saveAs(data, fileName); + exportMaterialsToExcel(dataWithRequirements, fileName, additionalInfo); console.log('✅ 엑셀 내보내기 성공'); } catch (error) { @@ -822,12 +1077,17 @@ const NewMaterialsPage = ({ > ← -

자재 목록

- {jobNo && ( - - {jobNo} - {bomName} +
+

자재 목록

+ {jobNo && ( + + {jobNo} - {bomName} + + )} + + 총 {materials.length}개 자재 ({currentRevision}) - )} +
{availableRevisions.length > 1 && ( @@ -868,15 +1128,11 @@ const NewMaterialsPage = ({ border: 'none', borderRadius: '4px', fontSize: '11px', - cursor: 'pointer', - marginRight: '12px' + cursor: 'pointer' }} > 🔗 URL - - 총 {materials.length}개 자재 ({currentRevision}) -
@@ -891,7 +1147,16 @@ const NewMaterialsPage = ({ 전체 {materials.length} - {Object.entries(categoryCounts).map(([category, count]) => ( + {/* SPECIAL 카테고리 우선 표시 */} + + + {Object.entries(categoryCounts).filter(([category]) => category !== 'SPECIAL').map(([category, count]) => ( + )}