feat: 사용자 요구사항 기능 완전 구현 및 전체 카테고리 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 사용자 요구사항 저장/로드/엑셀 내보내기 기능 완전 구현
- 백엔드 API 수정: Request Body 방식으로 변경
- 데이터베이스 스키마: material_id 컬럼 추가
- 프론트엔드 상태 관리 개선: 저장 후 자동 리로드
- 입력 필드 연결 문제 해결: 누락된 onChange 핸들러 추가
- NewMaterialsPage에 '전체' 카테고리 버튼 추가 (기본 선택)
- Docker 환경 개선: 프론트엔드 볼륨 마운트 및 포트 수정
- UI 개선: 벌레 이모지 제거, 디버그 코드 정리
This commit is contained in:
Hyungi Ahn
2025-09-30 08:55:20 +09:00
parent 0f9a5ad2ea
commit 50570e4624
34 changed files with 942 additions and 181 deletions

View File

@@ -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일 (메인 서버 배포 가이드 추가)

View File

@@ -268,5 +268,6 @@ jwt_service = JWTService()

View File

@@ -322,5 +322,6 @@ async def get_current_user_optional(

View File

@@ -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' 등

View File

@@ -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]

View File

@@ -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:
"""볼트 구매 정보 생성"""

View File

@@ -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"
}

View File

@@ -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"

View File

@@ -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:

View File

@@ -291,4 +291,5 @@ def get_revision_comparison(db: Session, job_no: str, current_revision: str,

View File

@@ -237,5 +237,6 @@ END $$;

View File

@@ -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 $$;

View File

@@ -74,4 +74,5 @@ COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자';

View File

@@ -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);

View File

@@ -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:

View File

@@ -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:

View File

@@ -237,5 +237,6 @@ export default SimpleDashboard;

View File

@@ -554,5 +554,6 @@

View File

@@ -287,5 +287,6 @@ export default NavigationBar;

View File

@@ -267,5 +267,6 @@

View File

@@ -191,5 +191,6 @@ export default NavigationMenu;

View File

@@ -99,5 +99,6 @@ export default RevisionUploadDialog;

View File

@@ -318,5 +318,6 @@ export default SimpleFileUpload;

View File

@@ -281,5 +281,6 @@ export default DashboardPage;

View File

@@ -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'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div style={{ fontSize: '24px' }}>🐛</div>
<div style={{ fontSize: '24px' }}></div>
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
최근 오류
</h3>
@@ -458,7 +458,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
background: '#f8f9fa'
}}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
🐛 프론트엔드 오류
프론트엔드 오류
</h2>
</div>

View File

@@ -236,5 +236,6 @@

View File

@@ -133,5 +133,6 @@ export default LoginPage;

View File

@@ -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;

View File

@@ -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`, {
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 => ({
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,13 +464,27 @@ const NewMaterialsPage = ({
pressure = `${pressureMatch[1]}LB`;
}
// 스케줄 찾기
if (description.includes('SCH')) {
// 스케줄 표시 (분리 스케줄 지원)
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]}`;
}
}
}
return {
type: 'FITTING',
@@ -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 = ({
)}
</div>
<div className="header-right">
<button
onClick={() => {
const currentUrl = window.location.href;
const pageInfo = `페이지: NewMaterialsPage\nURL: ${currentUrl}\nJob: ${jobNo}\nRevision: ${currentRevision}`;
alert(pageInfo);
}}
style={{
padding: '4px 8px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '11px',
cursor: 'pointer',
marginRight: '12px'
}}
>
🔗 URL
</button>
<span className="material-count">
{materials.length} 자재 ({currentRevision})
</span>
@@ -702,13 +882,22 @@ const NewMaterialsPage = ({
{/* 카테고리 필터 */}
<div className="category-filters">
{/* 전체 카테고리 버튼 */}
<button
key="ALL"
className={`category-btn ${selectedCategory === 'ALL' ? 'active' : ''}`}
onClick={() => setSelectedCategory('ALL')}
>
전체 <span className="count">{materials.length}</span>
</button>
{Object.entries(categoryCounts).map(([category, count]) => (
<button
key={category}
className={`category-btn ${selectedCategory === category ? 'active' : ''}`}
onClick={() => setSelectedCategory(category)}
>
{category} <span className="count">{count}</span>
{getCategoryDisplayName(category)} <span className="count">{count}</span>
</button>
))}
</div>
@@ -885,7 +1074,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
<span>{info.additionalReq || '-'}</span>
</div>
{/* 사용자요구 */}
@@ -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);
}}
/>
</div>
@@ -954,7 +1146,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
<span>{info.additionalReq || '-'}</span>
</div>
{/* 사용자요구 */}
@@ -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);
}}
/>
</div>
@@ -1030,7 +1225,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
<span>{info.additionalReq || '-'}</span>
</div>
{/* 사용자요구 */}
@@ -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);
}}
/>
</div>
@@ -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);
}}
/>
</div>
@@ -1164,7 +1365,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
<span>{info.additionalReq || '-'}</span>
</div>
{/* 사용자요구 */}
@@ -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);
}}
/>
</div>
@@ -1234,7 +1438,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */}
<div className="material-cell">
<span>-</span>
<span>{info.additionalReq || '-'}</span>
</div>
{/* 사용자요구 */}
@@ -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)}
/>
</div>

View File

@@ -405,5 +405,6 @@ export default ProjectsPage;

View File

@@ -447,5 +447,6 @@

View File

@@ -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 ? '신규' : '변경'
);

View File

@@ -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',

2
test_bom.csv Normal file
View File

@@ -0,0 +1,2 @@
Item,Description,Quantity
1,Test Item,10
1 Item Description Quantity
2 1 Test Item 10