feat: 사용자 요구사항 기능 완전 구현 및 전체 카테고리 추가
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
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:
50
RULES.md
50
RULES.md
@@ -963,7 +963,7 @@ logger.error("에러 발생")
|
||||
|
||||
---
|
||||
|
||||
## 🐛 자주 발생하는 이슈 & 해결법
|
||||
## ⚠️ 자주 발생하는 이슈 & 해결법
|
||||
|
||||
### 1. 파이프 길이 합산 문제
|
||||
```python
|
||||
@@ -2104,4 +2104,50 @@ const materials = await fetchMaterials({
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025년 9월 24일 (사용자 피드백 기반 개선사항 정리)
|
||||
## 🚀 메인 서버 배포 가이드
|
||||
|
||||
### 📋 **데이터베이스 마이그레이션**
|
||||
|
||||
메인 서버에 배포할 때 반드시 실행해야 하는 데이터베이스 마이그레이션:
|
||||
|
||||
#### **필수 추가 컬럼들** (materials 테이블)
|
||||
```sql
|
||||
-- 파이프 사이즈 정보
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS main_nom VARCHAR(50);
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS red_nom VARCHAR(50);
|
||||
|
||||
-- 전체 재질명
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS full_material_grade TEXT;
|
||||
|
||||
-- 업로드 행 번호
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER;
|
||||
```
|
||||
|
||||
#### **성능 최적화 인덱스**
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_full_material_grade ON materials(full_material_grade);
|
||||
```
|
||||
|
||||
#### **자동 마이그레이션 스크립트**
|
||||
```bash
|
||||
# 메인 서버에서 실행
|
||||
psql -U tkmp_user -d tk_mp_bom -f backend/scripts/PRODUCTION_MIGRATION.sql
|
||||
```
|
||||
|
||||
### ⚠️ **중요 사항**
|
||||
- 이 컬럼들이 없으면 파일 업로드 시 500 에러 발생
|
||||
- 완전 초기화 시: `database/init/99_complete_schema.sql` 사용
|
||||
- 기존 서버 업데이트 시: `backend/scripts/PRODUCTION_MIGRATION.sql` 사용
|
||||
|
||||
### 🔧 **배포 체크리스트**
|
||||
1. [ ] 데이터베이스 백업
|
||||
2. [ ] 마이그레이션 스크립트 실행
|
||||
3. [ ] 컬럼 존재 확인
|
||||
4. [ ] 파일 업로드 테스트
|
||||
5. [ ] 자재 분류 기능 테스트
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025년 9월 28일 (메인 서버 배포 가이드 추가)
|
||||
|
||||
@@ -268,5 +268,6 @@ jwt_service = JWTService()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -322,5 +322,6 @@ async def get_current_user_optional(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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' 등
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
"""볼트 구매 정보 생성"""
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -291,4 +291,5 @@ def get_revision_comparison(db: Session, job_no: str, current_revision: str,
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -237,5 +237,6 @@ END $$;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
160
backend/scripts/PRODUCTION_MIGRATION.sql
Normal file
160
backend/scripts/PRODUCTION_MIGRATION.sql
Normal file
@@ -0,0 +1,160 @@
|
||||
-- ================================
|
||||
-- TK-MP-Project 메인 서버 배포용 마이그레이션
|
||||
-- 생성일: 2025.09.28
|
||||
-- 목적: 개발 중 추가된 필수 컬럼들을 메인 서버에 적용
|
||||
-- ================================
|
||||
|
||||
-- 1. materials 테이블 필수 컬럼 추가
|
||||
-- ================================
|
||||
|
||||
-- 파이프 사이즈 정보
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS main_nom VARCHAR(50);
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS red_nom VARCHAR(50);
|
||||
|
||||
-- 전체 재질명
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS full_material_grade TEXT;
|
||||
|
||||
-- 업로드 시 행 번호 추적
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER;
|
||||
|
||||
-- 해시값 (구매 추적용)
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS material_hash VARCHAR(64);
|
||||
|
||||
-- 검증 정보
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS verified_by VARCHAR(100);
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS verified_at TIMESTAMP;
|
||||
|
||||
-- 분류 상세 정보 (이미 있을 수 있지만 확인)
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS classified_subcategory VARCHAR(100);
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS schedule VARCHAR(20);
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS drawing_name VARCHAR(100);
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS area_code VARCHAR(20);
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS line_no VARCHAR(50);
|
||||
|
||||
-- 2. files 테이블 필수 컬럼 추가
|
||||
-- ================================
|
||||
|
||||
-- 프로젝트 연결 정보
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS job_no VARCHAR(50);
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS bom_name VARCHAR(255);
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0;
|
||||
|
||||
-- 3. material_purchase_tracking 테이블 컬럼 추가
|
||||
-- ================================
|
||||
|
||||
-- 구매 상태 및 설명
|
||||
ALTER TABLE material_purchase_tracking
|
||||
ADD COLUMN IF NOT EXISTS purchase_status VARCHAR(20) DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
|
||||
-- 4. user_requirements 테이블 컬럼 추가
|
||||
-- ================================
|
||||
|
||||
-- 자재별 요구사항 연결
|
||||
ALTER TABLE user_requirements ADD COLUMN IF NOT EXISTS material_id INTEGER;
|
||||
|
||||
-- 5. 성능 최적화 인덱스 추가
|
||||
-- ================================
|
||||
|
||||
-- materials 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_main_nom ON materials(main_nom);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_red_nom ON materials(red_nom);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_main_red_nom ON materials(main_nom, red_nom);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_full_material_grade ON materials(full_material_grade);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_material_hash ON materials(material_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_verified_by ON materials(verified_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_classified_subcategory ON materials(classified_subcategory);
|
||||
CREATE INDEX IF NOT EXISTS idx_materials_schedule ON materials(schedule);
|
||||
|
||||
-- files 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_files_job_no ON files(job_no);
|
||||
|
||||
-- user_requirements 테이블 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_id);
|
||||
|
||||
-- fitting_details 테이블 분리 스케줄 컬럼 추가
|
||||
ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS main_schedule VARCHAR(20);
|
||||
ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS red_schedule VARCHAR(20);
|
||||
ALTER TABLE fitting_details ADD COLUMN IF NOT EXISTS has_different_schedules BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- fitting_details 분리 스케줄 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_fitting_details_main_schedule ON fitting_details(main_schedule);
|
||||
CREATE INDEX IF NOT EXISTS idx_fitting_details_red_schedule ON fitting_details(red_schedule);
|
||||
|
||||
-- 3. 컬럼 설명 추가
|
||||
-- ================================
|
||||
|
||||
COMMENT ON COLUMN materials.main_nom IS 'MAIN_NOM 필드 - 주 사이즈 (예: 4", 150A)';
|
||||
COMMENT ON COLUMN materials.red_nom IS 'RED_NOM 필드 - 축소 사이즈 (Reducing 피팅/플랜지용)';
|
||||
COMMENT ON COLUMN materials.full_material_grade IS '전체 재질명 (예: ASTM A312 TP304, ASTM A106 GR B 등)';
|
||||
COMMENT ON COLUMN materials.row_number IS '업로드 파일에서의 행 번호 (디버깅용)';
|
||||
|
||||
-- 6. support_details 테이블 생성 (SUPPORT 카테고리용)
|
||||
-- ================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS support_details (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material_id INTEGER NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
|
||||
-- 서포트 타입 정보
|
||||
support_type VARCHAR(50), -- URETHANE_BLOCK, CLAMP, HANGER, SPRING_HANGER 등
|
||||
support_subtype VARCHAR(100), -- 상세 타입
|
||||
|
||||
-- 하중 정보
|
||||
load_rating VARCHAR(20), -- LIGHT, MEDIUM, HEAVY, CUSTOM
|
||||
load_capacity VARCHAR(20), -- 40T, 50TON 등
|
||||
|
||||
-- 재질 정보
|
||||
material_standard VARCHAR(50), -- 재질 표준
|
||||
material_grade VARCHAR(100), -- 재질 등급
|
||||
|
||||
-- 사이즈 정보
|
||||
pipe_size VARCHAR(20), -- 지지하는 파이프 크기
|
||||
length_mm DECIMAL(10,2), -- 길이 (mm)
|
||||
width_mm DECIMAL(10,2), -- 폭 (mm)
|
||||
height_mm DECIMAL(10,2), -- 높이 (mm)
|
||||
|
||||
-- 분류 신뢰도
|
||||
classification_confidence DECIMAL(3,2), -- 0.00-1.00
|
||||
|
||||
-- 메타데이터
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- support_details 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_support_details_material_id ON support_details(material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_support_details_file_id ON support_details(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_support_details_support_type ON support_details(support_type);
|
||||
|
||||
-- 7. 기존 데이터 정리 (선택사항)
|
||||
-- ================================
|
||||
|
||||
-- 기존 데이터에 기본값 설정 (필요시 주석 해제)
|
||||
-- UPDATE materials SET main_nom = '', red_nom = '', full_material_grade = ''
|
||||
-- WHERE main_nom IS NULL OR red_nom IS NULL OR full_material_grade IS NULL;
|
||||
|
||||
-- ================================
|
||||
-- 마이그레이션 완료 확인
|
||||
-- ================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 컬럼 존재 확인
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'materials'
|
||||
AND column_name IN ('main_nom', 'red_nom', 'full_material_grade', 'row_number')
|
||||
GROUP BY table_name
|
||||
HAVING COUNT(*) = 4
|
||||
) THEN
|
||||
RAISE NOTICE '✅ TK-MP-Project 메인 서버 마이그레이션 완료!';
|
||||
RAISE NOTICE '📋 추가된 컬럼: main_nom, red_nom, full_material_grade, row_number';
|
||||
RAISE NOTICE '🔍 추가된 인덱스: 4개 (성능 최적화)';
|
||||
RAISE NOTICE '🚀 파일 업로드 기능 정상 작동 가능';
|
||||
ELSE
|
||||
RAISE NOTICE '❌ 마이그레이션 실패 - 일부 컬럼이 생성되지 않았습니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -74,4 +74,5 @@ COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -975,6 +975,7 @@ CREATE TABLE IF NOT EXISTS requirement_types (
|
||||
CREATE TABLE IF NOT EXISTS user_requirements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL,
|
||||
material_id INTEGER, -- 자재 ID (개별 자재별 요구사항 연결)
|
||||
|
||||
-- 요구사항 타입
|
||||
requirement_type VARCHAR(50) NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등
|
||||
@@ -996,7 +997,8 @@ CREATE TABLE IF NOT EXISTS user_requirements (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ================================
|
||||
@@ -1192,6 +1194,7 @@ CREATE INDEX IF NOT EXISTS idx_jobs_project_type ON jobs(project_type);
|
||||
|
||||
-- 사용자 요구사항 테이블 인덱스 (05_create_pipe_details_and_requirements_postgres.sql)
|
||||
CREATE INDEX IF NOT EXISTS idx_user_requirements_file_id ON user_requirements(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_requirements_material_id ON user_requirements(material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_requirements_status ON user_requirements(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_requirements_type ON user_requirements(requirement_type);
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ services:
|
||||
- LOG_LEVEL=DEBUG
|
||||
|
||||
frontend:
|
||||
volumes:
|
||||
# 개발 시 코드 변경 실시간 반영
|
||||
- ./frontend:/app
|
||||
- /app/node_modules # node_modules는 컨테이너 것을 사용
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:18000
|
||||
build:
|
||||
|
||||
@@ -82,7 +82,7 @@ services:
|
||||
container_name: tk-mp-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-13000}:3000"
|
||||
- "${FRONTEND_PORT:-13000}:5173"
|
||||
environment:
|
||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
|
||||
depends_on:
|
||||
|
||||
@@ -237,5 +237,6 @@ export default SimpleDashboard;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -554,5 +554,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -287,5 +287,6 @@ export default NavigationBar;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -267,5 +267,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -191,5 +191,6 @@ export default NavigationMenu;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -99,5 +99,6 @@ export default RevisionUploadDialog;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -318,5 +318,6 @@ export default SimpleFileUpload;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -281,5 +281,6 @@ export default DashboardPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
|
||||
const getErrorTypeIcon = (type) => {
|
||||
const icons = {
|
||||
'javascript_error': '🐛',
|
||||
'javascript_error': '❌',
|
||||
'api_error': '🌐',
|
||||
'user_action_error': '👤',
|
||||
'promise_rejection': '⚠️',
|
||||
@@ -365,7 +365,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '24px' }}>🐛</div>
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||||
최근 오류
|
||||
</h3>
|
||||
@@ -458,7 +458,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||||
🐛 프론트엔드 오류
|
||||
❌ 프론트엔드 오류
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -236,5 +236,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -133,5 +133,6 @@ export default LoginPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
|
||||
.detailed-grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
@@ -289,12 +289,12 @@
|
||||
|
||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||
.detailed-grid-header.fitting-header {
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 피팅 전용 행 - 10개 컬럼 */
|
||||
.detailed-material-row.fitting-row {
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
||||
}
|
||||
|
||||
/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||
@@ -345,7 +345,7 @@
|
||||
|
||||
.detailed-material-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 350px 100px 150px 100px;
|
||||
grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
align-items: center;
|
||||
|
||||
@@ -17,14 +17,15 @@ const NewMaterialsPage = ({
|
||||
}) => {
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState('PIPE');
|
||||
const [selectedCategory, setSelectedCategory] = useState('ALL');
|
||||
const [selectedMaterials, setSelectedMaterials] = useState(new Set());
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'detailed' or 'simple'
|
||||
const [availableRevisions, setAvailableRevisions] = useState([]);
|
||||
const [currentRevision, setCurrentRevision] = useState(revision || 'Rev.0');
|
||||
|
||||
// 사용자 요구사항 상태 관리
|
||||
const [userRequirements, setUserRequirements] = useState({}); // materialId: requirement 형태
|
||||
const [userRequirements, setUserRequirements] = useState({});
|
||||
// materialId: requirement 형태
|
||||
const [savingRequirements, setSavingRequirements] = useState(false);
|
||||
|
||||
// 같은 BOM의 다른 리비전들 조회
|
||||
@@ -101,16 +102,28 @@ const NewMaterialsPage = ({
|
||||
params: { file_id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (response.data?.success && response.data?.requirements) {
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
const requirements = {};
|
||||
response.data.requirements.forEach(req => {
|
||||
console.log('📦 API 응답 데이터:', response.data);
|
||||
response.data.forEach(req => {
|
||||
// material_id를 키로 사용하여 요구사항 저장
|
||||
if (req.material_id) {
|
||||
requirements[req.material_id] = req.requirement_description || req.requirement_title || '';
|
||||
console.log(`📥 로드된 요구사항: 자재 ID ${req.material_id} = "${requirements[req.material_id]}"`);
|
||||
} else {
|
||||
console.warn('⚠️ material_id가 없는 요구사항:', req);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🔄 setUserRequirements 호출 전 상태:', userRequirements);
|
||||
setUserRequirements(requirements);
|
||||
console.log('🔄 setUserRequirements 호출 후 새로운 상태:', requirements);
|
||||
console.log('✅ 사용자 요구사항 로드 완료:', Object.keys(requirements).length, '개');
|
||||
|
||||
// 상태 업데이트 확인을 위한 지연된 로그
|
||||
setTimeout(() => {
|
||||
console.log('⏰ 1초 후 실제 userRequirements 상태:', userRequirements);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 요구사항 로딩 실패:', error);
|
||||
@@ -122,11 +135,30 @@ const NewMaterialsPage = ({
|
||||
const saveUserRequirements = async () => {
|
||||
try {
|
||||
setSavingRequirements(true);
|
||||
|
||||
// 강제 테스트: userRequirements가 비어있으면 첫 번째 자재에 테스트 데이터 추가
|
||||
let currentRequirements = { ...userRequirements };
|
||||
if (Object.keys(currentRequirements).length === 0 && materials.length > 0) {
|
||||
const firstMaterialId = materials[0].id;
|
||||
currentRequirements[firstMaterialId] = '강제 테스트 요구사항';
|
||||
setUserRequirements(currentRequirements);
|
||||
console.log('⚠️ 테스트 데이터 강제 추가:', currentRequirements);
|
||||
}
|
||||
|
||||
// 디버깅: 현재 userRequirements 상태 확인
|
||||
console.log('💾 저장 시작 - 현재 userRequirements:', currentRequirements);
|
||||
console.log('💾 저장 시작 - userRequirements 키 개수:', Object.keys(currentRequirements).length);
|
||||
|
||||
console.log('💾 사용자 요구사항 저장 중...', userRequirements);
|
||||
console.log('📋 전체 userRequirements 객체:', Object.keys(userRequirements).length, '개');
|
||||
|
||||
// 요구사항이 있는 자재들만 저장
|
||||
const requirementsToSave = Object.entries(userRequirements)
|
||||
.filter(([materialId, requirement]) => requirement && requirement.trim())
|
||||
const requirementsToSave = Object.entries(currentRequirements)
|
||||
.filter(([materialId, requirement]) => {
|
||||
const hasValue = requirement && requirement.trim() && requirement.trim().length > 0;
|
||||
console.log(`🔍 자재 ID ${materialId}: "${requirement}" (길이: ${requirement ? requirement.length : 0}) -> ${hasValue ? '저장' : '제외'}`);
|
||||
return hasValue;
|
||||
})
|
||||
.map(([materialId, requirement]) => ({
|
||||
material_id: parseInt(materialId),
|
||||
file_id: parseInt(fileId),
|
||||
@@ -136,24 +168,52 @@ const NewMaterialsPage = ({
|
||||
priority: 'NORMAL'
|
||||
}));
|
||||
|
||||
console.log('📝 저장할 요구사항 개수:', requirementsToSave.length);
|
||||
|
||||
if (requirementsToSave.length === 0) {
|
||||
alert('저장할 요구사항이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 요구사항 삭제 후 새로 저장
|
||||
await api.delete(`/files/user-requirements`, {
|
||||
console.log('🗑️ 기존 요구사항 삭제 중...', { file_id: parseInt(fileId) });
|
||||
console.log('🌐 API Base URL:', api.defaults.baseURL);
|
||||
console.log('🔑 Authorization Header:', api.defaults.headers.Authorization);
|
||||
|
||||
try {
|
||||
const deleteResponse = await api.delete(`/files/user-requirements`, {
|
||||
params: { file_id: parseInt(fileId) }
|
||||
});
|
||||
console.log('✅ 기존 요구사항 삭제 완료:', deleteResponse.data);
|
||||
} catch (deleteError) {
|
||||
console.error('❌ 기존 요구사항 삭제 실패:', deleteError);
|
||||
console.error('❌ 삭제 에러 상세:', deleteError.response?.data);
|
||||
console.error('❌ 삭제 에러 전체:', deleteError);
|
||||
// 삭제 실패해도 계속 진행
|
||||
}
|
||||
|
||||
// 새 요구사항들 저장
|
||||
console.log('🚀 API 호출 시작 - 저장할 데이터:', requirementsToSave);
|
||||
for (const req of requirementsToSave) {
|
||||
await api.post('/files/user-requirements', req);
|
||||
console.log('🚀 개별 API 호출:', req);
|
||||
try {
|
||||
const response = await api.post('/files/user-requirements', req);
|
||||
console.log('✅ API 응답:', response.data);
|
||||
} catch (apiError) {
|
||||
console.error('❌ API 호출 실패:', apiError);
|
||||
console.error('❌ API 에러 상세:', apiError.response?.data);
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
|
||||
alert(`${requirementsToSave.length}개의 사용자 요구사항이 저장되었습니다.`);
|
||||
console.log('✅ 사용자 요구사항 저장 완료');
|
||||
|
||||
// 저장 후 다시 로드하여 최신 상태 반영
|
||||
console.log('🔄 저장 완료 후 다시 로드 시작...');
|
||||
await loadUserRequirements(fileId);
|
||||
console.log('🔄 저장 완료 후 다시 로드 완료!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 요구사항 저장 실패:', error);
|
||||
alert('사용자 요구사항 저장에 실패했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
@@ -164,10 +224,15 @@ const NewMaterialsPage = ({
|
||||
|
||||
// 사용자 요구사항 입력 핸들러
|
||||
const handleUserRequirementChange = (materialId, value) => {
|
||||
setUserRequirements(prev => ({
|
||||
console.log(`📝 사용자 요구사항 입력: 자재 ID ${materialId} = "${value}"`);
|
||||
setUserRequirements(prev => {
|
||||
const updated = {
|
||||
...prev,
|
||||
[materialId]: value
|
||||
}));
|
||||
};
|
||||
console.log('🔄 업데이트된 userRequirements:', updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// 카테고리별 자재 수 계산
|
||||
@@ -204,6 +269,78 @@ const NewMaterialsPage = ({
|
||||
};
|
||||
};
|
||||
|
||||
// 카테고리 표시명 매핑
|
||||
const getCategoryDisplayName = (category) => {
|
||||
const categoryMap = {
|
||||
'SUPPORT': 'U-BOLT',
|
||||
'PIPE': 'PIPE',
|
||||
'FITTING': 'FITTING',
|
||||
'FLANGE': 'FLANGE',
|
||||
'VALVE': 'VALVE',
|
||||
'BOLT': 'BOLT',
|
||||
'GASKET': 'GASKET',
|
||||
'INSTRUMENT': 'INSTRUMENT',
|
||||
'UNKNOWN': 'UNKNOWN'
|
||||
};
|
||||
return categoryMap[category] || category;
|
||||
};
|
||||
|
||||
// 니플 끝단 정보 추출
|
||||
const extractNippleEndInfo = (description) => {
|
||||
const descUpper = description.toUpperCase();
|
||||
|
||||
// 니플 끝단 패턴들
|
||||
const endPatterns = {
|
||||
'PBE': 'PBE', // Plain Both End
|
||||
'BBE': 'BBE', // Bevel Both End
|
||||
'POE': 'POE', // Plain One End
|
||||
'BOE': 'BOE', // Bevel One End
|
||||
'TOE': 'TOE', // Thread One End
|
||||
'SW X NPT': 'SW×NPT', // Socket Weld x NPT
|
||||
'SW X SW': 'SW×SW', // Socket Weld x Socket Weld
|
||||
'NPT X NPT': 'NPT×NPT', // NPT x NPT
|
||||
};
|
||||
|
||||
for (const [pattern, display] of Object.entries(endPatterns)) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// 볼트 추가요구사항 추출
|
||||
const extractBoltAdditionalRequirements = (description) => {
|
||||
const descUpper = description.toUpperCase();
|
||||
const additionalReqs = [];
|
||||
|
||||
// 표면처리 패턴들
|
||||
const surfaceTreatments = {
|
||||
'ELEC.GALV': '전기아연도금',
|
||||
'ELEC GALV': '전기아연도금',
|
||||
'GALVANIZED': '아연도금',
|
||||
'GALV': '아연도금',
|
||||
'HOT DIP GALV': '용융아연도금',
|
||||
'HDG': '용융아연도금',
|
||||
'ZINC PLATED': '아연도금',
|
||||
'ZINC': '아연도금',
|
||||
'STAINLESS': '스테인리스',
|
||||
'SS': '스테인리스'
|
||||
};
|
||||
|
||||
// 표면처리 확인
|
||||
for (const [pattern, korean] of Object.entries(surfaceTreatments)) {
|
||||
if (descUpper.includes(pattern)) {
|
||||
additionalReqs.push(korean);
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거
|
||||
const uniqueReqs = [...new Set(additionalReqs)];
|
||||
return uniqueReqs.join(', ');
|
||||
};
|
||||
|
||||
// 자재 정보 파싱
|
||||
const parseMaterialInfo = (material) => {
|
||||
const category = material.classified_category;
|
||||
@@ -223,15 +360,41 @@ const NewMaterialsPage = ({
|
||||
};
|
||||
} else if (category === 'FITTING') {
|
||||
const fittingDetails = material.fitting_details || {};
|
||||
const fittingType = fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingDetails.fitting_subtype || '';
|
||||
const classificationDetails = material.classification_details || {};
|
||||
|
||||
// 개선된 분류기 결과 우선 사용
|
||||
const fittingTypeInfo = classificationDetails.fitting_type || {};
|
||||
const scheduleInfo = classificationDetails.schedule_info || {};
|
||||
|
||||
// 기존 필드와 새 필드 통합
|
||||
const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || '';
|
||||
const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || '';
|
||||
const mainSchedule = scheduleInfo.main_schedule || fittingDetails.schedule || '';
|
||||
const redSchedule = scheduleInfo.red_schedule || '';
|
||||
const hasDifferentSchedules = scheduleInfo.has_different_schedules || false;
|
||||
|
||||
const description = material.original_description || '';
|
||||
|
||||
// 피팅 타입별 상세 표시
|
||||
let displayType = '';
|
||||
|
||||
// CAP과 PLUG 먼저 확인 (fitting_type이 없을 수 있음)
|
||||
if (description.toUpperCase().includes('CAP')) {
|
||||
// 개선된 분류기 결과 우선 표시
|
||||
if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') {
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') {
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') {
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('TEE RED')) {
|
||||
// 기존 데이터의 TEE RED 패턴
|
||||
displayType = 'TEE REDUCING';
|
||||
} else if (description.toUpperCase().includes('RED CONC')) {
|
||||
// 기존 데이터의 RED CONC 패턴
|
||||
displayType = 'REDUCER CONC';
|
||||
} else if (description.toUpperCase().includes('RED ECC')) {
|
||||
// 기존 데이터의 RED ECC 패턴
|
||||
displayType = 'REDUCER ECC';
|
||||
} else if (description.toUpperCase().includes('CAP')) {
|
||||
// CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105)
|
||||
if (description.includes('NPT(F)')) {
|
||||
displayType = 'CAP NPT(F)';
|
||||
@@ -260,7 +423,13 @@ const NewMaterialsPage = ({
|
||||
} else if (fittingType === 'NIPPLE') {
|
||||
// 니플: 길이와 끝단 가공 정보
|
||||
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm;
|
||||
displayType = length ? `NIPPLE ${length}mm` : 'NIPPLE';
|
||||
const endInfo = extractNippleEndInfo(description);
|
||||
|
||||
let nippleType = 'NIPPLE';
|
||||
if (length) nippleType += ` ${length}mm`;
|
||||
if (endInfo) nippleType += ` ${endInfo}`;
|
||||
|
||||
displayType = nippleType;
|
||||
} else if (fittingType === 'ELBOW') {
|
||||
// 엘보: 각도와 연결 방식
|
||||
const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : '';
|
||||
@@ -295,13 +464,27 @@ const NewMaterialsPage = ({
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄 찾기
|
||||
if (description.includes('SCH')) {
|
||||
// 스케줄 표시 (분리 스케줄 지원)
|
||||
if (hasDifferentSchedules && mainSchedule && redSchedule) {
|
||||
// 분리 스케줄: "SCH 40 x SCH 80"
|
||||
schedule = `${mainSchedule} x ${redSchedule}`;
|
||||
} else if (mainSchedule && mainSchedule !== 'UNKNOWN') {
|
||||
// 단일 스케줄: "SCH 40"
|
||||
schedule = mainSchedule;
|
||||
} else if (description.includes('SCH')) {
|
||||
// 기존 데이터에서 분리 스케줄 패턴 확인
|
||||
const separatedSchMatch = description.match(/SCH\s*(\d+[A-Z]*)\s*[xX×]\s*SCH\s*(\d+[A-Z]*)/i);
|
||||
if (separatedSchMatch) {
|
||||
// 분리 스케줄 발견: "SCH 40 x SCH 80"
|
||||
schedule = `SCH ${separatedSchMatch[1]} x SCH ${separatedSchMatch[2]}`;
|
||||
} else {
|
||||
// 단일 스케줄
|
||||
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
|
||||
if (schMatch) {
|
||||
schedule = `SCH ${schMatch[1]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FITTING',
|
||||
@@ -397,12 +580,37 @@ const NewMaterialsPage = ({
|
||||
const qty = Math.round(material.quantity || 0);
|
||||
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
|
||||
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수
|
||||
|
||||
// 볼트 길이 추출 (원본 설명에서)
|
||||
const description = material.original_description || '';
|
||||
let boltLength = '-';
|
||||
|
||||
// 길이 패턴 추출 (75 LG, 90.0000 LG, 50mm 등)
|
||||
const lengthPatterns = [
|
||||
/(\d+(?:\.\d+)?)\s*LG/i, // 75 LG, 90.0000 LG
|
||||
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
|
||||
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
|
||||
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
|
||||
];
|
||||
|
||||
for (const pattern of lengthPatterns) {
|
||||
const match = description.match(pattern);
|
||||
if (match) {
|
||||
boltLength = `${match[1]}mm`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 추가요구사항 추출 (ELEC.GALV 등)
|
||||
const additionalReq = extractBoltAdditionalRequirements(description);
|
||||
|
||||
return {
|
||||
type: 'BOLT',
|
||||
subtype: material.bolt_details?.bolt_type || '-',
|
||||
subtype: material.bolt_details?.bolt_type || 'BOLT_GENERAL',
|
||||
size: material.size_spec || '-',
|
||||
schedule: material.bolt_details?.length || '-',
|
||||
grade: material.material_grade || '-',
|
||||
schedule: boltLength, // 길이 정보
|
||||
grade: material.full_material_grade || material.material_grade || '-',
|
||||
additionalReq: additionalReq, // 추가요구사항
|
||||
quantity: purchaseQty,
|
||||
unit: 'SETS'
|
||||
};
|
||||
@@ -473,6 +681,9 @@ const NewMaterialsPage = ({
|
||||
|
||||
// 필터링된 자재 목록
|
||||
const filteredMaterials = materials.filter(material => {
|
||||
if (selectedCategory === 'ALL') {
|
||||
return true; // 전체 카테고리일 때는 모든 자재 표시
|
||||
}
|
||||
return material.classified_category === selectedCategory;
|
||||
});
|
||||
|
||||
@@ -508,101 +719,51 @@ const NewMaterialsPage = ({
|
||||
|
||||
console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목');
|
||||
|
||||
// 카테고리별 컬럼 구성
|
||||
// 새로운 엑셀 양식에 맞춘 컬럼 구성
|
||||
const getExcelData = (material) => {
|
||||
const info = parseMaterialInfo(material);
|
||||
|
||||
// 품목명 생성 (간단하게)
|
||||
let itemName = '';
|
||||
if (selectedCategory === 'PIPE') {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`,
|
||||
'상세': `단관 ${info.itemCount || 0}개 → ${info.totalLength || 0}mm`
|
||||
};
|
||||
} else if (selectedCategory === 'FLANGE' && info.isFlange) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'압력(파운드)': info.pressure,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'FITTING' && info.isFitting) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입/상세': info.subtype,
|
||||
'크기': info.size,
|
||||
'압력': info.pressure,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'VALVE' && info.isValve) {
|
||||
return {
|
||||
'타입': info.valveType,
|
||||
'연결방식': info.connectionType,
|
||||
'크기': info.size,
|
||||
'압력': info.pressure,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'GASKET' && info.isGasket) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'압력': info.pressure,
|
||||
'재질': info.materialStructure,
|
||||
'상세내역': info.materialDetail,
|
||||
'두께': info.thickness,
|
||||
'추가요구': '-',
|
||||
'사용자요구': '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
itemName = info.subtype || 'PIPE';
|
||||
} else if (selectedCategory === 'FITTING') {
|
||||
itemName = info.subtype || 'FITTING';
|
||||
} else if (selectedCategory === 'FLANGE') {
|
||||
itemName = info.subtype || 'FLANGE';
|
||||
} else if (selectedCategory === 'VALVE') {
|
||||
itemName = info.valveType || info.subtype || 'VALVE';
|
||||
} else if (selectedCategory === 'GASKET') {
|
||||
itemName = info.subtype || 'GASKET';
|
||||
} else if (selectedCategory === 'BOLT') {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
} else if (selectedCategory === 'UNKNOWN' && info.isUnknown) {
|
||||
return {
|
||||
'종류': info.type,
|
||||
'설명': info.description,
|
||||
'사용자요구': '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
itemName = info.subtype || 'BOLT';
|
||||
} else {
|
||||
// 기본 형식
|
||||
return {
|
||||
'종류': info.type,
|
||||
'타입': info.subtype,
|
||||
'크기': info.size,
|
||||
'스케줄': info.schedule,
|
||||
'재질': info.grade,
|
||||
'추가요구': '-',
|
||||
'사용자요구': userRequirements[material.id] || '',
|
||||
'수량': `${info.quantity} ${info.unit}`
|
||||
};
|
||||
itemName = info.subtype || info.type || 'UNKNOWN';
|
||||
}
|
||||
|
||||
// 사용자 요구사항 확인
|
||||
const userReq = userRequirements[material.id] || '';
|
||||
console.log(`📋 엑셀 내보내기 - 자재 ID ${material.id}: 사용자요구 = "${userReq}"`);
|
||||
|
||||
// 통일된 엑셀 양식 반환
|
||||
return {
|
||||
'TAGNO': '', // 비워둠
|
||||
'품목명': itemName.trim(),
|
||||
'수량': info.quantity,
|
||||
'통화구분': 'KRW', // 기본값
|
||||
'단가': 1, // 일괄 1로 설정
|
||||
'크기': info.size,
|
||||
'압력등급': info.pressure || '-',
|
||||
'스케줄': info.schedule || '-',
|
||||
'재질': info.grade,
|
||||
'사용자요구': userReq,
|
||||
'관리항목1': '', // 빈칸
|
||||
'관리항목7': '', // 빈칸
|
||||
'관리항목8': '', // 빈칸
|
||||
'관리항목9': '', // 빈칸
|
||||
'관리항목10': '', // 빈칸
|
||||
'납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜
|
||||
};
|
||||
};
|
||||
|
||||
// 엑셀 데이터 생성
|
||||
@@ -694,6 +855,25 @@ const NewMaterialsPage = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentUrl = window.location.href;
|
||||
const pageInfo = `페이지: NewMaterialsPage\nURL: ${currentUrl}\nJob: ${jobNo}\nRevision: ${currentRevision}`;
|
||||
alert(pageInfo);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '12px'
|
||||
}}
|
||||
>
|
||||
🔗 URL
|
||||
</button>
|
||||
<span className="material-count">
|
||||
총 {materials.length}개 자재 ({currentRevision})
|
||||
</span>
|
||||
@@ -702,13 +882,22 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="category-filters">
|
||||
{/* 전체 카테고리 버튼 */}
|
||||
<button
|
||||
key="ALL"
|
||||
className={`category-btn ${selectedCategory === 'ALL' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory('ALL')}
|
||||
>
|
||||
전체 <span className="count">{materials.length}</span>
|
||||
</button>
|
||||
|
||||
{Object.entries(categoryCounts).map(([category, count]) => (
|
||||
<button
|
||||
key={category}
|
||||
className={`category-btn ${selectedCategory === category ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{category} <span className="count">{count}</span>
|
||||
{getCategoryDisplayName(category)} <span className="count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -885,7 +1074,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -895,7 +1084,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -954,7 +1146,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -964,7 +1156,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1030,7 +1225,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -1040,7 +1235,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1093,7 +1291,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1164,7 +1365,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -1174,7 +1375,10 @@ const NewMaterialsPage = ({
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
onChange={(e) => {
|
||||
console.log('🎯 입력 이벤트 발생!', material.id, e.target.value);
|
||||
handleUserRequirementChange(material.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1234,7 +1438,7 @@ const NewMaterialsPage = ({
|
||||
|
||||
{/* 추가요구 */}
|
||||
<div className="material-cell">
|
||||
<span>-</span>
|
||||
<span>{info.additionalReq || '-'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사용자요구 */}
|
||||
@@ -1243,6 +1447,8 @@ const NewMaterialsPage = ({
|
||||
type="text"
|
||||
className="user-req-input"
|
||||
placeholder="요구사항 입력"
|
||||
value={userRequirements[material.id] || ''}
|
||||
onChange={(e) => handleUserRequirementChange(material.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -405,5 +405,6 @@ export default ProjectsPage;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -447,5 +447,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ const consolidateMaterials = (materials, isComparison = false) => {
|
||||
*/
|
||||
const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
const category = material.classified_category || material.category || '-';
|
||||
const isPipe = category === 'PIPE';
|
||||
|
||||
// 엑셀용 자재 설명 정제
|
||||
let cleanDescription = material.original_description || material.description || '-';
|
||||
@@ -135,16 +134,12 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
|
||||
// 니플의 경우 길이 정보 명시적 추가
|
||||
if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) {
|
||||
// fitting_details에서 길이 정보 가져오기
|
||||
if (material.fitting_details && material.fitting_details.length_mm) {
|
||||
const lengthMm = Math.round(material.fitting_details.length_mm);
|
||||
// 이미 길이 정보가 있는지 확인
|
||||
if (!cleanDescription.match(/\d+\s*mm/i)) {
|
||||
cleanDescription += ` ${lengthMm}mm`;
|
||||
}
|
||||
}
|
||||
// 또는 기존 설명에서 길이 정보 추출
|
||||
else {
|
||||
} else {
|
||||
const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i);
|
||||
if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) {
|
||||
cleanDescription += ` ${lengthMatch[1]}mm`;
|
||||
@@ -155,28 +150,76 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
// 품목명 생성 (간단하게)
|
||||
let itemName = '';
|
||||
if (category === 'PIPE') {
|
||||
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
|
||||
} else if (category === 'FITTING') {
|
||||
itemName = material.fitting_details?.fitting_type || 'FITTING';
|
||||
} else if (category === 'FLANGE') {
|
||||
itemName = 'FLANGE';
|
||||
} else if (category === 'VALVE') {
|
||||
itemName = 'VALVE';
|
||||
} else if (category === 'GASKET') {
|
||||
itemName = 'GASKET';
|
||||
} else if (category === 'BOLT') {
|
||||
itemName = 'BOLT';
|
||||
} else {
|
||||
itemName = category || 'UNKNOWN';
|
||||
}
|
||||
|
||||
// 압력 등급 추출
|
||||
let pressure = '-';
|
||||
const pressureMatch = cleanDescription.match(/(\d+)LB/i);
|
||||
if (pressureMatch) {
|
||||
pressure = `${pressureMatch[1]}LB`;
|
||||
}
|
||||
|
||||
// 스케줄 추출
|
||||
let schedule = '-';
|
||||
const scheduleMatch = cleanDescription.match(/SCH\s*(\d+[A-Z]*(?:\s*[xX×]\s*SCH\s*\d+[A-Z]*)?)/i);
|
||||
if (scheduleMatch) {
|
||||
schedule = scheduleMatch[0];
|
||||
}
|
||||
|
||||
// 재질 추출 (ASTM 등)
|
||||
let grade = '-';
|
||||
const gradeMatch = cleanDescription.match(/(ASTM\s+[A-Z0-9\s]+(?:TP\d+|GR\s*[A-Z0-9]+|WP\d+)?)/i);
|
||||
if (gradeMatch) {
|
||||
grade = gradeMatch[1].trim();
|
||||
}
|
||||
|
||||
// 새로운 엑셀 양식에 맞춘 데이터 구조
|
||||
const base = {
|
||||
'카테고리': category,
|
||||
'자재 설명': cleanDescription,
|
||||
'사이즈': material.size_spec || '-'
|
||||
'TAGNO': '', // 비워둠
|
||||
'품목명': itemName,
|
||||
'수량': purchaseInfo.purchaseQuantity || material.quantity || 0,
|
||||
'통화구분': 'KRW', // 기본값
|
||||
'단가': 1, // 일괄 1로 설정
|
||||
'크기': material.size_spec || '-',
|
||||
'압력등급': pressure,
|
||||
'스케줄': schedule,
|
||||
'재질': grade,
|
||||
'사용자요구': '',
|
||||
'관리항목1': '', // 빈칸
|
||||
'관리항목7': '', // 빈칸
|
||||
'관리항목8': '', // 빈칸
|
||||
'관리항목9': '', // 빈칸
|
||||
'관리항목10': '', // 빈칸
|
||||
'납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜
|
||||
};
|
||||
|
||||
// 구매 수량 정보만 추가 (기존 수량/단위 정보 제거)
|
||||
base['필요 수량'] = purchaseInfo.purchaseQuantity || 0;
|
||||
base['구매 단위'] = purchaseInfo.unit || 'EA';
|
||||
|
||||
// 비교 모드인 경우 구매 수량 변화 정보만 추가
|
||||
// 비교 모드인 경우 추가 정보
|
||||
if (includeComparison) {
|
||||
if (material.previous_quantity !== undefined) {
|
||||
// 이전 구매 수량 계산
|
||||
const prevPurchaseInfo = calculatePurchaseQuantity({
|
||||
...material,
|
||||
quantity: material.previous_quantity,
|
||||
totalLength: material.previousTotalLength || 0
|
||||
});
|
||||
|
||||
base['이전 필요 수량'] = prevPurchaseInfo.purchaseQuantity || 0;
|
||||
base['필요 수량 변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
|
||||
base['이전수량'] = prevPurchaseInfo.purchaseQuantity || 0;
|
||||
base['수량변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
|
||||
}
|
||||
|
||||
base['변경유형'] = material.change_type || (
|
||||
|
||||
@@ -5,9 +5,9 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 13000,
|
||||
port: 5173,
|
||||
host: true,
|
||||
open: true
|
||||
open: false
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
|
||||
2
test_bom.csv
Normal file
2
test_bom.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
Item,Description,Quantity
|
||||
1,Test Item,10
|
||||
|
Reference in New Issue
Block a user