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