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

@@ -963,7 +963,7 @@ logger.error("에러 발생")
--- ---
## 🐛 자주 발생하는 이슈 & 해결법 ## ⚠️ 자주 발생하는 이슈 & 해결법
### 1. 파이프 길이 합산 문제 ### 1. 파이프 길이 합산 문제
```python ```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일 (메인 서버 배포 가이드 추가)

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) id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey("files.id"), nullable=False) 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' 등 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.orm import Session
from sqlalchemy import text from sqlalchemy import text
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel
import os import os
import shutil import shutil
from datetime import datetime from datetime import datetime
@@ -2629,6 +2630,7 @@ async def get_user_requirements(
{ {
"id": req.id, "id": req.id,
"file_id": req.file_id, "file_id": req.file_id,
"material_id": req.material_id,
"original_filename": req.original_filename, "original_filename": req.original_filename,
"job_no": req.job_no, "job_no": req.job_no,
"revision": req.revision, "revision": req.revision,
@@ -2651,17 +2653,20 @@ async def get_user_requirements(
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"사용자 요구사항 조회 실패: {str(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") @router.post("/user-requirements")
async def create_user_requirement( async def create_user_requirement(
file_id: int, requirement: UserRequirementCreate,
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,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
@@ -2681,15 +2686,15 @@ async def create_user_requirement(
""") """)
result = db.execute(insert_query, { result = db.execute(insert_query, {
"file_id": file_id, "file_id": requirement.file_id,
"material_id": material_id, "material_id": requirement.material_id,
"requirement_type": requirement_type, "requirement_type": requirement.requirement_type,
"requirement_title": requirement_title, "requirement_title": requirement.requirement_title,
"requirement_description": requirement_description, "requirement_description": requirement.requirement_description,
"requirement_spec": requirement_spec, "requirement_spec": requirement.requirement_spec,
"priority": priority, "priority": requirement.priority,
"assigned_to": assigned_to, "assigned_to": requirement.assigned_to,
"due_date": due_date "due_date": requirement.due_date
}) })
requirement_id = result.fetchone()[0] 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: 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. 압력 등급 분류 # 4. 압력 등급 분류
pressure_result = classify_pressure_rating(dat_file, description) pressure_result = classify_pressure_rating(dat_file, description)
# 4.5. 스케줄 분류 (니플 등에 중요) # 4.5. 스케줄 분류 (니플 등에 중요) - 분리 스케줄 지원
schedule_result = classify_fitting_schedule(description) schedule_result = classify_fitting_schedule_with_reducing(description, main_nom, red_nom)
# 5. 제작 방법 추정 # 5. 제작 방법 추정
manufacturing_result = determine_fitting_manufacturing( 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, def classify_fitting_type(dat_file: str, description: str,
main_nom: str, red_nom: str = None) -> Dict: 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() dat_upper = dat_file.upper()
desc_upper = description.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차 분류 (가장 신뢰도 높음) # 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
for fitting_type, type_data in FITTING_TYPES.items(): for fitting_type, type_data in FITTING_TYPES.items():
for pattern in type_data["dat_file_patterns"]: for pattern in type_data["dat_file_patterns"]:
@@ -679,3 +801,53 @@ def classify_fitting_schedule(description: str) -> Dict:
"confidence": 0.0, "confidence": 0.0,
"matched_pattern": "" "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 import re
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from .fitting_classifier import classify_fitting
# Level 1: 명확한 타입 키워드 (최우선) # Level 1: 명확한 타입 키워드 (최우선)
LEVEL1_TYPE_KEYWORDS = { LEVEL1_TYPE_KEYWORDS = {
@@ -142,6 +143,18 @@ def classify_material_integrated(description: str, main_nom: str = "",
# 3단계: 단일 타입 확정 또는 Level 3/4로 판단 # 3단계: 단일 타입 확정 또는 Level 3/4로 판단
if len(detected_types) == 1: if len(detected_types) == 1:
material_type = detected_types[0][0] 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 { return {
"category": material_type, "category": material_type,
"confidence": 0.9, "confidence": 0.9,
@@ -171,6 +184,15 @@ def classify_material_integrated(description: str, main_nom: str = "",
if other_type_found: if other_type_found:
continue # 볼트로 분류하지 않음 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 { return {
"category": material_type, "category": material_type,
"confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도 "confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도
@@ -182,8 +204,19 @@ def classify_material_integrated(description: str, main_nom: str = "",
for material, priority_types in GENERIC_MATERIALS.items(): for material, priority_types in GENERIC_MATERIALS.items():
if material in desc_upper: 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 { return {
"category": priority_types[0], # 첫 번째 우선순위 "category": material_type,
"confidence": 0.3, "confidence": 0.3,
"evidence": [f"GENERIC_MATERIAL: {material}"], "evidence": [f"GENERIC_MATERIAL: {material}"],
"classification_level": "LEVEL4_GENERIC" "classification_level": "LEVEL4_GENERIC"

View File

@@ -23,6 +23,13 @@ def extract_full_material_grade(description: str) -> str:
# 1. ASTM 규격 패턴들 (가장 구체적인 것부터) # 1. ASTM 규격 패턴들 (가장 구체적인 것부터)
astm_patterns = [ 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 등 # ASTM A312 TP304, ASTM A312 TP316L 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*', r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*',
# ASTM A182 F304, ASTM A182 F316L 등 # ASTM A182 F304, ASTM A182 F316L 등
@@ -32,14 +39,14 @@ def extract_full_material_grade(description: str) -> str:
# ASTM A351 CF8M, ASTM A216 WCB 등 # ASTM A351 CF8M, ASTM A216 WCB 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*', 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 포함 # 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+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+GRADE\s+[A-Z0-9/]+',
# ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급 # ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)', r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)',
# ASTM A105, ASTM A234 등 (등급 없는 경우) # ASTM A105, ASTM A234 등 (등급 없는 경우)
r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])', r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])',
# 2자리 ASTM 규격도 지원 (A10, A36 등) # 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: for pattern in astm_patterns:

View File

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

View File

@@ -237,5 +237,6 @@ END $$;

View 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 $$;

View File

@@ -74,4 +74,5 @@ COMMENT ON COLUMN files.confirmed_by IS '구매 수량 확정자';

View File

@@ -975,6 +975,7 @@ CREATE TABLE IF NOT EXISTS requirement_types (
CREATE TABLE IF NOT EXISTS user_requirements ( CREATE TABLE IF NOT EXISTS user_requirements (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
file_id INTEGER NOT NULL, file_id INTEGER NOT NULL,
material_id INTEGER, -- 자재 ID (개별 자재별 요구사항 연결)
-- 요구사항 타입 -- 요구사항 타입
requirement_type VARCHAR(50) NOT NULL, -- 'IMPACT_TEST', 'HEAT_TREATMENT', 'CUSTOM_SPEC', 'CERTIFICATION' 등 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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_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) -- 사용자 요구사항 테이블 인덱스 (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_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_status ON user_requirements(status);
CREATE INDEX IF NOT EXISTS idx_user_requirements_type ON user_requirements(requirement_type); CREATE INDEX IF NOT EXISTS idx_user_requirements_type ON user_requirements(requirement_type);

View File

@@ -13,6 +13,10 @@ services:
- LOG_LEVEL=DEBUG - LOG_LEVEL=DEBUG
frontend: frontend:
volumes:
# 개발 시 코드 변경 실시간 반영
- ./frontend:/app
- /app/node_modules # node_modules는 컨테이너 것을 사용
environment: environment:
- VITE_API_URL=http://localhost:18000 - VITE_API_URL=http://localhost:18000
build: build:

View File

@@ -82,7 +82,7 @@ services:
container_name: tk-mp-frontend container_name: tk-mp-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${FRONTEND_PORT:-13000}:3000" - "${FRONTEND_PORT:-13000}:5173"
environment: environment:
- VITE_API_URL=${VITE_API_URL:-http://localhost:18000} - VITE_API_URL=${VITE_API_URL:-http://localhost:18000}
depends_on: depends_on:

View File

@@ -237,5 +237,6 @@ export default SimpleDashboard;

View File

@@ -554,5 +554,6 @@

View File

@@ -287,5 +287,6 @@ export default NavigationBar;

View File

@@ -267,5 +267,6 @@

View File

@@ -191,5 +191,6 @@ export default NavigationMenu;

View File

@@ -99,5 +99,6 @@ export default RevisionUploadDialog;

View File

@@ -318,5 +318,6 @@ export default SimpleFileUpload;

View File

@@ -281,5 +281,6 @@ export default DashboardPage;

View File

@@ -133,7 +133,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
const getErrorTypeIcon = (type) => { const getErrorTypeIcon = (type) => {
const icons = { const icons = {
'javascript_error': '🐛', 'javascript_error': '',
'api_error': '🌐', 'api_error': '🌐',
'user_action_error': '👤', 'user_action_error': '👤',
'promise_rejection': '⚠️', 'promise_rejection': '⚠️',
@@ -365,7 +365,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
border: '1px solid #e9ecef' border: '1px solid #e9ecef'
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}> <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 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
최근 오류 최근 오류
</h3> </h3>
@@ -458,7 +458,7 @@ const LogMonitoringPage = ({ onNavigate, user }) => {
background: '#f8f9fa' background: '#f8f9fa'
}}> }}>
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}> <h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
🐛 프론트엔드 오류 프론트엔드 오류
</h2> </h2>
</div> </div>

View File

@@ -236,5 +236,6 @@

View File

@@ -133,5 +133,6 @@ export default LoginPage;

View File

@@ -266,7 +266,7 @@
.detailed-grid-header { .detailed-grid-header {
display: grid; 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; padding: 12px 24px;
background: #f9fafb; background: #f9fafb;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
@@ -289,12 +289,12 @@
/* 피팅 전용 헤더 - 10개 컬럼 */ /* 피팅 전용 헤더 - 10개 컬럼 */
.detailed-grid-header.fitting-header { .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개 컬럼 */ /* 피팅 전용 행 - 10개 컬럼 */
.detailed-material-row.fitting-row { .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개 컬럼 (스케줄 제거, 타입 너비 증가) */ /* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
@@ -345,7 +345,7 @@
.detailed-material-row { .detailed-material-row {
display: grid; 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; padding: 12px 24px;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
align-items: center; align-items: center;

View File

@@ -17,14 +17,15 @@ const NewMaterialsPage = ({
}) => { }) => {
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('PIPE'); const [selectedCategory, setSelectedCategory] = useState('ALL');
const [selectedMaterials, setSelectedMaterials] = useState(new Set()); const [selectedMaterials, setSelectedMaterials] = useState(new Set());
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 [userRequirements, setUserRequirements] = useState({});
// materialId: requirement 형태
const [savingRequirements, setSavingRequirements] = useState(false); const [savingRequirements, setSavingRequirements] = useState(false);
// 같은 BOM의 다른 리비전들 조회 // 같은 BOM의 다른 리비전들 조회
@@ -101,16 +102,28 @@ const NewMaterialsPage = ({
params: { file_id: parseInt(id) } params: { file_id: parseInt(id) }
}); });
if (response.data?.success && response.data?.requirements) { if (response.data && Array.isArray(response.data)) {
const requirements = {}; const requirements = {};
response.data.requirements.forEach(req => { console.log('📦 API 응답 데이터:', response.data);
response.data.forEach(req => {
// material_id를 키로 사용하여 요구사항 저장 // material_id를 키로 사용하여 요구사항 저장
if (req.material_id) { if (req.material_id) {
requirements[req.material_id] = req.requirement_description || req.requirement_title || ''; 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); setUserRequirements(requirements);
console.log('🔄 setUserRequirements 호출 후 새로운 상태:', requirements);
console.log('✅ 사용자 요구사항 로드 완료:', Object.keys(requirements).length, '개'); console.log('✅ 사용자 요구사항 로드 완료:', Object.keys(requirements).length, '개');
// 상태 업데이트 확인을 위한 지연된 로그
setTimeout(() => {
console.log('⏰ 1초 후 실제 userRequirements 상태:', userRequirements);
}, 1000);
} }
} catch (error) { } catch (error) {
console.error('❌ 사용자 요구사항 로딩 실패:', error); console.error('❌ 사용자 요구사항 로딩 실패:', error);
@@ -122,11 +135,30 @@ const NewMaterialsPage = ({
const saveUserRequirements = async () => { const saveUserRequirements = async () => {
try { try {
setSavingRequirements(true); 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);
console.log('📋 전체 userRequirements 객체:', Object.keys(userRequirements).length, '개');
// 요구사항이 있는 자재들만 저장 // 요구사항이 있는 자재들만 저장
const requirementsToSave = Object.entries(userRequirements) const requirementsToSave = Object.entries(currentRequirements)
.filter(([materialId, requirement]) => requirement && requirement.trim()) .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]) => ({ .map(([materialId, requirement]) => ({
material_id: parseInt(materialId), material_id: parseInt(materialId),
file_id: parseInt(fileId), file_id: parseInt(fileId),
@@ -136,24 +168,52 @@ const NewMaterialsPage = ({
priority: 'NORMAL' priority: 'NORMAL'
})); }));
console.log('📝 저장할 요구사항 개수:', requirementsToSave.length);
if (requirementsToSave.length === 0) { if (requirementsToSave.length === 0) {
alert('저장할 요구사항이 없습니다.'); alert('저장할 요구사항이 없습니다.');
return; return;
} }
// 기존 요구사항 삭제 후 새로 저장 // 기존 요구사항 삭제 후 새로 저장
await api.delete(`/files/user-requirements`, { console.log('🗑️ 기존 요구사항 삭제 중...', { file_id: parseInt(fileId) });
params: { 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) { 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}개의 사용자 요구사항이 저장되었습니다.`); alert(`${requirementsToSave.length}개의 사용자 요구사항이 저장되었습니다.`);
console.log('✅ 사용자 요구사항 저장 완료'); console.log('✅ 사용자 요구사항 저장 완료');
// 저장 후 다시 로드하여 최신 상태 반영
console.log('🔄 저장 완료 후 다시 로드 시작...');
await loadUserRequirements(fileId);
console.log('🔄 저장 완료 후 다시 로드 완료!');
} catch (error) { } catch (error) {
console.error('❌ 사용자 요구사항 저장 실패:', error); console.error('❌ 사용자 요구사항 저장 실패:', error);
alert('사용자 요구사항 저장에 실패했습니다: ' + (error.response?.data?.detail || error.message)); alert('사용자 요구사항 저장에 실패했습니다: ' + (error.response?.data?.detail || error.message));
@@ -164,10 +224,15 @@ const NewMaterialsPage = ({
// 사용자 요구사항 입력 핸들러 // 사용자 요구사항 입력 핸들러
const handleUserRequirementChange = (materialId, value) => { const handleUserRequirementChange = (materialId, value) => {
setUserRequirements(prev => ({ console.log(`📝 사용자 요구사항 입력: 자재 ID ${materialId} = "${value}"`);
...prev, setUserRequirements(prev => {
[materialId]: value 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 parseMaterialInfo = (material) => {
const category = material.classified_category; const category = material.classified_category;
@@ -223,15 +360,41 @@ const NewMaterialsPage = ({
}; };
} else if (category === 'FITTING') { } else if (category === 'FITTING') {
const fittingDetails = material.fitting_details || {}; const fittingDetails = material.fitting_details || {};
const fittingType = fittingDetails.fitting_type || ''; const classificationDetails = material.classification_details || {};
const fittingSubtype = fittingDetails.fitting_subtype || '';
// 개선된 분류기 결과 우선 사용
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 || ''; const description = material.original_description || '';
// 피팅 타입별 상세 표시 // 피팅 타입별 상세 표시
let displayType = ''; 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) // CAP: 연결 방식 표시 (예: CAP, NPT(F), 3000LB, ASTM A105)
if (description.includes('NPT(F)')) { if (description.includes('NPT(F)')) {
displayType = 'CAP NPT(F)'; displayType = 'CAP NPT(F)';
@@ -260,7 +423,13 @@ const NewMaterialsPage = ({
} else if (fittingType === 'NIPPLE') { } else if (fittingType === 'NIPPLE') {
// 니플: 길이와 끝단 가공 정보 // 니플: 길이와 끝단 가공 정보
const length = fittingDetails.length_mm || fittingDetails.avg_length_mm; 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') { } else if (fittingType === 'ELBOW') {
// 엘보: 각도와 연결 방식 // 엘보: 각도와 연결 방식
const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : ''; const angle = fittingSubtype === '90DEG' ? '90°' : fittingSubtype === '45DEG' ? '45°' : '';
@@ -295,11 +464,25 @@ const NewMaterialsPage = ({
pressure = `${pressureMatch[1]}LB`; pressure = `${pressureMatch[1]}LB`;
} }
// 스케줄 찾기 // 스케줄 표시 (분리 스케줄 지원)
if (description.includes('SCH')) { if (hasDifferentSchedules && mainSchedule && redSchedule) {
const schMatch = description.match(/SCH\s*(\d+[A-Z]*)/i); // 분리 스케줄: "SCH 40 x SCH 80"
if (schMatch) { schedule = `${mainSchedule} x ${redSchedule}`;
schedule = `SCH ${schMatch[1]}`; } 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]}`;
}
} }
} }
@@ -397,12 +580,37 @@ const NewMaterialsPage = ({
const qty = Math.round(material.quantity || 0); const qty = Math.round(material.quantity || 0);
const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율 const safetyQty = Math.ceil(qty * 1.05); // 5% 여유율
const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수 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 { return {
type: 'BOLT', type: 'BOLT',
subtype: material.bolt_details?.bolt_type || '-', subtype: material.bolt_details?.bolt_type || 'BOLT_GENERAL',
size: material.size_spec || '-', size: material.size_spec || '-',
schedule: material.bolt_details?.length || '-', schedule: boltLength, // 길이 정보
grade: material.material_grade || '-', grade: material.full_material_grade || material.material_grade || '-',
additionalReq: additionalReq, // 추가요구사항
quantity: purchaseQty, quantity: purchaseQty,
unit: 'SETS' unit: 'SETS'
}; };
@@ -473,6 +681,9 @@ const NewMaterialsPage = ({
// 필터링된 자재 목록 // 필터링된 자재 목록
const filteredMaterials = materials.filter(material => { const filteredMaterials = materials.filter(material => {
if (selectedCategory === 'ALL') {
return true; // 전체 카테고리일 때는 모든 자재 표시
}
return material.classified_category === selectedCategory; return material.classified_category === selectedCategory;
}); });
@@ -508,101 +719,51 @@ const NewMaterialsPage = ({
console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목'); console.log('📊 엑셀 내보내기:', dataToExport.length, '개 항목');
// 카테고리별 컬럼 구성 // 새로운 엑셀 양식에 맞춘 컬럼 구성
const getExcelData = (material) => { const getExcelData = (material) => {
const info = parseMaterialInfo(material); const info = parseMaterialInfo(material);
// 품목명 생성 (간단하게)
let itemName = '';
if (selectedCategory === 'PIPE') { if (selectedCategory === 'PIPE') {
return { itemName = info.subtype || 'PIPE';
'종류': info.type, } else if (selectedCategory === 'FITTING') {
'타입': info.subtype, itemName = info.subtype || 'FITTING';
'크기': info.size, } else if (selectedCategory === 'FLANGE') {
'스케줄': info.schedule, itemName = info.subtype || 'FLANGE';
'재질': info.grade, } else if (selectedCategory === 'VALVE') {
'추가요구': '-', itemName = info.valveType || info.subtype || 'VALVE';
'사용자요구': userRequirements[material.id] || '', } else if (selectedCategory === 'GASKET') {
'수량': `${info.quantity} ${info.unit}`, itemName = info.subtype || 'GASKET';
'상세': `단관 ${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}`
};
} else if (selectedCategory === 'BOLT') { } else if (selectedCategory === 'BOLT') {
return { itemName = info.subtype || 'BOLT';
'종류': 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}`
};
} else { } else {
// 기본 형식 itemName = info.subtype || info.type || 'UNKNOWN';
return {
'종류': info.type,
'타입': info.subtype,
'크기': info.size,
'스케줄': info.schedule,
'재질': info.grade,
'추가요구': '-',
'사용자요구': userRequirements[material.id] || '',
'수량': `${info.quantity} ${info.unit}`
};
} }
// 사용자 요구사항 확인
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>
<div className="header-right"> <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"> <span className="material-count">
{materials.length} 자재 ({currentRevision}) {materials.length} 자재 ({currentRevision})
</span> </span>
@@ -702,13 +882,22 @@ const NewMaterialsPage = ({
{/* 카테고리 필터 */} {/* 카테고리 필터 */}
<div className="category-filters"> <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]) => ( {Object.entries(categoryCounts).map(([category, count]) => (
<button <button
key={category} key={category}
className={`category-btn ${selectedCategory === category ? 'active' : ''}`} className={`category-btn ${selectedCategory === category ? 'active' : ''}`}
onClick={() => setSelectedCategory(category)} onClick={() => setSelectedCategory(category)}
> >
{category} <span className="count">{count}</span> {getCategoryDisplayName(category)} <span className="count">{count}</span>
</button> </button>
))} ))}
</div> </div>
@@ -885,7 +1074,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */} {/* 추가요구 */}
<div className="material-cell"> <div className="material-cell">
<span>-</span> <span>{info.additionalReq || '-'}</span>
</div> </div>
{/* 사용자요구 */} {/* 사용자요구 */}
@@ -895,7 +1084,10 @@ const NewMaterialsPage = ({
className="user-req-input" className="user-req-input"
placeholder="요구사항 입력" placeholder="요구사항 입력"
value={userRequirements[material.id] || ''} 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> </div>
@@ -954,7 +1146,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */} {/* 추가요구 */}
<div className="material-cell"> <div className="material-cell">
<span>-</span> <span>{info.additionalReq || '-'}</span>
</div> </div>
{/* 사용자요구 */} {/* 사용자요구 */}
@@ -964,7 +1156,10 @@ const NewMaterialsPage = ({
className="user-req-input" className="user-req-input"
placeholder="요구사항 입력" placeholder="요구사항 입력"
value={userRequirements[material.id] || ''} 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> </div>
@@ -1030,7 +1225,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */} {/* 추가요구 */}
<div className="material-cell"> <div className="material-cell">
<span>-</span> <span>{info.additionalReq || '-'}</span>
</div> </div>
{/* 사용자요구 */} {/* 사용자요구 */}
@@ -1040,7 +1235,10 @@ const NewMaterialsPage = ({
className="user-req-input" className="user-req-input"
placeholder="요구사항 입력" placeholder="요구사항 입력"
value={userRequirements[material.id] || ''} 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> </div>
@@ -1093,7 +1291,10 @@ const NewMaterialsPage = ({
className="user-req-input" className="user-req-input"
placeholder="요구사항 입력" placeholder="요구사항 입력"
value={userRequirements[material.id] || ''} 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> </div>
@@ -1164,7 +1365,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */} {/* 추가요구 */}
<div className="material-cell"> <div className="material-cell">
<span>-</span> <span>{info.additionalReq || '-'}</span>
</div> </div>
{/* 사용자요구 */} {/* 사용자요구 */}
@@ -1174,7 +1375,10 @@ const NewMaterialsPage = ({
className="user-req-input" className="user-req-input"
placeholder="요구사항 입력" placeholder="요구사항 입력"
value={userRequirements[material.id] || ''} 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> </div>
@@ -1234,7 +1438,7 @@ const NewMaterialsPage = ({
{/* 추가요구 */} {/* 추가요구 */}
<div className="material-cell"> <div className="material-cell">
<span>-</span> <span>{info.additionalReq || '-'}</span>
</div> </div>
{/* 사용자요구 */} {/* 사용자요구 */}
@@ -1243,6 +1447,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

@@ -405,5 +405,6 @@ export default ProjectsPage;

View File

@@ -447,5 +447,6 @@

View File

@@ -120,7 +120,6 @@ const consolidateMaterials = (materials, isComparison = false) => {
*/ */
const formatMaterialForExcel = (material, includeComparison = false) => { const formatMaterialForExcel = (material, includeComparison = false) => {
const category = material.classified_category || material.category || '-'; const category = material.classified_category || material.category || '-';
const isPipe = category === 'PIPE';
// 엑셀용 자재 설명 정제 // 엑셀용 자재 설명 정제
let cleanDescription = material.original_description || material.description || '-'; let cleanDescription = material.original_description || material.description || '-';
@@ -135,16 +134,12 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
// 니플의 경우 길이 정보 명시적 추가 // 니플의 경우 길이 정보 명시적 추가
if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) { if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) {
// fitting_details에서 길이 정보 가져오기
if (material.fitting_details && material.fitting_details.length_mm) { if (material.fitting_details && material.fitting_details.length_mm) {
const lengthMm = Math.round(material.fitting_details.length_mm); const lengthMm = Math.round(material.fitting_details.length_mm);
// 이미 길이 정보가 있는지 확인
if (!cleanDescription.match(/\d+\s*mm/i)) { if (!cleanDescription.match(/\d+\s*mm/i)) {
cleanDescription += ` ${lengthMm}mm`; cleanDescription += ` ${lengthMm}mm`;
} }
} } else {
// 또는 기존 설명에서 길이 정보 추출
else {
const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i); const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i);
if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) { if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) {
cleanDescription += ` ${lengthMatch[1]}mm`; cleanDescription += ` ${lengthMatch[1]}mm`;
@@ -155,31 +150,79 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
// 구매 수량 계산 // 구매 수량 계산
const purchaseInfo = calculatePurchaseQuantity(material); 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 = { const base = {
'카테고리': category, 'TAGNO': '', // 비워둠
'자재 설명': cleanDescription, '품목명': itemName,
'사이즈': material.size_spec || '-' '수량': 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 (includeComparison) {
if (material.previous_quantity !== undefined) { if (material.previous_quantity !== undefined) {
// 이전 구매 수량 계산
const prevPurchaseInfo = calculatePurchaseQuantity({ const prevPurchaseInfo = calculatePurchaseQuantity({
...material, ...material,
quantity: material.previous_quantity, quantity: material.previous_quantity,
totalLength: material.previousTotalLength || 0 totalLength: material.previousTotalLength || 0
}); });
base['이전 필요 수량'] = prevPurchaseInfo.purchaseQuantity || 0; base['이전수량'] = prevPurchaseInfo.purchaseQuantity || 0;
base['필요 수량 변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity); base['수량변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity);
} }
base['변경 유형'] = material.change_type || ( base['변경유형'] = material.change_type || (
material.previous_quantity !== undefined ? '수량 변경' : material.previous_quantity !== undefined ? '수량 변경' :
material.quantity_change === undefined ? '신규' : '변경' material.quantity_change === undefined ? '신규' : '변경'
); );

View File

@@ -5,9 +5,9 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 13000, port: 5173,
host: true, host: true,
open: true open: false
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',

2
test_bom.csv Normal file
View File

@@ -0,0 +1,2 @@
Item,Description,Quantity
1,Test Item,10
1 Item Description Quantity
2 1 Test Item 10