🔧 재질 정보 표시 개선 및 UI 확장
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:
Hyungi Ahn
2025-09-25 08:32:17 +09:00
parent af4ad25a54
commit 0f9a5ad2ea
29 changed files with 1281 additions and 58 deletions

102
RULES.md
View File

@@ -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일 (사용자 피드백 기반 개선사항 정리)

View File

@@ -264,4 +264,9 @@ jwt_service = JWTService()

View File

@@ -318,4 +318,9 @@ async def get_current_user_optional(

View File

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

View File

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

View 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)
}

View File

@@ -287,3 +287,8 @@ def get_revision_comparison(db: Session, job_no: str, current_revision: str,

View 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)

View File

@@ -233,4 +233,9 @@ END $$;

View 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 (개별 자재별 요구사항 연결)';

View 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 등)';

View 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 '지지하는 파이프 크기';

View File

@@ -70,3 +70,8 @@ COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자';

20
docker-compose.dev.yml Normal file
View 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
View 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"]

View File

@@ -233,4 +233,9 @@ export default SimpleDashboard;

View File

@@ -550,4 +550,9 @@

View File

@@ -283,4 +283,9 @@ export default NavigationBar;

View File

@@ -263,4 +263,9 @@

View File

@@ -187,4 +187,9 @@ export default NavigationMenu;

View File

@@ -95,4 +95,9 @@ export default RevisionUploadDialog;

View File

@@ -314,4 +314,9 @@ export default SimpleFileUpload;

View File

@@ -277,4 +277,9 @@ export default DashboardPage;

View File

@@ -232,4 +232,9 @@

View File

@@ -129,4 +129,9 @@ export default LoginPage;

View File

@@ -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;
} }
/* 입력 필드 */ /* 입력 필드 */

View File

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

View File

@@ -401,4 +401,9 @@ export default ProjectsPage;

View File

@@ -443,4 +443,9 @@