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