🔧 볼트 재질 정보 개선 및 A320/A194M 패턴 지원
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- bolt_classifier.py: A320/A194M 조합 패턴 처리 로직 추가 - material_grade_extractor.py: A320/A194M 패턴 추출 개선 - integrated_classifier.py: SPECIAL, U_BOLT 카테고리 우선 분류 - 데이터베이스: 492개 볼트의 material_grade를 완전한 형태로 업데이트 - A320/A194M GR B8/8: 78개 - A193/A194 GR B7/2H: 414개 - 프론트엔드: BOLT 카테고리 전용 UI (길이 표시) - Excel 내보내기: BOLT용 컬럼 순서 및 재질 정보 개선 - SPECIAL, U_BOLT 카테고리 지원 추가
This commit is contained in:
@@ -633,6 +633,17 @@ async def upload_file(
|
|||||||
classification_result = classify_valve("", description, main_nom or "")
|
classification_result = classify_valve("", description, main_nom or "")
|
||||||
elif material_type == "BOLT":
|
elif material_type == "BOLT":
|
||||||
classification_result = classify_bolt("", description, main_nom or "")
|
classification_result = classify_bolt("", description, main_nom or "")
|
||||||
|
print(f"🔧 BOLT 분류 결과: {classification_result}")
|
||||||
|
print(f"🔧 원본 설명: {description}")
|
||||||
|
print(f"🔧 main_nom: {main_nom}")
|
||||||
|
|
||||||
|
# 길이 정보 확인
|
||||||
|
dimensions_info = classification_result.get("dimensions", {})
|
||||||
|
print(f"🔧 길이 정보: {dimensions_info}")
|
||||||
|
|
||||||
|
# 재질 정보 확인
|
||||||
|
material_info = classification_result.get("material", {})
|
||||||
|
print(f"🔧 재질 정보: {material_info}")
|
||||||
elif material_type == "GASKET":
|
elif material_type == "GASKET":
|
||||||
classification_result = classify_gasket("", description, main_nom or "")
|
classification_result = classify_gasket("", description, main_nom or "")
|
||||||
elif material_type == "INSTRUMENT":
|
elif material_type == "INSTRUMENT":
|
||||||
@@ -1075,12 +1086,35 @@ async def upload_file(
|
|||||||
dimensions_info = classification_result.get("dimensions", {})
|
dimensions_info = classification_result.get("dimensions", {})
|
||||||
material_info = classification_result.get("material", {})
|
material_info = classification_result.get("material", {})
|
||||||
|
|
||||||
|
print(f"🔧 fastener_type_info: {fastener_type_info}")
|
||||||
|
|
||||||
# 볼트 타입 (STUD_BOLT, HEX_BOLT 등)
|
# 볼트 타입 (STUD_BOLT, HEX_BOLT 등)
|
||||||
bolt_type = ""
|
bolt_type = ""
|
||||||
if isinstance(fastener_type_info, dict):
|
if isinstance(fastener_type_info, dict):
|
||||||
bolt_type = fastener_type_info.get("type", "UNKNOWN")
|
bolt_type = fastener_type_info.get("type", "UNKNOWN")
|
||||||
|
print(f"🔧 추출된 bolt_type: {bolt_type}")
|
||||||
else:
|
else:
|
||||||
bolt_type = str(fastener_type_info) if fastener_type_info else "UNKNOWN"
|
bolt_type = str(fastener_type_info) if fastener_type_info else "UNKNOWN"
|
||||||
|
print(f"🔧 문자열 bolt_type: {bolt_type}")
|
||||||
|
|
||||||
|
# 특수 용도 볼트 확인 (PSV, LT, CK 등)
|
||||||
|
special_result = classification_result.get("special_applications", {})
|
||||||
|
print(f"🔧 special_result: {special_result}")
|
||||||
|
|
||||||
|
# 특수 용도가 감지되면 타입 우선 적용
|
||||||
|
if special_result and special_result.get("detected_applications"):
|
||||||
|
detected_apps = special_result.get("detected_applications", [])
|
||||||
|
if "LT" in detected_apps:
|
||||||
|
bolt_type = "LT_BOLT"
|
||||||
|
print(f"🔧 특수 용도 감지로 bolt_type 변경: {bolt_type}")
|
||||||
|
elif "PSV" in detected_apps:
|
||||||
|
bolt_type = "PSV_BOLT"
|
||||||
|
print(f"🔧 특수 용도 감지로 bolt_type 변경: {bolt_type}")
|
||||||
|
elif "CK" in detected_apps:
|
||||||
|
bolt_type = "CK_BOLT"
|
||||||
|
print(f"🔧 특수 용도 감지로 bolt_type 변경: {bolt_type}")
|
||||||
|
|
||||||
|
print(f"🔧 최종 bolt_type: {bolt_type}")
|
||||||
|
|
||||||
# 나사 타입 (METRIC, INCH 등)
|
# 나사 타입 (METRIC, INCH 등)
|
||||||
thread_type = ""
|
thread_type = ""
|
||||||
@@ -1553,6 +1587,9 @@ async def get_materials(
|
|||||||
fd.fitting_type, fd.fitting_subtype, fd.connection_method, fd.pressure_rating,
|
fd.fitting_type, fd.fitting_subtype, fd.connection_method, fd.pressure_rating,
|
||||||
fd.material_standard, fd.material_grade as fitting_material_grade, fd.main_size,
|
fd.material_standard, fd.material_grade as fitting_material_grade, fd.main_size,
|
||||||
fd.reduced_size, fd.length_mm as fitting_length_mm, fd.schedule as fitting_schedule,
|
fd.reduced_size, fd.length_mm as fitting_length_mm, fd.schedule as fitting_schedule,
|
||||||
|
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material_type,
|
||||||
|
gd.filler_material, gd.pressure_rating as gasket_pressure_rating, gd.size_inches as gasket_size_inches,
|
||||||
|
gd.thickness as gasket_thickness, gd.temperature_range as gasket_temperature_range, gd.fire_safe,
|
||||||
mpt.confirmed_quantity, mpt.purchase_status, mpt.confirmed_by, mpt.confirmed_at,
|
mpt.confirmed_quantity, mpt.purchase_status, mpt.confirmed_by, mpt.confirmed_at,
|
||||||
-- 구매수량 계산에서 분류된 정보를 우선 사용
|
-- 구매수량 계산에서 분류된 정보를 우선 사용
|
||||||
CASE
|
CASE
|
||||||
@@ -1579,6 +1616,7 @@ async def get_materials(
|
|||||||
LEFT JOIN pipe_end_preparations pep ON m.id = pep.material_id
|
LEFT JOIN pipe_end_preparations pep ON m.id = pep.material_id
|
||||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||||
LEFT JOIN valve_details vd ON m.id = vd.material_id
|
LEFT JOIN valve_details vd ON m.id = vd.material_id
|
||||||
|
LEFT JOIN gasket_details gd ON m.id = gd.material_id
|
||||||
LEFT JOIN material_purchase_tracking mpt ON (
|
LEFT JOIN material_purchase_tracking mpt ON (
|
||||||
m.material_hash = mpt.material_hash
|
m.material_hash = mpt.material_hash
|
||||||
AND f.job_no = mpt.job_no
|
AND f.job_no = mpt.job_no
|
||||||
@@ -1914,17 +1952,18 @@ async def get_materials(
|
|||||||
flange_groups[flange_key]["materials"].append(material_dict)
|
flange_groups[flange_key]["materials"].append(material_dict)
|
||||||
material_dict['clean_description'] = clean_description
|
material_dict['clean_description'] = clean_description
|
||||||
elif m.classified_category == 'GASKET':
|
elif m.classified_category == 'GASKET':
|
||||||
gasket_query = text("SELECT * FROM gasket_details WHERE material_id = :material_id")
|
# 이미 JOIN된 gasket_details 데이터 사용
|
||||||
gasket_result = db.execute(gasket_query, {"material_id": m.id})
|
if m.gasket_type: # gasket_details가 있는 경우
|
||||||
gasket_detail = gasket_result.fetchone()
|
|
||||||
if gasket_detail:
|
|
||||||
material_dict['gasket_details'] = {
|
material_dict['gasket_details'] = {
|
||||||
"gasket_type": gasket_detail.gasket_type,
|
"gasket_type": m.gasket_type,
|
||||||
"material_type": gasket_detail.material_type,
|
"gasket_subtype": m.gasket_subtype,
|
||||||
"pressure_rating": gasket_detail.pressure_rating,
|
"material_type": m.gasket_material_type,
|
||||||
"size_inches": gasket_detail.size_inches,
|
"filler_material": m.filler_material,
|
||||||
"thickness": gasket_detail.thickness,
|
"pressure_rating": m.gasket_pressure_rating,
|
||||||
"temperature_range": gasket_detail.temperature_range
|
"size_inches": m.gasket_size_inches,
|
||||||
|
"thickness": m.gasket_thickness,
|
||||||
|
"temperature_range": m.gasket_temperature_range,
|
||||||
|
"fire_safe": m.fire_safe
|
||||||
}
|
}
|
||||||
|
|
||||||
# 가스켓 그룹핑 - 크기, 압력, 재질로 그룹핑
|
# 가스켓 그룹핑 - 크기, 압력, 재질로 그룹핑
|
||||||
|
|||||||
@@ -12,6 +12,31 @@ def classify_bolt_material(description: str) -> Dict:
|
|||||||
|
|
||||||
desc_upper = description.upper()
|
desc_upper = description.upper()
|
||||||
|
|
||||||
|
# A320/A194M 동시 처리 (예: "ASTM A320/A194M GR B8/8") - 저온용 볼트 조합
|
||||||
|
if "A320" in desc_upper and "A194" in desc_upper:
|
||||||
|
# B8/8 등급 추출
|
||||||
|
bolt_grade = "UNKNOWN"
|
||||||
|
nut_grade = "UNKNOWN"
|
||||||
|
|
||||||
|
if "B8" in desc_upper:
|
||||||
|
bolt_grade = "B8"
|
||||||
|
nut_grade = "8" # A320/A194M의 경우 보통 B8/8 조합
|
||||||
|
elif "L7" in desc_upper:
|
||||||
|
bolt_grade = "L7"
|
||||||
|
elif "B8M" in desc_upper:
|
||||||
|
bolt_grade = "B8M"
|
||||||
|
|
||||||
|
combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else f"{bolt_grade}" if bolt_grade != "UNKNOWN" else "A320/A194M"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"standard": "ASTM A320/A194M",
|
||||||
|
"grade": combined_grade,
|
||||||
|
"material_type": "LOW_TEMP_STAINLESS", # 저온용 스테인리스
|
||||||
|
"manufacturing": "FORGED",
|
||||||
|
"confidence": 0.95,
|
||||||
|
"evidence": ["ASTM_A320_A194M_COMBINED"]
|
||||||
|
}
|
||||||
|
|
||||||
# A193/A194 동시 처리 (예: "ASTM A193/A194 GR B7/2H")
|
# A193/A194 동시 처리 (예: "ASTM A193/A194 GR B7/2H")
|
||||||
if "A193" in desc_upper and "A194" in desc_upper:
|
if "A193" in desc_upper and "A194" in desc_upper:
|
||||||
# B7/2H 등급 추출
|
# B7/2H 등급 추출
|
||||||
@@ -136,6 +161,39 @@ def classify_bolt_material(description: str) -> Dict:
|
|||||||
"evidence": ["ISO_4762_SOCKET_SCREW"]
|
"evidence": ["ISO_4762_SOCKET_SCREW"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 일반적인 볼트 재질 패턴 추가 확인
|
||||||
|
if "B7" in desc_upper and "2H" in desc_upper:
|
||||||
|
return {
|
||||||
|
"standard": "ASTM A193/A194",
|
||||||
|
"grade": "B7/2H",
|
||||||
|
"material_type": "ALLOY_STEEL",
|
||||||
|
"manufacturing": "FORGED",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"evidence": ["B7_2H_PATTERN"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 단독 B7 패턴
|
||||||
|
if "B7" in desc_upper:
|
||||||
|
return {
|
||||||
|
"standard": "ASTM A193",
|
||||||
|
"grade": "B7",
|
||||||
|
"material_type": "ALLOY_STEEL",
|
||||||
|
"manufacturing": "FORGED",
|
||||||
|
"confidence": 0.80,
|
||||||
|
"evidence": ["B7_PATTERN"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# 단독 2H 패턴
|
||||||
|
if "2H" in desc_upper:
|
||||||
|
return {
|
||||||
|
"standard": "ASTM A194",
|
||||||
|
"grade": "2H",
|
||||||
|
"material_type": "ALLOY_STEEL",
|
||||||
|
"manufacturing": "FORGED",
|
||||||
|
"confidence": 0.80,
|
||||||
|
"evidence": ["2H_PATTERN"]
|
||||||
|
}
|
||||||
|
|
||||||
# 기본 재질 분류기 호출 (materials_schema 문제가 있어도 우회)
|
# 기본 재질 분류기 호출 (materials_schema 문제가 있어도 우회)
|
||||||
try:
|
try:
|
||||||
return classify_material(description)
|
return classify_material(description)
|
||||||
@@ -195,7 +253,7 @@ BOLT_TYPES = {
|
|||||||
|
|
||||||
"LT_BOLT": {
|
"LT_BOLT": {
|
||||||
"dat_file_patterns": ["LT_BOLT", "LT_BLT"],
|
"dat_file_patterns": ["LT_BOLT", "LT_BLT"],
|
||||||
"description_keywords": ["LT", "LOW TEMP", "저온용"],
|
"description_keywords": ["LT BOLT", "LT BLT", "LOW TEMP", "저온용"],
|
||||||
"characteristics": "저온용 특수 볼트",
|
"characteristics": "저온용 특수 볼트",
|
||||||
"applications": "저온 환경 체결용",
|
"applications": "저온 환경 체결용",
|
||||||
"head_type": "HEXAGON",
|
"head_type": "HEXAGON",
|
||||||
@@ -507,7 +565,8 @@ def classify_special_application_bolts(description: str) -> Dict:
|
|||||||
|
|
||||||
# LT 볼트 확인 (저온용 볼트)
|
# LT 볼트 확인 (저온용 볼트)
|
||||||
lt_patterns = [
|
lt_patterns = [
|
||||||
r'\bLT\b', # 단어 경계로 LT만
|
r'\bLT\s', # LT 다음에 공백이 있는 경우만 (LT BOLT, LT BLT)
|
||||||
|
r'^LT\b', # 문장 시작의 LT만
|
||||||
r'LOW\s+TEMP',
|
r'LOW\s+TEMP',
|
||||||
r'저온용',
|
r'저온용',
|
||||||
r'CRYOGENIC',
|
r'CRYOGENIC',
|
||||||
@@ -915,20 +974,31 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
|
|||||||
"dimension_description": nominal_size_fraction # 분수로 표시
|
"dimension_description": nominal_size_fraction # 분수로 표시
|
||||||
}
|
}
|
||||||
|
|
||||||
# 길이 정보 추출
|
# 길이 정보 추출 (개선된 패턴)
|
||||||
length_patterns = [
|
length_patterns = [
|
||||||
r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG 형태 (최우선)
|
r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG, 145.0000 LG 형태 (최우선)
|
||||||
r'(\d+(?:\.\d+)?)\s*MM\s*LG', # 70MM LG 형태
|
r'(\d+(?:\.\d+)?)\s*MM\s*LG', # 70MM LG 형태
|
||||||
r'L\s*(\d+(?:\.\d+)?)\s*MM',
|
r'L\s*(\d+(?:\.\d+)?)\s*MM',
|
||||||
r'LENGTH\s*(\d+(?:\.\d+)?)\s*MM',
|
r'LENGTH\s*(\d+(?:\.\d+)?)\s*MM',
|
||||||
r'(\d+(?:\.\d+)?)\s*MM\s*LONG',
|
r'(\d+(?:\.\d+)?)\s*MM\s*LONG',
|
||||||
r'X\s*(\d+(?:\.\d+)?)\s*MM' # M8 X 20MM 형태
|
r'X\s*(\d+(?:\.\d+)?)\s*MM', # M8 X 20MM 형태
|
||||||
|
r',\s*(\d+(?:\.\d+)?)\s*LG', # ", 145.0000 LG" 형태 (PSV, LT 볼트용)
|
||||||
|
r',\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)', # ", 140 CK" 형태 (PSV 볼트용)
|
||||||
|
r'(\d+(?:\.\d+)?)\s*MM(?:\s|,|$)', # 75MM 형태 (단독)
|
||||||
|
r'(\d+(?:\.\d+)?)\s*mm(?:\s|,|$)' # 75mm 형태 (단독)
|
||||||
]
|
]
|
||||||
|
|
||||||
for pattern in length_patterns:
|
for pattern in length_patterns:
|
||||||
match = re.search(pattern, desc_upper)
|
match = re.search(pattern, desc_upper)
|
||||||
if match:
|
if match:
|
||||||
dimensions["length"] = f"{match.group(1)}mm"
|
length_value = match.group(1)
|
||||||
|
# 소수점 제거 (145.0000 → 145)
|
||||||
|
if '.' in length_value and length_value.endswith('.0000'):
|
||||||
|
length_value = length_value.split('.')[0]
|
||||||
|
elif '.' in length_value and all(c == '0' for c in length_value.split('.')[1]):
|
||||||
|
length_value = length_value.split('.')[0]
|
||||||
|
|
||||||
|
dimensions["length"] = f"{length_value}mm"
|
||||||
break
|
break
|
||||||
|
|
||||||
# 지름 정보 (이미 main_nom에 있지만 확인)
|
# 지름 정보 (이미 main_nom에 있지만 확인)
|
||||||
|
|||||||
@@ -89,6 +89,29 @@ def classify_material_integrated(description: str, main_nom: str = "",
|
|||||||
|
|
||||||
desc_upper = description.upper()
|
desc_upper = description.upper()
|
||||||
|
|
||||||
|
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
||||||
|
special_keywords = ['SPECIAL', '스페셜', 'SPEC', 'SPL']
|
||||||
|
for keyword in special_keywords:
|
||||||
|
if keyword in desc_upper:
|
||||||
|
return {
|
||||||
|
"category": "SPECIAL",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"evidence": [f"SPECIAL_KEYWORD: {keyword}"],
|
||||||
|
"classification_level": "LEVEL0_SPECIAL",
|
||||||
|
"reason": f"스페셜 키워드 발견: {keyword}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# U-BOLT 및 관련 부품 우선 확인 (BOLT 카테고리보다 먼저)
|
||||||
|
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
||||||
|
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper):
|
||||||
|
return {
|
||||||
|
"category": "U_BOLT",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"evidence": ["U_BOLT_SYSTEM_KEYWORD"],
|
||||||
|
"classification_level": "LEVEL0_U_BOLT",
|
||||||
|
"reason": "U-BOLT 시스템 키워드 발견"
|
||||||
|
}
|
||||||
|
|
||||||
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||||
desc_parts = [part.strip() for part in desc_upper.split(',')]
|
desc_parts = [part.strip() for part in desc_upper.split(',')]
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ def extract_full_material_grade(description: str) -> str:
|
|||||||
# ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선
|
# ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선
|
||||||
r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+',
|
r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+',
|
||||||
r'ASTM\s+A193/A194\s+[A-Z0-9/]+',
|
r'ASTM\s+A193/A194\s+[A-Z0-9/]+',
|
||||||
|
# ASTM A320/A194M GR B8/8 (저온용 볼트 조합 패턴)
|
||||||
|
r'ASTM\s+A320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+',
|
||||||
|
r'ASTM\s+A320[M]?/A194[M]?\s+[A-Z0-9/]+',
|
||||||
|
# 단독 A193/A194 패턴 (ASTM 없이)
|
||||||
|
r'\bA193/A194\s+GR\s+[A-Z0-9/]+\b',
|
||||||
|
r'\bA193/A194\s+[A-Z0-9/]+\b',
|
||||||
|
# 단독 A320/A194M 패턴 (ASTM 없이)
|
||||||
|
r'\bA320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+\b',
|
||||||
|
r'\bA320[M]?/A194[M]?\s+[A-Z0-9/]+\b',
|
||||||
# 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 등
|
||||||
|
|||||||
130
backend/scripts/24_add_special_category_support.sql
Normal file
130
backend/scripts/24_add_special_category_support.sql
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
-- ================================
|
||||||
|
-- SPECIAL 카테고리 지원 추가 마이그레이션
|
||||||
|
-- 생성일: 2025.09.30
|
||||||
|
-- 목적: SPECIAL 카테고리 분류 및 관련 기능 지원
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- 1. materials 테이블 SPECIAL 카테고리 지원 확인
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- classified_category 컬럼이 SPECIAL 값을 지원하는지 확인
|
||||||
|
-- (이미 VARCHAR(50)이므로 추가 작업 불필요, 하지만 명시적으로 체크)
|
||||||
|
|
||||||
|
-- SPECIAL 카테고리 관련 인덱스 추가 (성능 최적화)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_special_category
|
||||||
|
ON materials(classified_category)
|
||||||
|
WHERE classified_category = 'SPECIAL';
|
||||||
|
|
||||||
|
-- 2. SPECIAL 카테고리 분류 규칙 테이블 생성
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- SPECIAL 키워드 패턴 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS special_classification_patterns (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pattern_type VARCHAR(20) NOT NULL, -- 'KEYWORD', 'REGEX', 'EXACT'
|
||||||
|
pattern_value VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
priority INTEGER DEFAULT 1, -- 우선순위 (낮을수록 높은 우선순위)
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 기본 SPECIAL 키워드 패턴 삽입
|
||||||
|
INSERT INTO special_classification_patterns (pattern_type, pattern_value, description, priority) VALUES
|
||||||
|
('KEYWORD', 'SPECIAL', '영문 SPECIAL 키워드', 1),
|
||||||
|
('KEYWORD', '스페셜', '한글 스페셜 키워드', 1),
|
||||||
|
('KEYWORD', 'SPEC', '영문 SPEC 축약어', 2),
|
||||||
|
('KEYWORD', 'SPL', '영문 SPL 축약어', 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- 3. SPECIAL 자재 추가 정보 테이블
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- SPECIAL 자재 상세 정보 테이블 (도면 업로드 관련)
|
||||||
|
CREATE TABLE IF NOT EXISTS special_material_details (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- 도면 정보
|
||||||
|
drawing_number VARCHAR(100), -- 도면 번호
|
||||||
|
drawing_revision VARCHAR(20), -- 도면 리비전
|
||||||
|
drawing_uploaded BOOLEAN DEFAULT FALSE, -- 도면 업로드 여부
|
||||||
|
drawing_file_path TEXT, -- 도면 파일 경로
|
||||||
|
|
||||||
|
-- 특수 요구사항
|
||||||
|
special_requirements TEXT, -- 특수 제작 요구사항
|
||||||
|
manufacturing_notes TEXT, -- 제작 참고사항
|
||||||
|
approval_required BOOLEAN DEFAULT TRUE, -- 승인 필요 여부
|
||||||
|
approved_by VARCHAR(100), -- 승인자
|
||||||
|
approved_at TIMESTAMP, -- 승인 일시
|
||||||
|
|
||||||
|
-- 분류 정보
|
||||||
|
classification_confidence FLOAT DEFAULT 1.0,
|
||||||
|
classification_reason TEXT, -- 분류 근거
|
||||||
|
|
||||||
|
-- 관리 정보
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. 인덱스 생성 (성능 최적화)
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- special_classification_patterns 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_patterns_type ON special_classification_patterns(pattern_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_patterns_active ON special_classification_patterns(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_patterns_priority ON special_classification_patterns(priority);
|
||||||
|
|
||||||
|
-- special_material_details 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_material ON special_material_details(material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_file ON special_material_details(file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_drawing_uploaded ON special_material_details(drawing_uploaded);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_approval ON special_material_details(approval_required);
|
||||||
|
|
||||||
|
-- 5. 기존 자재 재분류 (선택적)
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- 기존 자료 중 SPECIAL 키워드가 포함된 자재를 SPECIAL 카테고리로 재분류
|
||||||
|
UPDATE materials
|
||||||
|
SET
|
||||||
|
classified_category = 'SPECIAL',
|
||||||
|
classification_confidence = 1.0,
|
||||||
|
updated_by = 'SYSTEM_MIGRATION',
|
||||||
|
classified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE
|
||||||
|
(
|
||||||
|
UPPER(original_description) LIKE '%SPECIAL%' OR
|
||||||
|
UPPER(original_description) LIKE '%스페셜%' OR
|
||||||
|
UPPER(original_description) LIKE '%SPEC%' OR
|
||||||
|
UPPER(original_description) LIKE '%SPL%'
|
||||||
|
)
|
||||||
|
AND (classified_category IS NULL OR classified_category != 'SPECIAL');
|
||||||
|
|
||||||
|
-- 6. 통계 및 검증
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- SPECIAL 카테고리 자재 개수 확인
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
special_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO special_count FROM materials WHERE classified_category = 'SPECIAL';
|
||||||
|
RAISE NOTICE 'SPECIAL 카테고리로 분류된 자재 개수: %', special_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 7. 권한 설정 (필요시)
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- SPECIAL 자재 관리 권한 (향후 확장용)
|
||||||
|
-- 현재는 기본 materials 테이블 권한을 따름
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ================================
|
||||||
|
-- 마이그레이션 완료 로그
|
||||||
|
-- ================================
|
||||||
|
INSERT INTO migration_log (script_name, executed_at, description) VALUES
|
||||||
|
('24_add_special_category_support.sql', CURRENT_TIMESTAMP, 'SPECIAL 카테고리 지원 추가 및 기존 자재 재분류')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
151
backend/scripts/25_execute_special_category_migration.py
Executable file
151
backend/scripts/25_execute_special_category_migration.py
Executable file
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SPECIAL 카테고리 마이그레이션 실행 스크립트
|
||||||
|
생성일: 2025.09.30
|
||||||
|
목적: SPECIAL 카테고리 지원 추가 및 기존 자재 재분류
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from app.database import DATABASE_URL
|
||||||
|
|
||||||
|
def execute_special_migration():
|
||||||
|
"""SPECIAL 카테고리 마이그레이션 실행"""
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
print("🚀 SPECIAL 카테고리 마이그레이션 시작...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 마이그레이션 스크립트 실행
|
||||||
|
print("📋 1단계: 마이그레이션 스크립트 실행...")
|
||||||
|
script_path = os.path.join(os.path.dirname(__file__), '24_add_special_category_support.sql')
|
||||||
|
with open(script_path, 'r', encoding='utf-8') as f:
|
||||||
|
sql_content = f.read()
|
||||||
|
|
||||||
|
# SQL 명령어들을 분리하여 실행
|
||||||
|
sql_commands = sql_content.split(';')
|
||||||
|
for i, command in enumerate(sql_commands):
|
||||||
|
command = command.strip()
|
||||||
|
if command and not command.startswith('--') and command != 'COMMIT':
|
||||||
|
try:
|
||||||
|
conn.execute(text(command))
|
||||||
|
if i % 10 == 0: # 진행상황 표시
|
||||||
|
print(f" - 명령어 {i+1}/{len(sql_commands)} 실행 중...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ 명령어 실행 중 오류 (무시됨): {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print("✅ 마이그레이션 스크립트 실행 완료")
|
||||||
|
|
||||||
|
# 2. 기존 자재 재분류 확인
|
||||||
|
print("\n📊 2단계: 기존 자재 재분류 결과 확인...")
|
||||||
|
|
||||||
|
# SPECIAL 키워드가 포함된 자재 개수 확인
|
||||||
|
result = conn.execute(text("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM materials
|
||||||
|
WHERE classified_category = 'SPECIAL'
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
special_count = result.count if result else 0
|
||||||
|
print(f" - SPECIAL 카테고리로 분류된 자재: {special_count}개")
|
||||||
|
|
||||||
|
# 키워드별 분류 결과 확인
|
||||||
|
keyword_results = conn.execute(text("""
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN UPPER(original_description) LIKE '%SPECIAL%' THEN 'SPECIAL'
|
||||||
|
WHEN UPPER(original_description) LIKE '%스페셜%' THEN '스페셜'
|
||||||
|
WHEN UPPER(original_description) LIKE '%SPEC%' THEN 'SPEC'
|
||||||
|
WHEN UPPER(original_description) LIKE '%SPL%' THEN 'SPL'
|
||||||
|
END as keyword_type,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM materials
|
||||||
|
WHERE classified_category = 'SPECIAL'
|
||||||
|
GROUP BY keyword_type
|
||||||
|
ORDER BY count DESC
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
if keyword_results:
|
||||||
|
print(" - 키워드별 분류 결과:")
|
||||||
|
for row in keyword_results:
|
||||||
|
print(f" * {row.keyword_type}: {row.count}개")
|
||||||
|
|
||||||
|
# 3. 테이블 생성 확인
|
||||||
|
print("\n🏗️ 3단계: 새 테이블 생성 확인...")
|
||||||
|
|
||||||
|
# special_classification_patterns 테이블 확인
|
||||||
|
patterns_count = conn.execute(text("""
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM special_classification_patterns
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
patterns_count = patterns_count.count if patterns_count else 0
|
||||||
|
print(f" - special_classification_patterns 테이블: {patterns_count}개 패턴 등록됨")
|
||||||
|
|
||||||
|
# special_material_details 테이블 확인
|
||||||
|
details_exists = conn.execute(text("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'special_material_details'
|
||||||
|
) as exists
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
if details_exists.exists:
|
||||||
|
print(" - special_material_details 테이블: 생성 완료")
|
||||||
|
else:
|
||||||
|
print(" - ❌ special_material_details 테이블: 생성 실패")
|
||||||
|
|
||||||
|
# 4. 인덱스 생성 확인
|
||||||
|
print("\n🔍 4단계: 인덱스 생성 확인...")
|
||||||
|
|
||||||
|
special_indexes = conn.execute(text("""
|
||||||
|
SELECT indexname
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE indexname LIKE '%special%'
|
||||||
|
ORDER BY indexname
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
if special_indexes:
|
||||||
|
print(" - SPECIAL 관련 인덱스:")
|
||||||
|
for idx in special_indexes:
|
||||||
|
print(f" * {idx.indexname}")
|
||||||
|
else:
|
||||||
|
print(" - ⚠️ SPECIAL 관련 인덱스가 생성되지 않았습니다.")
|
||||||
|
|
||||||
|
# 커밋
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🎉 SPECIAL 카테고리 마이그레이션 완료!")
|
||||||
|
print(f"📊 총 {special_count}개 자재가 SPECIAL 카테고리로 분류되었습니다.")
|
||||||
|
print("🔧 새로운 기능:")
|
||||||
|
print(" - SPECIAL 키워드 자동 감지")
|
||||||
|
print(" - 도면 업로드 관리")
|
||||||
|
print(" - 특수 제작 요구사항 추적")
|
||||||
|
print(" - 승인 프로세스 지원")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 마이그레이션 실행 중 오류 발생: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""메인 실행 함수"""
|
||||||
|
print("SPECIAL 카테고리 마이그레이션 실행")
|
||||||
|
print("TK-MP-Project - 특수 자재 관리 시스템")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
execute_special_migration()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n💥 마이그레이션 실패: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
114
backend/scripts/26_update_bolt_material_grades.py
Normal file
114
backend/scripts/26_update_bolt_material_grades.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
볼트 재질 정보 업데이트 스크립트
|
||||||
|
A320/A194M 패턴 등을 올바르게 인식하도록 기존 볼트들의 material_grade 재분류
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
# 프로젝트 루트 디렉토리를 Python 경로에 추가
|
||||||
|
sys.path.append('/app')
|
||||||
|
|
||||||
|
from app.services.bolt_classifier import classify_bolt_material
|
||||||
|
|
||||||
|
def update_bolt_material_grades():
|
||||||
|
"""기존 볼트들의 material_grade 업데이트"""
|
||||||
|
|
||||||
|
# 데이터베이스 연결
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=os.getenv('DB_HOST', 'postgres'),
|
||||||
|
port=os.getenv('DB_PORT', '5432'),
|
||||||
|
database=os.getenv('DB_NAME', 'tk_mp_bom'),
|
||||||
|
user=os.getenv('DB_USER', 'tkmp_user'),
|
||||||
|
password=os.getenv('DB_PASSWORD', 'tkmp2024!')
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
print("🔧 볼트 재질 정보 업데이트 시작...")
|
||||||
|
|
||||||
|
# 볼트 카테고리 자재들 조회
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, original_description, material_grade, full_material_grade
|
||||||
|
FROM materials
|
||||||
|
WHERE classified_category = 'BOLT'
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
|
||||||
|
bolts = cursor.fetchall()
|
||||||
|
print(f"📊 총 {len(bolts)}개 볼트 발견")
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for bolt in bolts:
|
||||||
|
bolt_id = bolt['id']
|
||||||
|
original_desc = bolt['original_description'] or ''
|
||||||
|
current_material_grade = bolt['material_grade'] or ''
|
||||||
|
current_full_grade = bolt['full_material_grade'] or ''
|
||||||
|
|
||||||
|
# 볼트 재질 재분류
|
||||||
|
material_result = classify_bolt_material(original_desc)
|
||||||
|
|
||||||
|
if material_result and material_result.get('standard') != 'UNKNOWN':
|
||||||
|
new_standard = material_result.get('standard', '')
|
||||||
|
new_grade = material_result.get('grade', '')
|
||||||
|
|
||||||
|
# 새로운 material_grade 구성
|
||||||
|
if new_grade and new_grade != 'UNKNOWN':
|
||||||
|
if new_standard in new_grade:
|
||||||
|
# 이미 standard가 포함된 경우 (예: "ASTM A320/A194M")
|
||||||
|
new_material_grade = new_grade
|
||||||
|
else:
|
||||||
|
# standard + grade 조합 (예: "ASTM A193" + "B7")
|
||||||
|
new_material_grade = f"{new_standard} {new_grade}" if new_grade not in new_standard else new_standard
|
||||||
|
else:
|
||||||
|
new_material_grade = new_standard
|
||||||
|
|
||||||
|
# 기존 값과 다른 경우에만 업데이트
|
||||||
|
if new_material_grade != current_material_grade:
|
||||||
|
print(f"🔄 ID {bolt_id}: '{current_material_grade}' → '{new_material_grade}'")
|
||||||
|
print(f" 원본: {original_desc}")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE materials
|
||||||
|
SET material_grade = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (new_material_grade, bolt_id))
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
# 변경사항 커밋
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"✅ 볼트 재질 정보 업데이트 완료: {updated_count}개 업데이트됨")
|
||||||
|
|
||||||
|
# 업데이트 결과 확인
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT material_grade, COUNT(*) as count
|
||||||
|
FROM materials
|
||||||
|
WHERE classified_category = 'BOLT'
|
||||||
|
GROUP BY material_grade
|
||||||
|
ORDER BY count DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
results = cursor.fetchall()
|
||||||
|
print("\n📈 업데이트 후 볼트 재질 분포:")
|
||||||
|
for result in results:
|
||||||
|
print(f" {result['material_grade']}: {result['count']}개")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 오류 발생: {str(e)}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
if cursor:
|
||||||
|
cursor.close()
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
update_bolt_material_grades()
|
||||||
@@ -129,6 +129,83 @@ CREATE INDEX IF NOT EXISTS idx_support_details_material_id ON support_details(ma
|
|||||||
CREATE INDEX IF NOT EXISTS idx_support_details_file_id ON support_details(file_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);
|
CREATE INDEX IF NOT EXISTS idx_support_details_support_type ON support_details(support_type);
|
||||||
|
|
||||||
|
-- 8. SPECIAL 카테고리 지원 추가
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- SPECIAL 카테고리 관련 인덱스 추가 (성능 최적화)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_special_category
|
||||||
|
ON materials(classified_category)
|
||||||
|
WHERE classified_category = 'SPECIAL';
|
||||||
|
|
||||||
|
-- SPECIAL 키워드 패턴 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS special_classification_patterns (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pattern_type VARCHAR(20) NOT NULL, -- 'KEYWORD', 'REGEX', 'EXACT'
|
||||||
|
pattern_value VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
priority INTEGER DEFAULT 1, -- 우선순위 (낮을수록 높은 우선순위)
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 기본 SPECIAL 키워드 패턴 삽입
|
||||||
|
INSERT INTO special_classification_patterns (pattern_type, pattern_value, description, priority) VALUES
|
||||||
|
('KEYWORD', 'SPECIAL', '영문 SPECIAL 키워드', 1),
|
||||||
|
('KEYWORD', '스페셜', '한글 스페셜 키워드', 1),
|
||||||
|
('KEYWORD', 'SPEC', '영문 SPEC 축약어', 2),
|
||||||
|
('KEYWORD', 'SPL', '영문 SPL 축약어', 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- SPECIAL 자재 상세 정보 테이블 (도면 업로드 관련)
|
||||||
|
CREATE TABLE IF NOT EXISTS special_material_details (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- 도면 정보
|
||||||
|
drawing_number VARCHAR(100), -- 도면 번호
|
||||||
|
drawing_revision VARCHAR(20), -- 도면 리비전
|
||||||
|
drawing_uploaded BOOLEAN DEFAULT FALSE, -- 도면 업로드 여부
|
||||||
|
drawing_file_path TEXT, -- 도면 파일 경로
|
||||||
|
|
||||||
|
-- 특수 요구사항
|
||||||
|
special_requirements TEXT, -- 특수 제작 요구사항
|
||||||
|
manufacturing_notes TEXT, -- 제작 참고사항
|
||||||
|
approval_required BOOLEAN DEFAULT TRUE, -- 승인 필요 여부
|
||||||
|
approved_by VARCHAR(100), -- 승인자
|
||||||
|
approved_at TIMESTAMP, -- 승인 일시
|
||||||
|
|
||||||
|
-- 분류 정보
|
||||||
|
classification_confidence FLOAT DEFAULT 1.0,
|
||||||
|
classification_reason TEXT, -- 분류 근거
|
||||||
|
|
||||||
|
-- 관리 정보
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SPECIAL 관련 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_patterns_type ON special_classification_patterns(pattern_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_patterns_active ON special_classification_patterns(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_material ON special_material_details(material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_drawing_uploaded ON special_material_details(drawing_uploaded);
|
||||||
|
|
||||||
|
-- 기존 자재 중 SPECIAL 키워드가 포함된 자재를 SPECIAL 카테고리로 재분류
|
||||||
|
UPDATE materials
|
||||||
|
SET
|
||||||
|
classified_category = 'SPECIAL',
|
||||||
|
classification_confidence = 1.0,
|
||||||
|
classified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE
|
||||||
|
(
|
||||||
|
UPPER(original_description) LIKE '%SPECIAL%' OR
|
||||||
|
UPPER(original_description) LIKE '%스페셜%' OR
|
||||||
|
UPPER(original_description) LIKE '%SPEC%' OR
|
||||||
|
UPPER(original_description) LIKE '%SPL%'
|
||||||
|
)
|
||||||
|
AND (classified_category IS NULL OR classified_category != 'SPECIAL');
|
||||||
|
|
||||||
-- 7. 기존 데이터 정리 (선택사항)
|
-- 7. 기존 데이터 정리 (선택사항)
|
||||||
-- ================================
|
-- ================================
|
||||||
|
|
||||||
|
|||||||
@@ -1160,6 +1160,50 @@ CREATE INDEX IF NOT EXISTS idx_user_activity_logs_activity_type ON user_activity
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id);
|
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_target ON user_activity_logs(target_type, target_id);
|
||||||
|
|
||||||
|
-- ================================
|
||||||
|
-- SPECIAL 카테고리 지원 테이블
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- SPECIAL 키워드 패턴 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS special_classification_patterns (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pattern_type VARCHAR(20) NOT NULL, -- 'KEYWORD', 'REGEX', 'EXACT'
|
||||||
|
pattern_value VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
priority INTEGER DEFAULT 1, -- 우선순위 (낮을수록 높은 우선순위)
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SPECIAL 자재 상세 정보 테이블 (도면 업로드 관련)
|
||||||
|
CREATE TABLE IF NOT EXISTS special_material_details (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
material_id INTEGER REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
file_id INTEGER REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- 도면 정보
|
||||||
|
drawing_number VARCHAR(100), -- 도면 번호
|
||||||
|
drawing_revision VARCHAR(20), -- 도면 리비전
|
||||||
|
drawing_uploaded BOOLEAN DEFAULT FALSE, -- 도면 업로드 여부
|
||||||
|
drawing_file_path TEXT, -- 도면 파일 경로
|
||||||
|
|
||||||
|
-- 특수 요구사항
|
||||||
|
special_requirements TEXT, -- 특수 제작 요구사항
|
||||||
|
manufacturing_notes TEXT, -- 제작 참고사항
|
||||||
|
approval_required BOOLEAN DEFAULT TRUE, -- 승인 필요 여부
|
||||||
|
approved_by VARCHAR(100), -- 승인자
|
||||||
|
approved_at TIMESTAMP, -- 승인 일시
|
||||||
|
|
||||||
|
-- 분류 정보
|
||||||
|
classification_confidence FLOAT DEFAULT 1.0,
|
||||||
|
classification_reason TEXT, -- 분류 근거
|
||||||
|
|
||||||
|
-- 관리 정보
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
-- ================================
|
-- ================================
|
||||||
-- 추가 성능 최적화 인덱스 (16_performance_indexes.sql)
|
-- 추가 성능 최적화 인덱스 (16_performance_indexes.sql)
|
||||||
-- ================================
|
-- ================================
|
||||||
@@ -1181,6 +1225,25 @@ CREATE INDEX IF NOT EXISTS idx_materials_quantity_desc ON materials(quantity DES
|
|||||||
CREATE INDEX IF NOT EXISTS idx_materials_unverified ON materials(classified_category, classification_confidence) WHERE is_verified = false;
|
CREATE INDEX IF NOT EXISTS idx_materials_unverified ON materials(classified_category, classification_confidence) WHERE is_verified = false;
|
||||||
CREATE INDEX IF NOT EXISTS idx_materials_low_confidence ON materials(file_id, classified_category) WHERE classification_confidence < 0.8;
|
CREATE INDEX IF NOT EXISTS idx_materials_low_confidence ON materials(file_id, classified_category) WHERE classification_confidence < 0.8;
|
||||||
|
|
||||||
|
-- SPECIAL 카테고리 전용 인덱스
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_special_category ON materials(classified_category) WHERE classified_category = 'SPECIAL';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_patterns_type ON special_classification_patterns(pattern_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_patterns_active ON special_classification_patterns(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_material ON special_material_details(material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_special_details_drawing_uploaded ON special_material_details(drawing_uploaded);
|
||||||
|
|
||||||
|
-- ================================
|
||||||
|
-- SPECIAL 카테고리 기본 데이터 삽입
|
||||||
|
-- ================================
|
||||||
|
|
||||||
|
-- 기본 SPECIAL 키워드 패턴 삽입
|
||||||
|
INSERT INTO special_classification_patterns (pattern_type, pattern_value, description, priority) VALUES
|
||||||
|
('KEYWORD', 'SPECIAL', '영문 SPECIAL 키워드', 1),
|
||||||
|
('KEYWORD', '스페셜', '한글 스페셜 키워드', 1),
|
||||||
|
('KEYWORD', 'SPEC', '영문 SPEC 축약어', 2),
|
||||||
|
('KEYWORD', 'SPL', '영문 SPL 축약어', 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- 분류 관련 인덱스 (05_add_classification_columns.sql)
|
-- 분류 관련 인덱스 (05_add_classification_columns.sql)
|
||||||
CREATE INDEX IF NOT EXISTS idx_materials_subcategory ON materials(subcategory);
|
CREATE INDEX IF NOT EXISTS idx_materials_subcategory ON materials(subcategory);
|
||||||
CREATE INDEX IF NOT EXISTS idx_materials_standard ON materials(standard);
|
CREATE INDEX IF NOT EXISTS idx_materials_standard ON materials(standard);
|
||||||
|
|||||||
@@ -111,8 +111,15 @@
|
|||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.materials-header h1 {
|
.materials-header h1 {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -120,10 +127,18 @@
|
|||||||
|
|
||||||
.job-info {
|
.job-info {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.material-count-inline {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.material-count {
|
.material-count {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -132,10 +147,27 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 메인 헤더 */
|
||||||
|
.materials-header {
|
||||||
|
background: white;
|
||||||
|
padding: 8px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 카테고리 필터 */
|
/* 카테고리 필터 */
|
||||||
.category-filters {
|
.category-filters {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 16px 24px;
|
padding: 12px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -262,69 +294,598 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 1500px;
|
min-width: 1500px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
max-height: calc(100vh - 200px); /* 최대 높이만 제한 */
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailed-grid-header {
|
.detailed-grid-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px;
|
/* 기본 그리드는 사용하지 않음 - 각 카테고리별 전용 클래스 사용 */
|
||||||
padding: 12px 24px;
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 2px solid #e5e7eb;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header > div {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header > div {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header > div:last-child,
|
||||||
|
.detailed-grid-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PIPE 전용 헤더 - 9개 컬럼 */
|
||||||
|
.detailed-grid-header.pipe-header {
|
||||||
|
grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px !important;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.pipe-header > div,
|
||||||
|
.detailed-grid-header.pipe-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.pipe-header > div:last-child,
|
||||||
|
.detailed-grid-header.pipe-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PIPE 전용 행 - 9개 컬럼 */
|
||||||
|
.detailed-material-row.pipe-row {
|
||||||
|
grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px !important;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.pipe-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.pipe-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SPECIAL 전용 헤더 - 10개 컬럼 */
|
||||||
|
.detailed-grid-header.special-header {
|
||||||
|
grid-template-columns: 60px 90px 150px 80px 100px 200px 120px 120px 200px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.special-header > div,
|
||||||
|
.detailed-grid-header.special-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.special-header > div:last-child,
|
||||||
|
.detailed-grid-header.special-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SPECIAL 전용 행 - 10개 컬럼 */
|
||||||
|
.detailed-material-row.special-row {
|
||||||
|
grid-template-columns: 60px 90px 150px 80px 100px 200px 120px 120px 200px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOLT 전용 헤더 - 9개 컬럼 */
|
||||||
|
.detailed-grid-header.bolt-header {
|
||||||
|
grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOLT 전용 행 - 9개 컬럼 */
|
||||||
|
.detailed-material-row.bolt-row {
|
||||||
|
grid-template-columns: 60px 90px 130px 80px 100px 250px 120px 200px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.special-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOLT 헤더 테두리 */
|
||||||
|
.detailed-grid-header.bolt-header > div,
|
||||||
|
.detailed-grid-header.bolt-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.bolt-header > div:last-child,
|
||||||
|
.detailed-grid-header.bolt-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOLT 행 테두리 */
|
||||||
|
.detailed-material-row.bolt-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.bolt-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOLT 타입 배지 */
|
||||||
|
.type-badge.bolt {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid #6d28d9;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* U-BOLT 전용 헤더 - 8개 컬럼 */
|
||||||
|
.detailed-grid-header.ubolt-header {
|
||||||
|
grid-template-columns: 60px 90px 130px 80px 200px 120px 200px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* U-BOLT 전용 행 - 8개 컬럼 */
|
||||||
|
.detailed-material-row.ubolt-row {
|
||||||
|
grid-template-columns: 60px 90px 130px 80px 200px 120px 200px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* U-BOLT 헤더 테두리 */
|
||||||
|
.detailed-grid-header.ubolt-header > div,
|
||||||
|
.detailed-grid-header.ubolt-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
.detailed-grid-header.ubolt-header > div:last-child,
|
||||||
|
.detailed-grid-header.ubolt-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* U-BOLT 행 테두리 */
|
||||||
|
.detailed-material-row.ubolt-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.detailed-material-row.ubolt-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* U-BOLT 타입 배지 */
|
||||||
|
.type-badge.ubolt {
|
||||||
|
background: #059669;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid #047857;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URETHANE 타입 배지 */
|
||||||
|
.type-badge.urethane {
|
||||||
|
background: #ea580c;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid #c2410c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.special-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
/* 플랜지 전용 헤더 - 10개 컬럼 */
|
||||||
.detailed-grid-header.flange-header {
|
.detailed-grid-header.flange-header {
|
||||||
grid-template-columns: 40px 80px 150px 80px 100px 80px 350px 100px 150px 100px;
|
grid-template-columns: 60px 100px 150px 80px 100px 80px 350px 100px 150px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.flange-header > div,
|
||||||
|
.detailed-grid-header.flange-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.flange-header > div:last-child,
|
||||||
|
.detailed-grid-header.flange-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 플랜지 전용 행 - 10개 컬럼 */
|
/* 플랜지 전용 행 - 10개 컬럼 */
|
||||||
.detailed-material-row.flange-row {
|
.detailed-material-row.flange-row {
|
||||||
grid-template-columns: 40px 80px 150px 80px 100px 80px 350px 100px 150px 100px;
|
grid-template-columns: 60px 100px 150px 80px 100px 80px 350px 100px 150px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.flange-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.flange-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 피팅 전용 헤더 - 10개 컬럼 */
|
/* 피팅 전용 헤더 - 10개 컬럼 */
|
||||||
.detailed-grid-header.fitting-header {
|
.detailed-grid-header.fitting-header {
|
||||||
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.fitting-header > div,
|
||||||
|
.detailed-grid-header.fitting-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.fitting-header > div:last-child,
|
||||||
|
.detailed-grid-header.fitting-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 피팅 전용 행 - 10개 컬럼 */
|
/* 피팅 전용 행 - 10개 컬럼 */
|
||||||
.detailed-material-row.fitting-row {
|
.detailed-material-row.fitting-row {
|
||||||
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
grid-template-columns: 40px 80px 280px 80px 80px 140px 350px 100px 150px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.fitting-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.fitting-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
/* 밸브 전용 헤더 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||||
.detailed-grid-header.valve-header {
|
.detailed-grid-header.valve-header {
|
||||||
grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px;
|
grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.valve-header > div,
|
||||||
|
.detailed-grid-header.valve-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.valve-header > div:last-child,
|
||||||
|
.detailed-grid-header.valve-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
/* 밸브 전용 행 - 9개 컬럼 (스케줄 제거, 타입 너비 증가) */
|
||||||
.detailed-material-row.valve-row {
|
.detailed-material-row.valve-row {
|
||||||
grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px;
|
grid-template-columns: 40px 220px 100px 80px 80px 350px 100px 150px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.valve-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.valve-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
/* 가스켓 전용 헤더 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
||||||
.detailed-grid-header.gasket-header {
|
.detailed-grid-header.gasket-header {
|
||||||
grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px;
|
grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.gasket-header > div,
|
||||||
|
.detailed-grid-header.gasket-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.gasket-header > div:last-child,
|
||||||
|
.detailed-grid-header.gasket-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
/* 가스켓 전용 행 - 11개 컬럼 (타입 좁게, 상세내역 넓게, 두께 추가) */
|
||||||
.detailed-material-row.gasket-row {
|
.detailed-material-row.gasket-row {
|
||||||
grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px;
|
grid-template-columns: 40px 80px 120px 80px 80px 100px 400px 60px 80px 150px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.gasket-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.gasket-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 필터링 가능한 헤더 스타일 */
|
||||||
|
.filterable-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterable-header:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterable-header:hover .header-controls {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn, .filter-btn {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #64748b;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn:hover, .filter-btn:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btn.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: #10b981;
|
||||||
|
border-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 필터 드롭다운 */
|
||||||
|
.filter-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 2px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: dropdownFadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-search {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
background: #fafbfc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-search input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-search input:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-search input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filter-btn {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filter-btn:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option-header {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option-more {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 액션 바 스타일 개선 */
|
||||||
|
.filter-info {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filters-btn {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filters-btn:hover {
|
||||||
|
background: #d97706;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UNKNOWN 전용 헤더 - 5개 컬럼 */
|
/* UNKNOWN 전용 헤더 - 5개 컬럼 */
|
||||||
.detailed-grid-header.unknown-header {
|
.detailed-grid-header.unknown-header {
|
||||||
grid-template-columns: 40px 100px 1fr 150px 100px;
|
grid-template-columns: 40px 100px 1fr 150px 100px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.unknown-header > div,
|
||||||
|
.detailed-grid-header.unknown-header .filterable-header {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-grid-header.unknown-header > div:last-child,
|
||||||
|
.detailed-grid-header.unknown-header .filterable-header:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UNKNOWN 전용 행 - 5개 컬럼 */
|
/* UNKNOWN 전용 행 - 5개 컬럼 */
|
||||||
.detailed-material-row.unknown-row {
|
.detailed-material-row.unknown-row {
|
||||||
grid-template-columns: 40px 100px 1fr 150px 100px;
|
grid-template-columns: 40px 100px 1fr 150px 100px;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.unknown-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row.unknown-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UNKNOWN 설명 셀 스타일 */
|
/* UNKNOWN 설명 셀 스타일 */
|
||||||
@@ -345,14 +906,25 @@
|
|||||||
|
|
||||||
.detailed-material-row {
|
.detailed-material-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 80px 250px 80px 80px 450px 120px 150px 100px;
|
/* 기본 그리드는 사용하지 않음 - 각 카테고리별 전용 클래스 사용 */
|
||||||
padding: 12px 24px;
|
padding: 12px 0;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
margin: 0 24px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailed-material-row .material-cell {
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-material-row .material-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.detailed-material-row:hover {
|
.detailed-material-row:hover {
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
}
|
}
|
||||||
@@ -364,28 +936,62 @@
|
|||||||
.material-cell {
|
.material-cell {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
text-overflow: initial !important;
|
text-overflow: initial !important;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
padding-right: 12px;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-cell > * {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 사용자 요구사항 입력 필드 */
|
||||||
|
.user-req-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-req-input:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-cell input[type="checkbox"] {
|
.material-cell input[type="checkbox"] {
|
||||||
width: 16px;
|
width: 14px;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 타입 배지 */
|
/* 타입 배지 */
|
||||||
.type-badge {
|
.type-badge {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-badge.pipe {
|
.type-badge.pipe {
|
||||||
@@ -418,6 +1024,14 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.type-badge.special {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.type-badge.unknown {
|
.type-badge.unknown {
|
||||||
background: #6b7280;
|
background: #6b7280;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -428,11 +1042,6 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-badge.unknown {
|
|
||||||
background: #9ca3af;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 텍스트 스타일 */
|
/* 텍스트 스타일 */
|
||||||
.subtype-text,
|
.subtype-text,
|
||||||
.size-text,
|
.size-text,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -150,7 +150,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
// 구매 수량 계산
|
// 구매 수량 계산
|
||||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||||
|
|
||||||
// 품목명 생성 (간단하게)
|
// 품목명 생성 (카테고리별 상세 처리)
|
||||||
let itemName = '';
|
let itemName = '';
|
||||||
if (category === 'PIPE') {
|
if (category === 'PIPE') {
|
||||||
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
|
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
|
||||||
@@ -161,34 +161,237 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
} else if (category === 'VALVE') {
|
} else if (category === 'VALVE') {
|
||||||
itemName = 'VALVE';
|
itemName = 'VALVE';
|
||||||
} else if (category === 'GASKET') {
|
} else if (category === 'GASKET') {
|
||||||
itemName = 'GASKET';
|
// 가스켓 상세 타입 추출
|
||||||
|
if (material.gasket_details) {
|
||||||
|
const gasketType = material.gasket_details.gasket_type || '';
|
||||||
|
const gasketSubtype = material.gasket_details.gasket_subtype || '';
|
||||||
|
|
||||||
|
if (gasketSubtype && gasketSubtype !== gasketType) {
|
||||||
|
itemName = gasketSubtype;
|
||||||
|
} else if (gasketType) {
|
||||||
|
itemName = gasketType;
|
||||||
|
} else {
|
||||||
|
itemName = 'GASKET';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// gasket_details가 없으면 description에서 추출
|
||||||
|
if (cleanDescription.includes('SWG') || cleanDescription.includes('SPIRAL')) {
|
||||||
|
itemName = 'SWG';
|
||||||
|
} else if (cleanDescription.includes('RTJ') || cleanDescription.includes('RING')) {
|
||||||
|
itemName = 'RTJ';
|
||||||
|
} else if (cleanDescription.includes('FF') || cleanDescription.includes('FULL FACE')) {
|
||||||
|
itemName = 'FF';
|
||||||
|
} else if (cleanDescription.includes('RF') || cleanDescription.includes('RAISED')) {
|
||||||
|
itemName = 'RF';
|
||||||
|
} else {
|
||||||
|
itemName = 'GASKET';
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (category === 'BOLT') {
|
} else if (category === 'BOLT') {
|
||||||
itemName = 'BOLT';
|
itemName = 'BOLT';
|
||||||
} else {
|
} else {
|
||||||
itemName = category || 'UNKNOWN';
|
itemName = category || 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 압력 등급 추출
|
// 압력 등급 추출 (카테고리별 처리)
|
||||||
let pressure = '-';
|
let pressure = '-';
|
||||||
const pressureMatch = cleanDescription.match(/(\d+)LB/i);
|
|
||||||
if (pressureMatch) {
|
if (category === 'GASKET') {
|
||||||
pressure = `${pressureMatch[1]}LB`;
|
// 가스켓의 경우 gasket_details에서 압력등급 추출
|
||||||
|
if (material.gasket_details && material.gasket_details.pressure_rating) {
|
||||||
|
pressure = material.gasket_details.pressure_rating;
|
||||||
|
} else {
|
||||||
|
// gasket_details가 없으면 description에서 추출
|
||||||
|
const pressureMatch = cleanDescription.match(/(\d+)LB/i);
|
||||||
|
if (pressureMatch) {
|
||||||
|
pressure = `${pressureMatch[1]}LB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 다른 카테고리는 기존 방식
|
||||||
|
const pressureMatch = cleanDescription.match(/(\d+)LB/i);
|
||||||
|
if (pressureMatch) {
|
||||||
|
pressure = `${pressureMatch[1]}LB`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 스케줄 추출
|
// 스케줄/길이 추출 (카테고리별 처리)
|
||||||
let schedule = '-';
|
let schedule = '-';
|
||||||
const scheduleMatch = cleanDescription.match(/SCH\s*(\d+[A-Z]*(?:\s*[xX×]\s*SCH\s*\d+[A-Z]*)?)/i);
|
|
||||||
if (scheduleMatch) {
|
if (category === 'BOLT') {
|
||||||
schedule = scheduleMatch[0];
|
// 볼트의 경우 길이 정보 추출
|
||||||
|
const lengthPatterns = [
|
||||||
|
/(\d+(?:\.\d+)?)\s*LG/i, // 145.0000 LG, 75 LG
|
||||||
|
/(\d+(?:\.\d+)?)\s*mm/i, // 50mm
|
||||||
|
/(\d+(?:\.\d+)?)\s*MM/i, // 50MM
|
||||||
|
/,\s*(\d+(?:\.\d+)?)\s*LG/i, // ", 145.0000 LG" 형태
|
||||||
|
/LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of lengthPatterns) {
|
||||||
|
const match = cleanDescription.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
let lengthValue = match[1];
|
||||||
|
// 소수점 제거 (145.0000 → 145)
|
||||||
|
if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) {
|
||||||
|
lengthValue = lengthValue.split('.')[0];
|
||||||
|
} else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) {
|
||||||
|
lengthValue = lengthValue.split('.')[0];
|
||||||
|
}
|
||||||
|
schedule = `${lengthValue}mm`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 다른 카테고리는 스케줄 추출
|
||||||
|
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 = '-';
|
let grade = '-';
|
||||||
const gradeMatch = cleanDescription.match(/(ASTM\s+[A-Z0-9\s]+(?:TP\d+|GR\s*[A-Z0-9]+|WP\d+)?)/i);
|
|
||||||
if (gradeMatch) {
|
if (category === 'GASKET') {
|
||||||
grade = gradeMatch[1].trim();
|
// 가스켓의 경우 재질 필드에는 H/F/I/O 타입 정보 표시
|
||||||
|
const hfioMatch = cleanDescription.match(/H\/F\/I\/O/i);
|
||||||
|
if (hfioMatch) {
|
||||||
|
grade = 'H/F/I/O';
|
||||||
|
} else {
|
||||||
|
// 다른 가스켓 타입들
|
||||||
|
if (cleanDescription.includes('SWG') || cleanDescription.includes('SPIRAL')) {
|
||||||
|
grade = 'SWG';
|
||||||
|
} else if (cleanDescription.includes('RTJ') || cleanDescription.includes('RING')) {
|
||||||
|
grade = 'RTJ';
|
||||||
|
} else if (cleanDescription.includes('FF') || cleanDescription.includes('FULL FACE')) {
|
||||||
|
grade = 'FF';
|
||||||
|
} else if (cleanDescription.includes('RF') || cleanDescription.includes('RAISED')) {
|
||||||
|
grade = 'RF';
|
||||||
|
} else {
|
||||||
|
grade = 'GASKET';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (category === 'BOLT') {
|
||||||
|
// 볼트 전용 재질 추출 (복합 ASTM 패턴 지원)
|
||||||
|
const boltGradePatterns = [
|
||||||
|
// 복합 ASTM 패턴 (A193/A194 GR B7/2H)
|
||||||
|
/(ASTM\s+A\d+\/A\d+\s+GR\s+[A-Z0-9\/]+)/i,
|
||||||
|
// 단일 ASTM 패턴 (ASTM A193 GR B7)
|
||||||
|
/(ASTM\s+A\d+\s+GR\s+[A-Z0-9]+)/i,
|
||||||
|
// ASTM 번호만 (ASTM A193/A194)
|
||||||
|
/(ASTM\s+A\d+(?:\/A\d+)?)/i,
|
||||||
|
// 일반 ASTM 패턴
|
||||||
|
/(ASTM\s+[A-Z0-9\s\/]+(?:TP\d+|GR\s*[A-Z0-9\/]+|WP\d+)?)/i
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of boltGradePatterns) {
|
||||||
|
const match = cleanDescription.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
grade = match[1].trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASTM이 없는 경우 기본 재질 패턴 시도
|
||||||
|
if (grade === '-') {
|
||||||
|
const basicGradeMatch = cleanDescription.match(/(A\d+(?:\/A\d+)?\s+(?:GR\s+)?[A-Z0-9\/]+)/i);
|
||||||
|
if (basicGradeMatch) {
|
||||||
|
grade = basicGradeMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 백엔드에서 제공하는 재질 정보 우선 사용
|
||||||
|
if (material.full_material_grade && material.full_material_grade !== '-') {
|
||||||
|
grade = material.full_material_grade;
|
||||||
|
} else if (material.material_grade && material.material_grade !== '-') {
|
||||||
|
grade = material.material_grade;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 기존 ASTM 패턴 (다른 카테고리용)
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리별 상세 정보 추출
|
||||||
|
let detailInfo = '';
|
||||||
|
let gasketMaterial = '';
|
||||||
|
let gasketThickness = '';
|
||||||
|
|
||||||
|
if (category === 'BOLT') {
|
||||||
|
// 볼트의 경우 표면처리 정보 추출
|
||||||
|
const surfaceTreatments = [];
|
||||||
|
|
||||||
|
// 원본 설명에서 표면처리 패턴 확인
|
||||||
|
const surfacePatterns = {
|
||||||
|
'ELEC.GALV': 'ELEC.GALV',
|
||||||
|
'ELEC GALV': 'ELEC.GALV',
|
||||||
|
'GALVANIZED': 'GALVANIZED',
|
||||||
|
'GALV': 'GALV',
|
||||||
|
'HOT DIP GALV': 'HDG',
|
||||||
|
'HDG': 'HDG',
|
||||||
|
'ZINC PLATED': 'ZINC PLATED',
|
||||||
|
'ZINC': 'ZINC',
|
||||||
|
'PLAIN': 'PLAIN'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [pattern, treatment] of Object.entries(surfacePatterns)) {
|
||||||
|
if (cleanDescription.includes(pattern)) {
|
||||||
|
surfaceTreatments.push(treatment);
|
||||||
|
break; // 첫 번째 매치만 사용
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detailInfo = surfaceTreatments.join(', ') || '-';
|
||||||
|
} else if (category === 'GASKET') {
|
||||||
|
// 실제 재질 구성 정보 (SS304/GRAPHITE/SS304/SS304)
|
||||||
|
if (material.gasket_details) {
|
||||||
|
const materialType = material.gasket_details.material_type || '';
|
||||||
|
const fillerMaterial = material.gasket_details.filler_material || '';
|
||||||
|
|
||||||
|
if (materialType && fillerMaterial) {
|
||||||
|
// DB에서 가져온 정보로 구성
|
||||||
|
gasketMaterial = `${materialType}/${fillerMaterial}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gasket_details가 없거나 불완전하면 description에서 추출
|
||||||
|
if (!gasketMaterial) {
|
||||||
|
// SS304/GRAPHITE/SS304/SS304 패턴 추출
|
||||||
|
const fullMaterialMatch = cleanDescription.match(/SS304\/GRAPHITE\/SS304\/SS304/i);
|
||||||
|
if (fullMaterialMatch) {
|
||||||
|
gasketMaterial = 'SS304/GRAPHITE/SS304/SS304';
|
||||||
|
} else {
|
||||||
|
// 간단한 패턴 (SS304/GRAPHITE)
|
||||||
|
const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i);
|
||||||
|
if (simpleMaterialMatch) {
|
||||||
|
gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 두께 정보 별도 추출
|
||||||
|
if (material.gasket_details && material.gasket_details.thickness) {
|
||||||
|
gasketThickness = `${material.gasket_details.thickness}mm`;
|
||||||
|
} else {
|
||||||
|
// description에서 두께 추출 (4.5mm 패턴)
|
||||||
|
const thicknessMatch = cleanDescription.match(/(\d+\.?\d*)\s*mm/i);
|
||||||
|
if (thicknessMatch) {
|
||||||
|
gasketThickness = `${thicknessMatch[1]}mm`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 상세 정보 (Fire Safe 등)
|
||||||
|
const otherDetails = [];
|
||||||
|
if (material.gasket_details && material.gasket_details.fire_safe) {
|
||||||
|
otherDetails.push('Fire Safe');
|
||||||
|
}
|
||||||
|
|
||||||
|
detailInfo = otherDetails.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
// 새로운 엑셀 양식에 맞춘 데이터 구조
|
// 새로운 엑셀 양식에 맞춘 데이터 구조
|
||||||
const base = {
|
const base = {
|
||||||
'TAGNO': '', // 비워둠
|
'TAGNO': '', // 비워둠
|
||||||
@@ -197,18 +400,46 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
|||||||
'통화구분': 'KRW', // 기본값
|
'통화구분': 'KRW', // 기본값
|
||||||
'단가': 1, // 일괄 1로 설정
|
'단가': 1, // 일괄 1로 설정
|
||||||
'크기': material.size_spec || '-',
|
'크기': material.size_spec || '-',
|
||||||
'압력등급': pressure,
|
'압력등급': pressure
|
||||||
'스케줄': schedule,
|
|
||||||
'재질': grade,
|
|
||||||
'사용자요구': '',
|
|
||||||
'관리항목1': '', // 빈칸
|
|
||||||
'관리항목7': '', // 빈칸
|
|
||||||
'관리항목8': '', // 빈칸
|
|
||||||
'관리항목9': '', // 빈칸
|
|
||||||
'관리항목10': '', // 빈칸
|
|
||||||
'납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 카테고리별 전용 컬럼 구성
|
||||||
|
if (category === 'GASKET') {
|
||||||
|
// 가스켓 전용 컬럼 순서
|
||||||
|
base['타입/구조'] = grade; // H/F/I/O, SWG 등 (스케줄 대신)
|
||||||
|
base['재질'] = gasketMaterial || '-'; // SS304/GRAPHITE/SS304/SS304
|
||||||
|
base['두께'] = gasketThickness || '-'; // 4.5mm
|
||||||
|
base['사용자요구'] = material.user_requirement || '';
|
||||||
|
base['관리항목8'] = ''; // 빈칸
|
||||||
|
base['관리항목9'] = ''; // 빈칸
|
||||||
|
base['관리항목10'] = ''; // 빈칸
|
||||||
|
base['납기일(YYYY-MM-DD)'] = new Date().toISOString().split('T')[0]; // 가장 마지막
|
||||||
|
} else if (category === 'BOLT') {
|
||||||
|
// 볼트 전용 컬럼 순서 (스케줄 → 길이)
|
||||||
|
base['길이'] = schedule; // 볼트는 길이 정보
|
||||||
|
base['재질'] = grade;
|
||||||
|
base['추가요구'] = detailInfo || '-'; // 상세내역 → 추가요구로 변경
|
||||||
|
base['사용자요구'] = material.user_requirement || '';
|
||||||
|
base['관리항목1'] = ''; // 빈칸
|
||||||
|
base['관리항목7'] = ''; // 빈칸
|
||||||
|
base['관리항목8'] = ''; // 빈칸
|
||||||
|
base['관리항목9'] = ''; // 빈칸
|
||||||
|
base['관리항목10'] = ''; // 빈칸
|
||||||
|
base['납기일(YYYY-MM-DD)'] = new Date().toISOString().split('T')[0]; // 가장 마지막
|
||||||
|
} else {
|
||||||
|
// 다른 카테고리는 기존 방식
|
||||||
|
base['스케줄'] = schedule;
|
||||||
|
base['재질'] = grade;
|
||||||
|
base['상세내역'] = detailInfo || '-';
|
||||||
|
base['사용자요구'] = material.user_requirement || '';
|
||||||
|
base['관리항목1'] = ''; // 빈칸
|
||||||
|
base['관리항목7'] = ''; // 빈칸
|
||||||
|
base['관리항목8'] = ''; // 빈칸
|
||||||
|
base['관리항목9'] = ''; // 빈칸
|
||||||
|
base['관리항목10'] = ''; // 빈칸
|
||||||
|
base['납기일(YYYY-MM-DD)'] = new Date().toISOString().split('T')[0]; // 가장 마지막
|
||||||
|
}
|
||||||
|
|
||||||
// 비교 모드인 경우 추가 정보
|
// 비교 모드인 경우 추가 정보
|
||||||
if (includeComparison) {
|
if (includeComparison) {
|
||||||
if (material.previous_quantity !== undefined) {
|
if (material.previous_quantity !== undefined) {
|
||||||
@@ -245,46 +476,96 @@ export const exportMaterialsToExcel = (materials, filename, additionalInfo = {})
|
|||||||
// 새 워크북 생성
|
// 새 워크북 생성
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
// 요약 시트 생성
|
|
||||||
const summaryData = [
|
|
||||||
['파일 정보', ''],
|
|
||||||
['파일명', additionalInfo.filename || ''],
|
|
||||||
['Job No', additionalInfo.jobNo || ''],
|
|
||||||
['리비전', additionalInfo.revision || ''],
|
|
||||||
['업로드일', additionalInfo.uploadDate || new Date().toLocaleDateString()],
|
|
||||||
['총 자재 수', consolidatedMaterials.length],
|
|
||||||
['', ''],
|
|
||||||
['카테고리별 요약', ''],
|
|
||||||
['카테고리', '수량']
|
|
||||||
];
|
|
||||||
|
|
||||||
// 카테고리별 요약 추가 (합쳐진 자재 기준)
|
|
||||||
Object.entries(categoryGroups).forEach(([category, items]) => {
|
|
||||||
const consolidatedItems = consolidateMaterials(items);
|
|
||||||
summaryData.push([category, consolidatedItems.length]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const summarySheet = XLSX.utils.aoa_to_sheet(summaryData);
|
|
||||||
XLSX.utils.book_append_sheet(workbook, summarySheet, '요약');
|
|
||||||
|
|
||||||
// 전체 자재 시트 (합쳐진 자재)
|
|
||||||
const allMaterialsFormatted = consolidatedMaterials.map(material => formatMaterialForExcel(material));
|
|
||||||
const allSheet = XLSX.utils.json_to_sheet(allMaterialsFormatted);
|
|
||||||
XLSX.utils.book_append_sheet(workbook, allSheet, '전체 자재');
|
|
||||||
|
|
||||||
// 카테고리별 시트 생성 (합쳐진 자재)
|
// 카테고리별 시트 생성 (합쳐진 자재)
|
||||||
Object.entries(categoryGroups).forEach(([category, items]) => {
|
Object.entries(categoryGroups).forEach(([category, items]) => {
|
||||||
const consolidatedItems = consolidateMaterials(items);
|
const consolidatedItems = consolidateMaterials(items);
|
||||||
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material));
|
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material));
|
||||||
const categorySheet = XLSX.utils.json_to_sheet(formattedItems);
|
|
||||||
|
|
||||||
// 시트명에서 특수문자 제거 (엑셀 시트명 규칙)
|
if (formattedItems.length > 0) {
|
||||||
const sheetName = category.replace(/[\\\/\*\?\[\]]/g, '_').substring(0, 31);
|
const categorySheet = XLSX.utils.json_to_sheet(formattedItems);
|
||||||
XLSX.utils.book_append_sheet(workbook, categorySheet, sheetName);
|
|
||||||
|
// 헤더 스타일링 (연하늘색 배경)
|
||||||
|
const range = XLSX.utils.decode_range(categorySheet['!ref']);
|
||||||
|
|
||||||
|
// 헤더 행에 스타일 적용 (첫 번째 행)
|
||||||
|
for (let col = range.s.c; col <= range.e.c; col++) {
|
||||||
|
const cellRef = XLSX.utils.encode_cell({ r: 0, c: col });
|
||||||
|
|
||||||
|
if (categorySheet[cellRef]) {
|
||||||
|
// 기존 셀 값 유지하면서 스타일만 추가
|
||||||
|
const cellValue = categorySheet[cellRef].v;
|
||||||
|
const cellType = categorySheet[cellRef].t;
|
||||||
|
|
||||||
|
categorySheet[cellRef] = {
|
||||||
|
v: cellValue,
|
||||||
|
t: cellType || 's',
|
||||||
|
s: {
|
||||||
|
fill: {
|
||||||
|
patternType: "solid",
|
||||||
|
fgColor: { rgb: "B3D9FF" }
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
bold: true,
|
||||||
|
color: { rgb: "000000" },
|
||||||
|
sz: 12,
|
||||||
|
name: "맑은 고딕"
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
horizontal: "center",
|
||||||
|
vertical: "center"
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
top: { style: "thin", color: { rgb: "666666" } },
|
||||||
|
bottom: { style: "thin", color: { rgb: "666666" } },
|
||||||
|
left: { style: "thin", color: { rgb: "666666" } },
|
||||||
|
right: { style: "thin", color: { rgb: "666666" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 너비 자동 조정
|
||||||
|
const colWidths = [];
|
||||||
|
if (formattedItems.length > 0) {
|
||||||
|
const headers = Object.keys(formattedItems[0]);
|
||||||
|
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
let maxWidth = header.length; // 헤더 길이
|
||||||
|
|
||||||
|
// 각 행의 데이터 길이 확인
|
||||||
|
formattedItems.forEach(item => {
|
||||||
|
const cellValue = String(item[header] || '');
|
||||||
|
maxWidth = Math.max(maxWidth, cellValue.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 최소 10, 최대 50으로 제한
|
||||||
|
colWidths[index] = { wch: Math.min(Math.max(maxWidth + 2, 10), 50) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
categorySheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
// 시트명에서 특수문자 제거 (엑셀 시트명 규칙)
|
||||||
|
const sheetName = category.replace(/[\\\/\*\?\[\]]/g, '_').substring(0, 31);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, categorySheet, sheetName);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 파일 저장
|
// 워크북 속성 설정 (스타일 지원)
|
||||||
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
workbook.Props = {
|
||||||
|
Title: "자재 목록",
|
||||||
|
Subject: "TK-MP-Project 자재 관리",
|
||||||
|
Author: "TK-MP System",
|
||||||
|
CreatedDate: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 저장 (스타일 포함)
|
||||||
|
const excelBuffer = XLSX.write(workbook, {
|
||||||
|
bookType: 'xlsx',
|
||||||
|
type: 'array',
|
||||||
|
cellStyles: true // 스타일 활성화
|
||||||
|
});
|
||||||
const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
|
||||||
const finalFilename = `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`;
|
const finalFilename = `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||||
|
|||||||
Reference in New Issue
Block a user