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

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

View File

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

View File

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

View File

@@ -284,6 +284,7 @@ class UserRequirement(Base):
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
material_id = Column(Integer, ForeignKey("materials.id"), nullable=True) # 자재 ID (개별 자재별 요구사항 연결)
# 요구사항 타입
requirement_type = Column(String(50), nullable=False) # 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, R
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Optional, Dict
from pydantic import BaseModel
import os
import shutil
from datetime import datetime
@@ -2629,6 +2630,7 @@ async def get_user_requirements(
{
"id": req.id,
"file_id": req.file_id,
"material_id": req.material_id,
"original_filename": req.original_filename,
"job_no": req.job_no,
"revision": req.revision,
@@ -2651,17 +2653,20 @@ async def get_user_requirements(
except Exception as e:
raise HTTPException(status_code=500, detail=f"사용자 요구사항 조회 실패: {str(e)}")
class UserRequirementCreate(BaseModel):
file_id: int
material_id: Optional[int] = None
requirement_type: str
requirement_title: str
requirement_description: Optional[str] = None
requirement_spec: Optional[str] = None
priority: str = "NORMAL"
assigned_to: Optional[str] = None
due_date: Optional[str] = None
@router.post("/user-requirements")
async def create_user_requirement(
file_id: int,
requirement_type: str,
requirement_title: str,
material_id: Optional[int] = None,
requirement_description: Optional[str] = None,
requirement_spec: Optional[str] = None,
priority: str = "NORMAL",
assigned_to: Optional[str] = None,
due_date: Optional[str] = None,
requirement: UserRequirementCreate,
db: Session = Depends(get_db)
):
"""
@@ -2681,15 +2686,15 @@ async def create_user_requirement(
""")
result = db.execute(insert_query, {
"file_id": file_id,
"material_id": material_id,
"requirement_type": requirement_type,
"requirement_title": requirement_title,
"requirement_description": requirement_description,
"requirement_spec": requirement_spec,
"priority": priority,
"assigned_to": assigned_to,
"due_date": due_date
"file_id": requirement.file_id,
"material_id": requirement.material_id,
"requirement_type": requirement.requirement_type,
"requirement_title": requirement.requirement_title,
"requirement_description": requirement.requirement_description,
"requirement_spec": requirement.requirement_spec,
"priority": requirement.priority,
"assigned_to": requirement.assigned_to,
"due_date": requirement.due_date
})
requirement_id = result.fetchone()[0]

View File

@@ -1053,6 +1053,68 @@ def calculate_bolt_confidence(confidence_scores: Dict) -> float:
# ========== 특수 기능들 ==========
def extract_bolt_additional_requirements(description: str) -> str:
"""볼트 설명에서 추가요구사항 추출 (표면처리, 특수 요구사항 등)"""
desc_upper = description.upper()
additional_reqs = []
# 표면처리 패턴들
surface_treatments = {
'ELEC.GALV': '전기아연도금',
'ELEC GALV': '전기아연도금',
'GALVANIZED': '아연도금',
'GALV': '아연도금',
'HOT DIP GALV': '용융아연도금',
'HDG': '용융아연도금',
'ZINC PLATED': '아연도금',
'ZINC': '아연도금',
'STAINLESS': '스테인리스',
'SS': '스테인리스',
'PASSIVATED': '부동태화',
'ANODIZED': '아노다이징',
'BLACK OXIDE': '흑색산화',
'PHOSPHATE': '인산처리',
'DACROMET': '다크로메트',
'GEOMET': '지오메트'
}
# 특수 요구사항 패턴들
special_requirements = {
'HEAVY HEX': '중육각',
'FULL THREAD': '전나사',
'PARTIAL THREAD': '부분나사',
'FINE THREAD': '세나사',
'COARSE THREAD': '조나사',
'LEFT HAND': '좌나사',
'RIGHT HAND': '우나사',
'SOCKET HEAD': '소켓헤드',
'BUTTON HEAD': '버튼헤드',
'FLAT HEAD': '평머리',
'PAN HEAD': '팬헤드',
'TRUSS HEAD': '트러스헤드',
'WASHER FACE': '와셔면',
'SERRATED': '톱니형',
'LOCK': '잠금',
'SPRING': '스프링',
'WAVE': '웨이브'
}
# 표면처리 확인
for pattern, korean in surface_treatments.items():
if pattern in desc_upper:
additional_reqs.append(korean)
# 특수 요구사항 확인
for pattern, korean in special_requirements.items():
if pattern in desc_upper:
additional_reqs.append(korean)
# 중복 제거 및 정렬
additional_reqs = list(set(additional_reqs))
return ', '.join(additional_reqs) if additional_reqs else ''
def get_bolt_purchase_info(bolt_result: Dict) -> Dict:
"""볼트 구매 정보 생성"""

View File

@@ -230,8 +230,8 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
# 4. 압력 등급 분류
pressure_result = classify_pressure_rating(dat_file, description)
# 4.5. 스케줄 분류 (니플 등에 중요)
schedule_result = classify_fitting_schedule(description)
# 4.5. 스케줄 분류 (니플 등에 중요) - 분리 스케줄 지원
schedule_result = classify_fitting_schedule_with_reducing(description, main_nom, red_nom)
# 5. 제작 방법 추정
manufacturing_result = determine_fitting_manufacturing(
@@ -304,6 +304,123 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
})
}
def analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict:
"""
실제 BOM 패턴 기반 TEE vs REDUCER 구분
실제 패턴:
- TEE RED, SMLS, SCH 40 x SCH 80 → TEE (키워드 우선)
- RED CONC, SMLS, SCH 80 x SCH 80 → REDUCER (키워드 우선)
- 모두 A x B 형태 (메인 x 감소)
"""
desc_upper = description.upper()
# 1. 키워드 기반 분류 (최우선) - 실제 BOM 패턴
if "TEE RED" in desc_upper or "TEE REDUCING" in desc_upper:
return {
"type": "TEE",
"subtype": "REDUCING",
"confidence": 0.95,
"evidence": ["KEYWORD_TEE_RED"],
"subtype_confidence": 0.95,
"requires_two_sizes": False
}
if "RED CONC" in desc_upper or "REDUCER CONC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "CONCENTRIC",
"confidence": 0.95,
"evidence": ["KEYWORD_RED_CONC"],
"subtype_confidence": 0.95,
"requires_two_sizes": True
}
if "RED ECC" in desc_upper or "REDUCER ECC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "ECCENTRIC",
"confidence": 0.95,
"evidence": ["KEYWORD_RED_ECC"],
"subtype_confidence": 0.95,
"requires_two_sizes": True
}
# 2. 사이즈 패턴 분석 (보조) - 기존 로직 유지
# x 또는 × 기호로 연결된 사이즈들 찾기
connected_sizes = re.findall(r'(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?(?:\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?)?', description)
if connected_sizes:
# 연결된 사이즈들을 리스트로 변환
sizes = []
for size_group in connected_sizes:
for size in size_group:
if size.strip():
sizes.append(size.strip())
# 중복 제거하되 순서 유지
unique_sizes = []
for size in sizes:
if size not in unique_sizes:
unique_sizes.append(size)
sizes = unique_sizes
if len(sizes) == 3:
# A x B x B 패턴 → TEE REDUCING
if sizes[1] == sizes[2]:
return {
"type": "TEE",
"subtype": "REDUCING",
"confidence": 0.85,
"evidence": [f"SIZE_PATTERN_TEE_REDUCING: {' x '.join(sizes)}"],
"subtype_confidence": 0.85,
"requires_two_sizes": False
}
# A x B x C 패턴 → TEE REDUCING (모두 다른 사이즈)
else:
return {
"type": "TEE",
"subtype": "REDUCING",
"confidence": 0.80,
"evidence": [f"SIZE_PATTERN_TEE_REDUCING_UNEQUAL: {' x '.join(sizes)}"],
"subtype_confidence": 0.80,
"requires_two_sizes": False
}
elif len(sizes) == 2:
# A x B 패턴 → 키워드가 없으면 REDUCER로 기본 분류
if "CONC" in desc_upper or "CONCENTRIC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "CONCENTRIC",
"confidence": 0.80,
"evidence": [f"SIZE_PATTERN_REDUCER_CONC: {' x '.join(sizes)}"],
"subtype_confidence": 0.80,
"requires_two_sizes": True
}
elif "ECC" in desc_upper or "ECCENTRIC" in desc_upper:
return {
"type": "REDUCER",
"subtype": "ECCENTRIC",
"confidence": 0.80,
"evidence": [f"SIZE_PATTERN_REDUCER_ECC: {' x '.join(sizes)}"],
"subtype_confidence": 0.80,
"requires_two_sizes": True
}
else:
# 키워드 없는 A x B 패턴은 낮은 신뢰도로 REDUCER
return {
"type": "REDUCER",
"subtype": "CONCENTRIC", # 기본값
"confidence": 0.60,
"evidence": [f"SIZE_PATTERN_REDUCER_DEFAULT: {' x '.join(sizes)}"],
"subtype_confidence": 0.60,
"requires_two_sizes": True
}
return {"confidence": 0.0}
def classify_fitting_type(dat_file: str, description: str,
main_nom: str, red_nom: str = None) -> Dict:
"""피팅 타입 분류"""
@@ -311,6 +428,11 @@ def classify_fitting_type(dat_file: str, description: str,
dat_upper = dat_file.upper()
desc_upper = description.upper()
# 0. 사이즈 패턴 분석으로 TEE vs REDUCER 구분 (최우선)
size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom)
if size_pattern_result.get("confidence", 0) > 0.85:
return size_pattern_result
# 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
for fitting_type, type_data in FITTING_TYPES.items():
for pattern in type_data["dat_file_patterns"]:
@@ -679,3 +801,53 @@ def classify_fitting_schedule(description: str) -> Dict:
"confidence": 0.0,
"matched_pattern": ""
}
def classify_fitting_schedule_with_reducing(description: str, main_nom: str, red_nom: str = None) -> Dict:
"""
실제 BOM 패턴 기반 분리 스케줄 처리
실제 패턴:
- "TEE RED, SMLS, SCH 40 x SCH 80" → main: SCH 40, red: SCH 80
- "RED CONC, SMLS, SCH 40S x SCH 40S" → main: SCH 40S, red: SCH 40S
- "RED CONC, SMLS, SCH 80 x SCH 80" → main: SCH 80, red: SCH 80
"""
desc_upper = description.upper()
# 1. 분리 스케줄 패턴 확인 (SCH XX x SCH YY) - 개선된 패턴
separated_schedule_patterns = [
r'SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)', # SCH 40 x SCH 80
r'SCH\s*(\d+S?)\s*X\s*(\d+S?)', # SCH 40S X 40S (SCH 생략)
]
for pattern in separated_schedule_patterns:
separated_match = re.search(pattern, desc_upper)
if separated_match:
main_schedule = f"SCH {separated_match.group(1)}"
red_schedule = f"SCH {separated_match.group(2)}"
return {
"schedule": main_schedule, # 기본 스케줄 (호환성)
"main_schedule": main_schedule,
"red_schedule": red_schedule,
"has_different_schedules": main_schedule != red_schedule,
"confidence": 0.95,
"matched_pattern": separated_match.group(0),
"schedule_type": "SEPARATED"
}
# 2. 단일 스케줄 패턴 (기존 로직 사용)
basic_result = classify_fitting_schedule(description)
# 단일 스케줄을 main/red 모두에 적용
schedule = basic_result.get("schedule", "UNKNOWN")
return {
"schedule": schedule, # 기본 스케줄 (호환성)
"main_schedule": schedule,
"red_schedule": schedule if red_nom else None,
"has_different_schedules": False,
"confidence": basic_result.get("confidence", 0.0),
"matched_pattern": basic_result.get("matched_pattern", ""),
"schedule_type": "UNIFIED"
}

View File

@@ -5,6 +5,7 @@
import re
from typing import Dict, List, Optional, Tuple
from .fitting_classifier import classify_fitting
# Level 1: 명확한 타입 키워드 (최우선)
LEVEL1_TYPE_KEYWORDS = {
@@ -142,6 +143,18 @@ def classify_material_integrated(description: str, main_nom: str = "",
# 3단계: 단일 타입 확정 또는 Level 3/4로 판단
if len(detected_types) == 1:
material_type = detected_types[0][0]
# FITTING으로 분류된 경우 상세 분류기 호출
if material_type == "FITTING":
try:
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
# 상세 분류 결과가 있으면 사용, 없으면 기본 FITTING 반환
if detailed_result and detailed_result.get("category"):
return detailed_result
except Exception as e:
# 상세 분류 실패 시 기본 FITTING으로 처리
pass
return {
"category": material_type,
"confidence": 0.9,
@@ -171,6 +184,15 @@ def classify_material_integrated(description: str, main_nom: str = "",
if other_type_found:
continue # 볼트로 분류하지 않음
# FITTING으로 분류된 경우 상세 분류기 호출
if material_type == "FITTING":
try:
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
if detailed_result and detailed_result.get("category"):
return detailed_result
except Exception as e:
pass
return {
"category": material_type,
"confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도
@@ -182,8 +204,19 @@ def classify_material_integrated(description: str, main_nom: str = "",
for material, priority_types in GENERIC_MATERIALS.items():
if material in desc_upper:
# 우선순위에 따라 타입 결정
material_type = priority_types[0] # 첫 번째 우선순위
# FITTING으로 분류된 경우 상세 분류기 호출
if material_type == "FITTING":
try:
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
if detailed_result and detailed_result.get("category"):
return detailed_result
except Exception as e:
pass
return {
"category": priority_types[0], # 첫 번째 우선순위
"category": material_type,
"confidence": 0.3,
"evidence": [f"GENERIC_MATERIAL: {material}"],
"classification_level": "LEVEL4_GENERIC"

View File

@@ -23,6 +23,13 @@ def extract_full_material_grade(description: str) -> str:
# 1. ASTM 규격 패턴들 (가장 구체적인 것부터)
astm_patterns = [
# A320 L7, A325, A490 등 단독 규격 (ASTM 없이)
r'\bA320\s+L[0-9]+\b', # A320 L7
r'\bA325\b', # A325
r'\bA490\b', # A490
# ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선
r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+',
r'ASTM\s+A193/A194\s+[A-Z0-9/]+',
# ASTM A312 TP304, ASTM A312 TP316L 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*',
# ASTM A182 F304, ASTM A182 F316L 등
@@ -32,14 +39,14 @@ def extract_full_material_grade(description: str) -> str:
# ASTM A351 CF8M, ASTM A216 WCB 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*',
# ASTM A106 GR B, ASTM A105 등 - GR 포함
r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9]+',
r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9]+',
r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9/]+',
r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9/]+',
# ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)',
# ASTM A105, ASTM A234 등 (등급 없는 경우)
r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])',
# 2자리 ASTM 규격도 지원 (A10, A36 등)
r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9]+)?',
r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9/]+)?',
]
for pattern in astm_patterns:

View File

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