Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 🎨 UI/UX 개선: 데본씽크 스타일 모던 디자인 적용 - 📁 컴포넌트 구조 개선: 폴더별 체계적 관리 (common/, bom/, materials/) - 🔧 BOM 관리 페이지 리팩토링: NewMaterialsPage → BOMManagementPage + 카테고리별 컴포넌트 분리 - 💾 구매신청 기능 개선: 선택된 자재 비활성화, 제목 편집, 엑셀 다운로드 - 📊 자재 표시 개선: 타입/서브타입 컬럼 정리, 상세 정보 복원 - 🐛 CSS 빌드 오류 수정: NewMaterialsPage.css 문법 오류 해결 - 📚 문서화: PAGES_GUIDE.md 추가, README에 Docker 캐시 문제 해결 가이드 추가 - 🔄 API 개선: 구매신청 자재 조회, 제목 수정 엔드포인트 추가
3909 lines
179 KiB
Python
3909 lines
179 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Body
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import text
|
||
from typing import List, Optional, Dict
|
||
from pydantic import BaseModel
|
||
import os
|
||
import shutil
|
||
from datetime import datetime
|
||
import uuid
|
||
import pandas as pd
|
||
import re
|
||
from pathlib import Path
|
||
import json
|
||
|
||
from ..database import get_db
|
||
from ..auth.middleware import get_current_user
|
||
from ..services.activity_logger import ActivityLogger, log_activity_from_request
|
||
from ..utils.logger import get_logger
|
||
from app.services.material_classifier import classify_material
|
||
|
||
# 로거 설정
|
||
logger = get_logger(__name__)
|
||
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
|
||
from app.services.bolt_classifier import classify_bolt
|
||
from app.services.flange_classifier import classify_flange
|
||
from app.services.fitting_classifier import classify_fitting
|
||
from app.services.gasket_classifier import classify_gasket
|
||
from app.services.instrument_classifier import classify_instrument
|
||
from app.services.pipe_classifier import classify_pipe
|
||
from app.services.valve_classifier import classify_valve
|
||
from app.services.revision_comparator import get_revision_comparison
|
||
|
||
router = APIRouter()
|
||
|
||
class ExcelSaveRequest(BaseModel):
|
||
file_id: int
|
||
category: str
|
||
materials: List[Dict]
|
||
filename: str
|
||
user_id: Optional[int] = None
|
||
|
||
def extract_enhanced_material_grade(description: str, original_grade: str, category: str) -> str:
|
||
"""
|
||
원본 설명에서 개선된 재질 정보를 추출
|
||
|
||
Args:
|
||
description: 원본 설명
|
||
original_grade: 기존 재질 정보
|
||
category: 자재 카테고리 (PIPE, FITTING, FLANGE 등)
|
||
|
||
Returns:
|
||
개선된 재질 정보
|
||
"""
|
||
if not description:
|
||
return original_grade or '-'
|
||
|
||
desc_upper = description.upper()
|
||
|
||
# PIPE 재질 패턴
|
||
if category == 'PIPE':
|
||
pipe_patterns = [
|
||
(r'A312\s*(TP\d+[A-Z]*)', lambda m: f'A312 {m.group(1)}'),
|
||
(r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B, A106 GR B 등 전체 보존
|
||
(r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B
|
||
(r'A333\s*(GR\.?\s*[A-Z0-9]+)', lambda m: f'A333 {m.group(1)}'), # A333 GR.6 등 전체 보존
|
||
(r'A333\s*([A-Z0-9]+)', lambda m: f'A333 GR.{m.group(1)}'), # A333 6 → A333 GR.6
|
||
(r'A53\s*(GR\.?\s*[A-Z]+)', lambda m: f'A53 {m.group(1)}'), # A53 GR.B 등 전체 보존
|
||
(r'A53\s*([A-Z]+)', lambda m: f'A53 GR.{m.group(1)}'), # A53 B → A53 GR.B
|
||
(r'A335\s*(P\d+[A-Z]*)', lambda m: f'A335 {m.group(1)}'),
|
||
(r'STPG\s*(\d+)', lambda m: f'STPG {m.group(1)}'),
|
||
(r'STS\s*(\d+[A-Z]*)', lambda m: f'STS {m.group(1)}')
|
||
]
|
||
|
||
for pattern, formatter in pipe_patterns:
|
||
match = re.search(pattern, desc_upper)
|
||
if match:
|
||
return formatter(match)
|
||
|
||
# FITTING 재질 패턴
|
||
elif category == 'FITTING':
|
||
fitting_patterns = [
|
||
(r'A403\s*(WP\d+[A-Z]*)', lambda m: f'A403 {m.group(1)}'),
|
||
(r'A234\s*(WP[A-Z]+)', lambda m: f'A234 {m.group(1)}'),
|
||
(r'A420\s*(WPL\d+)', lambda m: f'A420 {m.group(1)}'),
|
||
(r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존
|
||
(r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N
|
||
(r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우
|
||
(r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B 전체 보존
|
||
(r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B
|
||
(r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'),
|
||
(r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}')
|
||
]
|
||
|
||
for pattern, formatter in fitting_patterns:
|
||
match = re.search(pattern, desc_upper)
|
||
if match:
|
||
return formatter(match)
|
||
|
||
# FLANGE 재질 패턴
|
||
elif category == 'FLANGE':
|
||
flange_patterns = [
|
||
(r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'),
|
||
(r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존
|
||
(r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N
|
||
(r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우
|
||
(r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}'),
|
||
(r'A694\s*(F\d+)', lambda m: f'A694 {m.group(1)}')
|
||
]
|
||
|
||
for pattern, formatter in flange_patterns:
|
||
match = re.search(pattern, desc_upper)
|
||
if match:
|
||
return formatter(match)
|
||
|
||
# 패턴이 매치되지 않으면 기존 값 반환
|
||
return original_grade or '-'
|
||
|
||
def extract_enhanced_flange_type(description: str, original_type: str) -> str:
|
||
"""
|
||
FLANGE 타입에 PIPE측 연결면 정보 추가
|
||
|
||
Args:
|
||
description: 원본 설명
|
||
original_type: 기존 플랜지 타입
|
||
|
||
Returns:
|
||
개선된 플랜지 타입 (예: WN RF, SO FF 등)
|
||
"""
|
||
if not description:
|
||
return original_type or '-'
|
||
|
||
desc_upper = description.upper()
|
||
|
||
# 기본 플랜지 타입 매핑
|
||
flange_type_map = {
|
||
'WELD_NECK': 'WN',
|
||
'SLIP_ON': 'SO',
|
||
'BLIND': 'BL',
|
||
'SOCKET_WELD': 'SW',
|
||
'LAP_JOINT': 'LJ',
|
||
'THREADED': 'TH',
|
||
'ORIFICE': 'ORIFICE'
|
||
}
|
||
|
||
display_type = flange_type_map.get(original_type, original_type) if original_type else '-'
|
||
|
||
# PIPE측 연결면 타입 추출
|
||
pipe_end_type = ''
|
||
if ' RF' in desc_upper or 'RAISED FACE' in desc_upper:
|
||
pipe_end_type = ' RF'
|
||
elif ' FF' in desc_upper or 'FLAT FACE' in desc_upper:
|
||
pipe_end_type = ' FF'
|
||
elif ' RTJ' in desc_upper or 'RING TYPE JOINT' in desc_upper:
|
||
pipe_end_type = ' RTJ'
|
||
elif ' MSF' in desc_upper or 'MALE AND FEMALE' in desc_upper:
|
||
pipe_end_type = ' MSF'
|
||
elif ' T&G' in desc_upper or 'TONGUE AND GROOVE' in desc_upper:
|
||
pipe_end_type = ' T&G'
|
||
|
||
# 최종 타입 조합
|
||
if pipe_end_type and display_type != '-':
|
||
return display_type + pipe_end_type
|
||
|
||
return display_type
|
||
|
||
UPLOAD_DIR = Path("uploads")
|
||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
||
|
||
# API 정보는 /info 엔드포인트로 이동됨
|
||
|
||
@router.get("/test")
|
||
async def test_endpoint():
|
||
return {"status": "파일 API가 정상 작동합니다!"}
|
||
|
||
@router.post("/add-missing-columns")
|
||
async def add_missing_columns(db: Session = Depends(get_db)):
|
||
"""누락된 컬럼들 추가"""
|
||
try:
|
||
db.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS parsed_count INTEGER DEFAULT 0"))
|
||
db.execute(text("ALTER TABLE materials ADD COLUMN IF NOT EXISTS row_number INTEGER"))
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "누락된 컬럼들이 추가되었습니다",
|
||
"added_columns": ["files.parsed_count", "materials.row_number"]
|
||
}
|
||
except Exception as e:
|
||
db.rollback()
|
||
return {"success": False, "error": f"컬럼 추가 실패: {str(e)}"}
|
||
|
||
def validate_file_extension(filename: str) -> bool:
|
||
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
||
|
||
def generate_unique_filename(original_filename: str) -> str:
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
unique_id = str(uuid.uuid4())[:8]
|
||
stem = Path(original_filename).stem
|
||
suffix = Path(original_filename).suffix
|
||
return f"{stem}_{timestamp}_{unique_id}{suffix}"
|
||
|
||
def parse_dataframe(df):
|
||
df = df.dropna(how='all')
|
||
# 원본 컬럼명 출력
|
||
# 로그 제거
|
||
df.columns = df.columns.str.strip().str.lower()
|
||
# 로그 제거
|
||
|
||
column_mapping = {
|
||
'description': ['description', 'item', 'material', '품명', '자재명'],
|
||
'quantity': ['qty', 'quantity', 'ea', '수량'],
|
||
'main_size': ['main_nom', 'nominal_diameter', 'nd', '주배관'],
|
||
'red_size': ['red_nom', 'reduced_diameter', '축소배관'],
|
||
'length': ['length', 'len', '길이'],
|
||
'weight': ['weight', 'wt', '중량'],
|
||
'dwg_name': ['dwg_name', 'drawing', '도면명'],
|
||
'line_num': ['line_num', 'line_number', '라인번호']
|
||
}
|
||
|
||
mapped_columns = {}
|
||
for standard_col, possible_names in column_mapping.items():
|
||
for possible_name in possible_names:
|
||
if possible_name in df.columns:
|
||
mapped_columns[standard_col] = possible_name
|
||
break
|
||
|
||
print(f"📋 엑셀 컬럼 매핑 결과: {mapped_columns}")
|
||
print(f"📋 원본 컬럼명들: {list(df.columns)}")
|
||
|
||
materials = []
|
||
for index, row in df.iterrows():
|
||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||
|
||
try:
|
||
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
|
||
except:
|
||
quantity = 0
|
||
|
||
material_grade = ""
|
||
if "ASTM" in description.upper():
|
||
# ASTM 표준과 등급만 추출, end_preparation(BOE, POE, BBE 등)은 제외
|
||
# A\d{3,4} 패턴으로 3-4자리 숫자 보장, 등급도 포함
|
||
astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description.upper())
|
||
if astm_match:
|
||
material_grade = astm_match.group(0).strip()
|
||
|
||
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
|
||
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
|
||
|
||
# main_nom과 red_nom 별도 저장 (원본 값 유지)
|
||
main_nom = main_size if main_size != 'nan' and main_size != '' else None
|
||
red_nom = red_size if red_size != 'nan' and red_size != '' else None
|
||
|
||
# 기존 size_spec도 유지 (호환성을 위해)
|
||
if main_size != 'nan' and red_size != 'nan' and red_size != '':
|
||
size_spec = f"{main_size} x {red_size}"
|
||
elif main_size != 'nan' and main_size != '':
|
||
size_spec = main_size
|
||
else:
|
||
size_spec = ""
|
||
|
||
# LENGTH 정보 추출
|
||
length_raw = row.get(mapped_columns.get('length', ''), '')
|
||
length_value = None
|
||
if pd.notna(length_raw) and str(length_raw).strip() != '':
|
||
try:
|
||
length_value = float(str(length_raw).strip())
|
||
except (ValueError, TypeError):
|
||
length_value = None
|
||
|
||
# DWG_NAME 정보 추출
|
||
dwg_name_raw = row.get(mapped_columns.get('dwg_name', ''), '')
|
||
dwg_name = None
|
||
if pd.notna(dwg_name_raw) and str(dwg_name_raw).strip() not in ['', 'nan', 'None']:
|
||
dwg_name = str(dwg_name_raw).strip()
|
||
if index < 3: # 처음 3개만 로그
|
||
print(f"📐 도면번호 파싱: {dwg_name}")
|
||
|
||
# LINE_NUM 정보 추출
|
||
line_num_raw = row.get(mapped_columns.get('line_num', ''), '')
|
||
line_num = None
|
||
if pd.notna(line_num_raw) and str(line_num_raw).strip() not in ['', 'nan', 'None']:
|
||
line_num = str(line_num_raw).strip()
|
||
if index < 3: # 처음 3개만 로그
|
||
print(f"📍 라인번호 파싱: {line_num}")
|
||
|
||
if description and description not in ['nan', 'None', '']:
|
||
materials.append({
|
||
'original_description': description,
|
||
'quantity': quantity,
|
||
'unit': "EA",
|
||
'size_spec': size_spec,
|
||
'main_nom': main_nom,
|
||
'red_nom': red_nom,
|
||
'material_grade': material_grade,
|
||
'length': length_value,
|
||
'dwg_name': dwg_name,
|
||
'line_num': line_num,
|
||
'line_number': index + 1,
|
||
'row_number': index + 1
|
||
})
|
||
|
||
return materials
|
||
|
||
def parse_file_data(file_path):
|
||
file_extension = Path(file_path).suffix.lower()
|
||
|
||
try:
|
||
if file_extension == ".csv":
|
||
df = pd.read_csv(file_path, encoding='utf-8')
|
||
elif file_extension in [".xlsx", ".xls"]:
|
||
df = pd.read_excel(file_path, sheet_name=0)
|
||
else:
|
||
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식")
|
||
|
||
return parse_dataframe(df)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"파일 파싱 실패: {str(e)}")
|
||
|
||
@router.post("/upload")
|
||
async def upload_file(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
job_no: str = Form(...),
|
||
revision: str = Form("Rev.0"), # 기본값은 Rev.0 (새 BOM)
|
||
parent_file_id: Optional[int] = Form(None), # 리비전 업로드 시 부모 파일 ID
|
||
bom_name: Optional[str] = Form(None), # BOM 이름 (사용자 입력)
|
||
db: Session = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
# 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화
|
||
try:
|
||
# 1. 현재 트랜잭션 완전 롤백
|
||
db.rollback()
|
||
print("🔄 1단계: 이전 트랜잭션 롤백 완료")
|
||
|
||
# 2. 세션 상태 초기화
|
||
db.close()
|
||
print("🔄 2단계: 세션 닫기 완료")
|
||
|
||
# 3. 새 세션 생성
|
||
from ..database import get_db
|
||
db = next(get_db())
|
||
print("🔄 3단계: 새 세션 생성 완료")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ 트랜잭션 초기화 중 오류: {e}")
|
||
# 오류 발생 시에도 계속 진행
|
||
# 로그 제거
|
||
if not validate_file_extension(file.filename):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
|
||
)
|
||
|
||
if file.size and file.size > 10 * 1024 * 1024:
|
||
raise HTTPException(status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다")
|
||
|
||
unique_filename = generate_unique_filename(file.filename)
|
||
file_path = UPLOAD_DIR / unique_filename
|
||
|
||
try:
|
||
# 로그 제거
|
||
with open(file_path, "wb") as buffer:
|
||
shutil.copyfileobj(file.file, buffer)
|
||
# 로그 제거
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||
|
||
try:
|
||
# 로그 제거
|
||
materials_data = parse_file_data(str(file_path))
|
||
parsed_count = len(materials_data)
|
||
# 로그 제거
|
||
|
||
# 신규 자재 카운트 초기화
|
||
new_materials_count = 0
|
||
existing_materials_descriptions = set()
|
||
|
||
# 리비전 업로드인 경우만 자동 리비전 생성 및 기존 자재 조회
|
||
if parent_file_id is not None:
|
||
print(f"🔄 리비전 업로드 감지: parent_file_id={parent_file_id}")
|
||
# 부모 파일의 정보 조회
|
||
parent_query = text("""
|
||
SELECT original_filename, revision, bom_name FROM files
|
||
WHERE id = :parent_file_id AND job_no = :job_no
|
||
""")
|
||
|
||
parent_result = db.execute(parent_query, {
|
||
"parent_file_id": parent_file_id,
|
||
"job_no": job_no
|
||
})
|
||
parent_file = parent_result.fetchone()
|
||
|
||
if not parent_file:
|
||
raise HTTPException(status_code=404, detail="부모 파일을 찾을 수 없습니다.")
|
||
|
||
# 해당 BOM의 최신 리비전 확인 (bom_name 기준)
|
||
bom_name_to_use = parent_file[2] or parent_file[0] # bom_name 우선, 없으면 original_filename
|
||
latest_revision_query = text("""
|
||
SELECT revision FROM files
|
||
WHERE job_no = :job_no
|
||
AND (bom_name = :bom_name OR (bom_name IS NULL AND original_filename = :bom_name))
|
||
ORDER BY revision DESC
|
||
LIMIT 1
|
||
""")
|
||
|
||
latest_result = db.execute(latest_revision_query, {
|
||
"job_no": job_no,
|
||
"bom_name": bom_name_to_use
|
||
})
|
||
latest_revision = latest_result.fetchone()
|
||
|
||
if latest_revision:
|
||
latest_rev = latest_revision[0]
|
||
if latest_rev.startswith("Rev."):
|
||
try:
|
||
rev_num = int(latest_rev.replace("Rev.", ""))
|
||
revision = f"Rev.{rev_num + 1}"
|
||
except ValueError:
|
||
revision = "Rev.1"
|
||
else:
|
||
revision = "Rev.1"
|
||
|
||
print(f"리비전 업로드: {latest_rev} → {revision}")
|
||
else:
|
||
revision = "Rev.1"
|
||
print(f"첫 번째 리비전: {revision}")
|
||
|
||
# 모든 이전 리비전의 누적 자재 목록 조회 (리비전 0부터 현재까지)
|
||
existing_materials_query = text("""
|
||
SELECT m.original_description, m.size_spec, SUM(m.quantity) as total_quantity
|
||
FROM materials m
|
||
JOIN files f ON m.file_id = f.id
|
||
WHERE f.job_no = :job_no
|
||
AND f.id <= :parent_file_id
|
||
AND f.is_active = TRUE
|
||
GROUP BY m.original_description, m.size_spec
|
||
""")
|
||
|
||
existing_result = db.execute(existing_materials_query, {
|
||
"job_no": job_no,
|
||
"parent_file_id": parent_file_id
|
||
})
|
||
|
||
existing_materials_with_quantity = {}
|
||
for row in existing_result:
|
||
# 설명과 사이즈를 조합하여 유니크 키 생성
|
||
key = f"{row.original_description}|{row.size_spec or ''}"
|
||
existing_materials_descriptions.add(key)
|
||
existing_materials_with_quantity[key] = float(row.total_quantity or 0)
|
||
|
||
print(f"📊 누적 자재 수 (Rev.0~현재): {len(existing_materials_descriptions)}")
|
||
print(f"📊 누적 자재 총 수량: {sum(existing_materials_with_quantity.values())}")
|
||
if len(existing_materials_descriptions) > 0:
|
||
print(f"📝 기존 자재 샘플 (처음 3개): {list(existing_materials_descriptions)[:3]}")
|
||
# 수량이 있는 자재들 확인
|
||
quantity_samples = [(k, v) for k, v in list(existing_materials_with_quantity.items())[:3]]
|
||
print(f"📊 기존 자재 수량 샘플: {quantity_samples}")
|
||
else:
|
||
print(f"⚠️ 기존 자재가 없습니다! parent_file_id={parent_file_id}의 materials 테이블을 확인하세요.")
|
||
|
||
# 파일명을 부모와 동일하게 유지
|
||
file.filename = parent_file[0]
|
||
else:
|
||
# 일반 업로드 (새 BOM)
|
||
print(f"일반 업로드 모드: 새 BOM 파일 (Rev.0)")
|
||
|
||
# 파일 정보 저장 (사용자 정보 포함)
|
||
print("DB 저장 시작")
|
||
username = current_user.get('username', 'unknown')
|
||
user_id = current_user.get('user_id')
|
||
|
||
file_insert_query = text("""
|
||
INSERT INTO files (filename, original_filename, file_path, job_no, revision, bom_name, description, file_size, parsed_count, is_active, uploaded_by)
|
||
VALUES (:filename, :original_filename, :file_path, :job_no, :revision, :bom_name, :description, :file_size, :parsed_count, :is_active, :uploaded_by)
|
||
RETURNING id
|
||
""")
|
||
|
||
file_result = db.execute(file_insert_query, {
|
||
"filename": unique_filename,
|
||
"original_filename": file.filename,
|
||
"file_path": str(file_path),
|
||
"job_no": job_no,
|
||
"revision": revision,
|
||
"bom_name": bom_name or file.filename, # bom_name 우선, 없으면 파일명
|
||
"description": f"BOM 파일 - {parsed_count}개 자재",
|
||
"file_size": file.size,
|
||
"parsed_count": parsed_count,
|
||
"is_active": True,
|
||
"uploaded_by": username
|
||
})
|
||
|
||
file_id = file_result.fetchone()[0]
|
||
print(f"파일 저장 완료: file_id = {file_id}, uploaded_by = {username}")
|
||
|
||
# 🔄 리비전 비교 수행 (RULES.md 코딩 컨벤션 준수)
|
||
revision_comparison = None
|
||
materials_to_classify = materials_data
|
||
|
||
if revision != "Rev.0": # 리비전 업로드인 경우만 비교
|
||
# 로그 제거
|
||
try:
|
||
revision_comparison = get_revision_comparison(db, job_no, revision, materials_data)
|
||
|
||
if revision_comparison.get("has_previous_confirmation", False):
|
||
print(f"📊 리비전 비교 결과:")
|
||
print(f" - 변경없음: {revision_comparison.get('unchanged_count', 0)}개")
|
||
print(f" - 변경됨: {revision_comparison.get('changed_count', 0)}개")
|
||
print(f" - 신규: {revision_comparison.get('new_count', 0)}개")
|
||
print(f" - 삭제됨: {revision_comparison.get('removed_count', 0)}개")
|
||
print(f" - 분류 필요: {revision_comparison.get('classification_needed', 0)}개")
|
||
|
||
# 분류가 필요한 자재만 추출 (변경됨 + 신규)
|
||
materials_to_classify = (
|
||
revision_comparison.get("changed_materials", []) +
|
||
revision_comparison.get("new_materials", [])
|
||
)
|
||
else:
|
||
print("📝 이전 확정 자료 없음 - 전체 자재 분류")
|
||
|
||
except Exception as e:
|
||
logger.error(f"리비전 비교 실패: {str(e)}")
|
||
print(f"⚠️ 리비전 비교 실패, 전체 자재 분류로 진행: {str(e)}")
|
||
|
||
print(f"🔧 자재 분류 시작: {len(materials_to_classify)}개 자재")
|
||
|
||
# 자재 데이터 저장 (분류 포함) - 배치 처리로 성능 개선
|
||
materials_to_insert = []
|
||
pipe_details_to_insert = []
|
||
fitting_details_to_insert = []
|
||
bolt_details_to_insert = []
|
||
gasket_details_to_insert = []
|
||
flange_details_to_insert = []
|
||
|
||
materials_inserted = 0
|
||
|
||
# 변경없는 자재 먼저 처리 (기존 분류 결과 재사용)
|
||
if revision_comparison and revision_comparison.get("has_previous_confirmation", False):
|
||
unchanged_materials = revision_comparison.get("unchanged_materials", [])
|
||
for material_data in unchanged_materials:
|
||
previous_item = material_data.get("previous_item", {})
|
||
|
||
# 기존 분류 결과 재사용
|
||
materials_to_insert.append({
|
||
"file_id": file_id,
|
||
"original_description": material_data["original_description"],
|
||
"classified_category": previous_item.get("category", "UNKNOWN"),
|
||
"confidence": 1.0, # 확정된 자료이므로 신뢰도 100%
|
||
"quantity": material_data["quantity"],
|
||
"unit": material_data.get("unit", "EA"),
|
||
"size_spec": material_data.get("size_spec", ""),
|
||
"material_grade": previous_item.get("material", ""),
|
||
"specification": previous_item.get("specification", ""),
|
||
"reused_from_confirmation": True
|
||
})
|
||
materials_inserted += 1
|
||
|
||
# 리비전 업로드의 경우 변경사항 추적
|
||
changed_materials_keys = set()
|
||
new_materials_keys = set()
|
||
|
||
if parent_file_id is not None:
|
||
print(f"🔄 리비전 업로드: 전체 {len(materials_to_classify)}개 자재 저장 (변경사항 추적 포함)")
|
||
|
||
# 이전 리비전의 자재 조회 (도면번호 기준)
|
||
try:
|
||
# 🎯 트랜잭션 오류 방지: 쿼리 실행 전 롤백
|
||
db.rollback()
|
||
print("🔄 리비전 자재 조회 전 트랜잭션 롤백")
|
||
|
||
prev_materials_query = text("""
|
||
SELECT original_description, size_spec, material_grade, main_nom,
|
||
drawing_name, line_no, quantity
|
||
FROM materials
|
||
WHERE file_id = :parent_file_id
|
||
""")
|
||
prev_materials_result = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
|
||
print(f"✅ 이전 리비전 자재 조회 성공: {len(prev_materials_result)}개")
|
||
|
||
except Exception as e:
|
||
print(f"❌ 이전 리비전 자재 조회 실패: {e}")
|
||
# 오류 발생 시 빈 결과로 처리
|
||
prev_materials_result = []
|
||
|
||
# 이전 자재를 딕셔너리로 변환 (도면번호 + 설명 + 크기 + 재질로 키 생성)
|
||
prev_materials_dict = {}
|
||
for prev_mat in prev_materials_result:
|
||
if prev_mat.drawing_name:
|
||
key = f"{prev_mat.drawing_name}|{prev_mat.original_description}|{prev_mat.size_spec or ''}|{prev_mat.material_grade or ''}"
|
||
elif prev_mat.line_no:
|
||
key = f"{prev_mat.line_no}|{prev_mat.original_description}|{prev_mat.size_spec or ''}|{prev_mat.material_grade or ''}"
|
||
else:
|
||
key = f"{prev_mat.original_description}|{prev_mat.size_spec or ''}|{prev_mat.material_grade or ''}"
|
||
|
||
prev_materials_dict[key] = {
|
||
"quantity": float(prev_mat.quantity) if prev_mat.quantity else 0,
|
||
"description": prev_mat.original_description
|
||
}
|
||
|
||
# 새 자재와 비교하여 변경사항 감지
|
||
for material_data in materials_to_classify:
|
||
desc = material_data["original_description"]
|
||
size_spec = material_data.get("size_spec", "")
|
||
material_grade = material_data.get("material_grade", "")
|
||
dwg_name = material_data.get("dwg_name")
|
||
line_num = material_data.get("line_num")
|
||
|
||
if dwg_name:
|
||
new_key = f"{dwg_name}|{desc}|{size_spec}|{material_grade}"
|
||
elif line_num:
|
||
new_key = f"{line_num}|{desc}|{size_spec}|{material_grade}"
|
||
else:
|
||
new_key = f"{desc}|{size_spec}|{material_grade}"
|
||
|
||
if new_key in prev_materials_dict:
|
||
# 기존에 있던 자재 - 수량 변경 확인
|
||
prev_qty = prev_materials_dict[new_key]["quantity"]
|
||
new_qty = float(material_data.get("quantity", 0))
|
||
|
||
if abs(prev_qty - new_qty) > 0.001:
|
||
# 수량이 변경됨
|
||
changed_materials_keys.add(new_key)
|
||
print(f"🔄 변경 감지: {desc[:40]}... (수량: {prev_qty} → {new_qty})")
|
||
else:
|
||
# 새로 추가된 자재
|
||
new_materials_keys.add(new_key)
|
||
print(f"➕ 신규 감지: {desc[:40]}...")
|
||
|
||
print(f"📊 변경사항 요약: 변경 {len(changed_materials_keys)}개, 신규 {len(new_materials_keys)}개")
|
||
else:
|
||
print(f"🆕 신규 업로드: 전체 {len(materials_to_classify)}개 분류 처리")
|
||
|
||
# 분류가 필요한 자재 처리
|
||
print(f"분류할 자재 총 개수: {len(materials_to_classify)}")
|
||
|
||
for material_data in materials_to_classify:
|
||
# 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등)
|
||
description = material_data["original_description"]
|
||
size_spec = material_data["size_spec"]
|
||
|
||
# 각 분류기로 시도 (올바른 매개변수 사용)
|
||
print(f"분류 시도: {description}")
|
||
|
||
# LENGTH 정보 추출
|
||
length_value = None
|
||
if "length" in material_data:
|
||
try:
|
||
length_value = float(material_data["length"])
|
||
except (ValueError, TypeError):
|
||
length_value = None
|
||
|
||
# main_nom과 red_nom 추출
|
||
main_nom = material_data.get("main_nom")
|
||
red_nom = material_data.get("red_nom")
|
||
|
||
# 1. 통합 분류기로 자재 타입 결정
|
||
integrated_result = classify_material_integrated(description, main_nom or "", red_nom or "", length_value)
|
||
print(f"[분류] {description}")
|
||
print(f"통합 분류 결과: {integrated_result.get('category', 'UNKNOWN')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})")
|
||
|
||
# 2. 제외 대상 확인
|
||
if should_exclude_material(description):
|
||
classification_result = {
|
||
"category": "EXCLUDE",
|
||
"overall_confidence": 0.95,
|
||
"reason": "제외 대상 자재"
|
||
}
|
||
else:
|
||
# 3. 타입별 상세 분류기 실행
|
||
material_type = integrated_result.get('category', 'UNKNOWN')
|
||
|
||
if material_type == "PIPE":
|
||
from ..services.pipe_classifier import classify_pipe_for_purchase
|
||
classification_result = classify_pipe_for_purchase("", description, main_nom or "", length_value)
|
||
elif material_type == "FITTING":
|
||
classification_result = classify_fitting("", description, main_nom or "", red_nom)
|
||
elif material_type == "FLANGE":
|
||
classification_result = classify_flange("", description, main_nom or "", red_nom)
|
||
elif material_type == "VALVE":
|
||
classification_result = classify_valve("", description, main_nom or "")
|
||
elif material_type == "BOLT":
|
||
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":
|
||
classification_result = classify_gasket("", description, main_nom or "")
|
||
elif material_type == "INSTRUMENT":
|
||
classification_result = classify_instrument("", description, main_nom or "")
|
||
elif material_type == "SUPPORT":
|
||
from ..services.support_classifier import classify_support
|
||
classification_result = classify_support("", description, main_nom or "")
|
||
elif material_type == "SPECIAL":
|
||
# SPECIAL 카테고리는 별도 분류기 없이 통합 분류 결과 사용
|
||
classification_result = {
|
||
"category": "SPECIAL",
|
||
"overall_confidence": integrated_result.get('confidence', 1.0),
|
||
"reason": integrated_result.get('reason', 'SPECIAL 키워드 발견'),
|
||
"details": {
|
||
"description": description,
|
||
"main_nom": main_nom or "",
|
||
"drawing_required": True # 도면 필요
|
||
}
|
||
}
|
||
else:
|
||
# UNKNOWN 처리
|
||
classification_result = {
|
||
"category": "UNKNOWN",
|
||
"overall_confidence": integrated_result.get('confidence', 0.0),
|
||
"reason": f"분류 불가: {integrated_result.get('evidence', [])}"
|
||
}
|
||
|
||
# 통합 분류기의 신뢰도가 더 낮으면 조정
|
||
if integrated_result.get('confidence', 0) < 0.5:
|
||
classification_result['overall_confidence'] = min(
|
||
classification_result.get('overall_confidence', 1.0),
|
||
integrated_result.get('confidence', 0.0) + 0.2
|
||
)
|
||
|
||
print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}")
|
||
|
||
# 전체 재질명 추출
|
||
from ..services.material_grade_extractor import extract_full_material_grade
|
||
full_material_grade = extract_full_material_grade(description)
|
||
if not full_material_grade and material_data.get("material_grade"):
|
||
full_material_grade = material_data["material_grade"]
|
||
|
||
# 기본 자재 정보 저장
|
||
material_insert_query = text("""
|
||
INSERT INTO materials (
|
||
file_id, original_description, quantity, unit, size_spec,
|
||
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
|
||
classified_category, classification_confidence, is_verified,
|
||
drawing_name, line_no, created_at
|
||
)
|
||
VALUES (
|
||
:file_id, :original_description, :quantity, :unit, :size_spec,
|
||
:main_nom, :red_nom, :material_grade, :full_material_grade, :line_number, :row_number,
|
||
:classified_category, :classification_confidence, :is_verified,
|
||
:drawing_name, :line_no, :created_at
|
||
)
|
||
RETURNING id
|
||
""")
|
||
|
||
# 첫 번째 자재에 대해서만 디버그 출력
|
||
if materials_inserted == 0:
|
||
print(f"첫 번째 자재 저장:")
|
||
print(f" size_spec: '{material_data['size_spec']}'")
|
||
print(f" original_description: {material_data['original_description']}")
|
||
print(f" category: {classification_result.get('category', 'UNKNOWN')}")
|
||
print(f" drawing_name: {material_data.get('dwg_name')}")
|
||
print(f" line_no: {material_data.get('line_num')}")
|
||
|
||
material_result = db.execute(material_insert_query, {
|
||
"file_id": file_id,
|
||
"original_description": material_data["original_description"],
|
||
"quantity": material_data["quantity"],
|
||
"unit": material_data["unit"],
|
||
"size_spec": material_data["size_spec"],
|
||
"main_nom": material_data.get("main_nom"),
|
||
"red_nom": material_data.get("red_nom"),
|
||
"material_grade": material_data["material_grade"],
|
||
"full_material_grade": full_material_grade,
|
||
"line_number": material_data["line_number"],
|
||
"row_number": material_data["row_number"],
|
||
"classified_category": classification_result.get("category", "UNKNOWN"),
|
||
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
||
"is_verified": False,
|
||
"drawing_name": material_data.get("dwg_name"),
|
||
"line_no": material_data.get("line_num"),
|
||
"created_at": datetime.now()
|
||
})
|
||
|
||
material_id = material_result.fetchone()[0]
|
||
materials_inserted += 1
|
||
|
||
# 리비전 업로드인 경우 변경/신규 자재 표시 및 구매신청 정보 상속
|
||
if parent_file_id is not None:
|
||
# 현재 자재의 키 생성
|
||
dwg_name = material_data.get("dwg_name")
|
||
line_num = material_data.get("line_num")
|
||
|
||
if dwg_name:
|
||
current_key = f"{dwg_name}|{description}|{size_spec}|{material_data.get('material_grade', '')}"
|
||
elif line_num:
|
||
current_key = f"{line_num}|{description}|{size_spec}|{material_data.get('material_grade', '')}"
|
||
else:
|
||
current_key = f"{description}|{size_spec}|{material_data.get('material_grade', '')}"
|
||
|
||
# 변경 또는 신규 자재에 상태 표시
|
||
if current_key in changed_materials_keys:
|
||
# 변경된 자재
|
||
update_status_query = text("""
|
||
UPDATE materials SET revision_status = 'changed'
|
||
WHERE id = :material_id
|
||
""")
|
||
db.execute(update_status_query, {"material_id": material_id})
|
||
elif current_key in new_materials_keys:
|
||
# 신규 자재 (추가됨)
|
||
update_status_query = text("""
|
||
UPDATE materials SET revision_status = 'active'
|
||
WHERE id = :material_id
|
||
""")
|
||
db.execute(update_status_query, {"material_id": material_id})
|
||
|
||
# 구매신청 정보 상속은 전체 자재 저장 후 일괄 처리하도록 변경
|
||
# (개별 처리는 비효율적이고 수량 계산이 복잡함)
|
||
|
||
# PIPE 분류 결과인 경우 상세 정보 저장
|
||
if classification_result.get("category") == "PIPE":
|
||
print("PIPE 상세 정보 저장 시작")
|
||
|
||
# 끝단 가공 정보 추출 및 저장
|
||
from ..services.pipe_classifier import extract_end_preparation_info
|
||
end_prep_info = extract_end_preparation_info(description)
|
||
|
||
# 끝단 가공 정보 테이블에 저장
|
||
end_prep_insert_query = text("""
|
||
INSERT INTO pipe_end_preparations (
|
||
material_id, file_id, end_preparation_type, end_preparation_code,
|
||
machining_required, cutting_note, original_description, clean_description,
|
||
confidence, matched_pattern
|
||
) VALUES (
|
||
:material_id, :file_id, :end_preparation_type, :end_preparation_code,
|
||
:machining_required, :cutting_note, :original_description, :clean_description,
|
||
:confidence, :matched_pattern
|
||
)
|
||
""")
|
||
|
||
db.execute(end_prep_insert_query, {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"end_preparation_type": end_prep_info["end_preparation_type"],
|
||
"end_preparation_code": end_prep_info["end_preparation_code"],
|
||
"machining_required": end_prep_info["machining_required"],
|
||
"cutting_note": end_prep_info["cutting_note"],
|
||
"original_description": end_prep_info["original_description"],
|
||
"clean_description": end_prep_info["clean_description"],
|
||
"confidence": end_prep_info["confidence"],
|
||
"matched_pattern": end_prep_info["matched_pattern"]
|
||
})
|
||
|
||
# 길이 정보 추출 - 분류 결과의 length_info 우선 사용
|
||
length_info = classification_result.get("length_info", {})
|
||
length_mm = length_info.get("length_mm") or material_data.get("length", 0.0) if material_data.get("length") else None
|
||
|
||
# material_id도 함께 저장하도록 수정
|
||
pipe_detail_insert_query = text("""
|
||
INSERT INTO pipe_details (
|
||
material_id, file_id, outer_diameter, schedule,
|
||
material_spec, manufacturing_method, end_preparation, length_mm
|
||
) VALUES (
|
||
:material_id, :file_id, :outer_diameter, :schedule,
|
||
:material_spec, :manufacturing_method, :end_preparation, :length_mm
|
||
)
|
||
""")
|
||
|
||
# 재질 정보
|
||
material_info = classification_result.get("material", {})
|
||
manufacturing_info = classification_result.get("manufacturing", {})
|
||
end_prep_info = classification_result.get("end_preparation", {})
|
||
schedule_info = classification_result.get("schedule", {})
|
||
size_info = classification_result.get("size_info", {})
|
||
|
||
# main_nom을 outer_diameter로 활용
|
||
outer_diameter = material_data.get("main_nom") or material_data.get("size_spec", "")
|
||
|
||
# end_preparation 정보 추출 (분류 결과에서)
|
||
end_prep = ""
|
||
if isinstance(end_prep_info, dict):
|
||
end_prep = end_prep_info.get("type", "")
|
||
else:
|
||
end_prep = str(end_prep_info) if end_prep_info else ""
|
||
|
||
# 재질 정보 - 분류 결과에서 상세 정보 추출
|
||
material_grade_from_classifier = ""
|
||
if isinstance(material_info, dict):
|
||
material_grade_from_classifier = material_info.get("grade", "")
|
||
|
||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||
if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN":
|
||
material_spec = material_grade_from_classifier
|
||
|
||
# materials 테이블의 material_grade도 업데이트
|
||
db.execute(text("""
|
||
UPDATE materials
|
||
SET material_grade = :new_material_grade
|
||
WHERE id = :material_id
|
||
"""), {
|
||
"new_material_grade": material_grade_from_classifier,
|
||
"material_id": material_id
|
||
})
|
||
print(f"PIPE material_grade 업데이트: {material_grade_from_classifier}")
|
||
else:
|
||
# 기존 파싱 결과 사용
|
||
material_spec = material_data.get("material_grade", "")
|
||
|
||
# 제조방법 추출
|
||
manufacturing_method = ""
|
||
if isinstance(manufacturing_info, dict):
|
||
manufacturing_method = manufacturing_info.get("method", "UNKNOWN")
|
||
else:
|
||
manufacturing_method = str(manufacturing_info) if manufacturing_info else "UNKNOWN"
|
||
|
||
# 스케줄 정보 추출
|
||
schedule = ""
|
||
if isinstance(schedule_info, dict):
|
||
schedule = schedule_info.get("schedule", "UNKNOWN")
|
||
else:
|
||
schedule = str(schedule_info) if schedule_info else "UNKNOWN"
|
||
|
||
db.execute(pipe_detail_insert_query, {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"outer_diameter": outer_diameter,
|
||
"schedule": schedule,
|
||
"material_spec": material_spec,
|
||
"manufacturing_method": manufacturing_method,
|
||
"end_preparation": end_prep,
|
||
"length_mm": material_data.get("length", 0.0) if material_data.get("length") else 0.0
|
||
})
|
||
|
||
print("PIPE 상세 정보 저장 완료")
|
||
|
||
# FITTING 분류 결과인 경우 상세 정보 저장
|
||
elif classification_result.get("category") == "FITTING":
|
||
print("FITTING 상세 정보 저장 시작")
|
||
|
||
# 피팅 정보 추출
|
||
fitting_type_info = classification_result.get("fitting_type", {})
|
||
connection_info = classification_result.get("connection_method", {})
|
||
pressure_info = classification_result.get("pressure_rating", {})
|
||
material_info = classification_result.get("material", {})
|
||
|
||
# 피팅 타입 및 서브타입
|
||
fitting_type = fitting_type_info.get("type", "UNKNOWN")
|
||
fitting_subtype = fitting_type_info.get("subtype", "UNKNOWN")
|
||
|
||
# 연결 방식
|
||
connection_method = connection_info.get("method", "UNKNOWN")
|
||
|
||
# 압력 등급
|
||
pressure_rating = pressure_info.get("rating", "UNKNOWN")
|
||
|
||
# 재질 정보
|
||
material_standard = material_info.get("standard", "")
|
||
material_grade = material_info.get("grade", "")
|
||
|
||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||
if material_grade and material_grade != "UNKNOWN":
|
||
db.execute(text("""
|
||
UPDATE materials
|
||
SET material_grade = :new_material_grade
|
||
WHERE id = :material_id
|
||
"""), {
|
||
"new_material_grade": material_grade,
|
||
"material_id": material_id
|
||
})
|
||
print(f"FITTING material_grade 업데이트: {material_grade}")
|
||
|
||
# main_size와 reduced_size
|
||
main_size = material_data.get("main_nom") or material_data.get("size_spec", "")
|
||
reduced_size = material_data.get("red_nom", "")
|
||
|
||
# NIPPLE인 경우 길이와 스케줄 정보 추가
|
||
length_mm = None
|
||
schedule = "UNKNOWN"
|
||
if fitting_type == "NIPPLE":
|
||
# 길이 정보 추출
|
||
length_mm = material_data.get("length", 0.0) if material_data.get("length") else None
|
||
|
||
# 스케줄 정보 추출 (분류 결과에서)
|
||
schedule_info = classification_result.get("schedule_info", {})
|
||
schedule = schedule_info.get("schedule", "UNKNOWN")
|
||
schedule_info = classification_result.get("schedule", {})
|
||
if isinstance(schedule_info, dict):
|
||
schedule = schedule_info.get("schedule", "UNKNOWN")
|
||
else:
|
||
schedule = str(schedule_info) if schedule_info else "UNKNOWN"
|
||
|
||
db.execute(text("""
|
||
INSERT INTO fitting_details (
|
||
material_id, file_id, fitting_type, fitting_subtype,
|
||
connection_method, pressure_rating, material_standard,
|
||
material_grade, main_size, reduced_size, length_mm, schedule
|
||
) VALUES (
|
||
:material_id, :file_id, :fitting_type, :fitting_subtype,
|
||
:connection_method, :pressure_rating, :material_standard,
|
||
:material_grade, :main_size, :reduced_size, :length_mm, :schedule
|
||
)
|
||
"""), {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"fitting_type": fitting_type,
|
||
"fitting_subtype": fitting_subtype,
|
||
"connection_method": connection_method,
|
||
"pressure_rating": pressure_rating,
|
||
"material_standard": material_standard,
|
||
"material_grade": material_grade,
|
||
"main_size": main_size,
|
||
"reduced_size": reduced_size,
|
||
"length_mm": length_mm,
|
||
"schedule": schedule
|
||
})
|
||
|
||
print(f"FITTING 상세 정보 저장 완료: {fitting_type} - {fitting_subtype}")
|
||
|
||
# FLANGE 분류 결과인 경우 상세 정보 저장
|
||
if classification_result.get("category") == "FLANGE":
|
||
print("FLANGE 상세 정보 저장 시작")
|
||
|
||
# 플랜지 타입 정보
|
||
flange_type_info = classification_result.get("flange_type", {})
|
||
pressure_info = classification_result.get("pressure_rating", {})
|
||
face_finish_info = classification_result.get("face_finish", {})
|
||
material_info = classification_result.get("material", {})
|
||
|
||
# 플랜지 타입 (WN, BL, SO 등)
|
||
flange_type = ""
|
||
if isinstance(flange_type_info, dict):
|
||
flange_type = flange_type_info.get("type", "UNKNOWN")
|
||
else:
|
||
flange_type = str(flange_type_info) if flange_type_info else "UNKNOWN"
|
||
|
||
# 압력 등급 (150LB, 300LB 등)
|
||
pressure_rating = ""
|
||
if isinstance(pressure_info, dict):
|
||
pressure_rating = pressure_info.get("rating", "UNKNOWN")
|
||
else:
|
||
pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN"
|
||
|
||
# 면 가공 (RF, FF, RTJ 등)
|
||
facing_type = ""
|
||
if isinstance(face_finish_info, dict):
|
||
facing_type = face_finish_info.get("finish", "UNKNOWN")
|
||
else:
|
||
facing_type = str(face_finish_info) if face_finish_info else "UNKNOWN"
|
||
|
||
# 재질 정보
|
||
material_standard = ""
|
||
material_grade = ""
|
||
if isinstance(material_info, dict):
|
||
material_standard = material_info.get("standard", "")
|
||
material_grade = material_info.get("grade", "")
|
||
|
||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||
if material_grade and material_grade != "UNKNOWN":
|
||
db.execute(text("""
|
||
UPDATE materials
|
||
SET material_grade = :new_material_grade
|
||
WHERE id = :material_id
|
||
"""), {
|
||
"new_material_grade": material_grade,
|
||
"material_id": material_id
|
||
})
|
||
print(f"FLANGE material_grade 업데이트: {material_grade}")
|
||
|
||
# 사이즈 정보
|
||
size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
|
||
|
||
db.execute(text("""
|
||
INSERT INTO flange_details (
|
||
material_id, file_id, flange_type, pressure_rating,
|
||
facing_type, material_standard, material_grade, size_inches
|
||
) VALUES (
|
||
:material_id, :file_id, :flange_type, :pressure_rating,
|
||
:facing_type, :material_standard, :material_grade, :size_inches
|
||
)
|
||
"""), {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"flange_type": flange_type,
|
||
"pressure_rating": pressure_rating,
|
||
"facing_type": facing_type,
|
||
"material_standard": material_standard,
|
||
"material_grade": material_grade,
|
||
"size_inches": size_inches
|
||
})
|
||
|
||
print(f"FLANGE 상세 정보 저장 완료: {flange_type} - {pressure_rating}")
|
||
|
||
# GASKET 분류 결과인 경우 상세 정보 저장
|
||
if classification_result.get("category") == "GASKET":
|
||
print("GASKET 상세 정보 저장 시작")
|
||
|
||
# 가스켓 타입 정보
|
||
gasket_type_info = classification_result.get("gasket_type", {})
|
||
gasket_material_info = classification_result.get("gasket_material", {})
|
||
pressure_info = classification_result.get("pressure_rating", {})
|
||
|
||
# 가스켓 타입 (SPIRAL_WOUND, O_RING 등)
|
||
gasket_type = ""
|
||
if isinstance(gasket_type_info, dict):
|
||
gasket_type = gasket_type_info.get("type", "UNKNOWN")
|
||
else:
|
||
gasket_type = str(gasket_type_info) if gasket_type_info else "UNKNOWN"
|
||
|
||
# 가스켓 소재 - SWG의 경우 메탈 부분을 우선으로
|
||
material_type = ""
|
||
if isinstance(gasket_material_info, dict):
|
||
# SWG 상세 정보가 있으면 메탈 부분을 material_type으로 사용
|
||
swg_details = gasket_material_info.get("swg_details", {})
|
||
if swg_details and swg_details.get("outer_ring"):
|
||
material_type = swg_details.get("outer_ring", "UNKNOWN") # SS304
|
||
else:
|
||
material_type = gasket_material_info.get("material", "UNKNOWN")
|
||
else:
|
||
material_type = str(gasket_material_info) if gasket_material_info else "UNKNOWN"
|
||
|
||
# 압력 등급
|
||
pressure_rating = ""
|
||
if isinstance(pressure_info, dict):
|
||
pressure_rating = pressure_info.get("rating", "UNKNOWN")
|
||
else:
|
||
pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN"
|
||
|
||
# 사이즈 정보
|
||
size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
|
||
|
||
# SWG 상세 정보 추출
|
||
swg_details = gasket_material_info.get("swg_details", {}) if isinstance(gasket_material_info, dict) else {}
|
||
thickness = swg_details.get("thickness", None) if swg_details else None
|
||
filler_material = swg_details.get("filler", "") if swg_details else ""
|
||
|
||
# additional_info에 SWG 상세 정보 저장
|
||
additional_info = ""
|
||
if swg_details:
|
||
face_type = swg_details.get("face_type", "")
|
||
outer_ring = swg_details.get("outer_ring", "")
|
||
inner_ring = swg_details.get("inner_ring", "")
|
||
construction = swg_details.get("detailed_construction", "")
|
||
|
||
# JSON 형태로 additional_info 생성
|
||
additional_info = {
|
||
"face_type": face_type,
|
||
"construction": construction,
|
||
"outer_ring": outer_ring,
|
||
"inner_ring": inner_ring,
|
||
"filler": swg_details.get("filler", ""),
|
||
"thickness": swg_details.get("thickness", None)
|
||
}
|
||
additional_info_json = json.dumps(additional_info, ensure_ascii=False)
|
||
|
||
db.execute(text("""
|
||
INSERT INTO gasket_details (
|
||
material_id, file_id, gasket_type, material_type,
|
||
pressure_rating, size_inches, thickness, filler_material, additional_info
|
||
) VALUES (
|
||
:material_id, :file_id, :gasket_type, :material_type,
|
||
:pressure_rating, :size_inches, :thickness, :filler_material, :additional_info
|
||
)
|
||
"""), {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"gasket_type": gasket_type,
|
||
"material_type": material_type,
|
||
"pressure_rating": pressure_rating,
|
||
"size_inches": size_inches,
|
||
"thickness": thickness,
|
||
"filler_material": filler_material,
|
||
"additional_info": additional_info_json
|
||
})
|
||
|
||
print(f"GASKET 상세 정보 저장 완료: {gasket_type} - {material_type}")
|
||
|
||
# BOLT 분류 결과인 경우 상세 정보 저장
|
||
if classification_result.get("category") == "BOLT":
|
||
print("BOLT 상세 정보 저장 시작")
|
||
|
||
# 볼트 타입 정보
|
||
fastener_type_info = classification_result.get("fastener_type", {})
|
||
thread_spec_info = classification_result.get("thread_specification", {})
|
||
dimensions_info = classification_result.get("dimensions", {})
|
||
material_info = classification_result.get("material", {})
|
||
|
||
print(f"🔧 fastener_type_info: {fastener_type_info}")
|
||
|
||
# 볼트 타입 (STUD_BOLT, HEX_BOLT 등)
|
||
bolt_type = ""
|
||
if isinstance(fastener_type_info, dict):
|
||
bolt_type = fastener_type_info.get("type", "UNKNOWN")
|
||
print(f"🔧 추출된 bolt_type: {bolt_type}")
|
||
else:
|
||
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 등)
|
||
thread_type = ""
|
||
if isinstance(thread_spec_info, dict):
|
||
thread_type = thread_spec_info.get("standard", "UNKNOWN")
|
||
else:
|
||
thread_type = str(thread_spec_info) if thread_spec_info else "UNKNOWN"
|
||
|
||
# 치수 정보 (실제 볼트 사이즈 사용)
|
||
diameter = ""
|
||
length = ""
|
||
nominal_size_fraction = ""
|
||
if isinstance(dimensions_info, dict):
|
||
# 볼트 분류기에서 추출한 실제 볼트 사이즈 사용
|
||
diameter = dimensions_info.get("nominal_size", material_data.get("main_nom", ""))
|
||
nominal_size_fraction = dimensions_info.get("nominal_size_fraction", diameter)
|
||
length = dimensions_info.get("length", "")
|
||
if not length and "70.0000 LG" in description:
|
||
# 원본 설명에서 길이 추출
|
||
import re
|
||
length_match = re.search(r'(\d+(?:\.\d+)?)\s*LG', description.upper())
|
||
if length_match:
|
||
length = f"{length_match.group(1)}mm"
|
||
|
||
# 재질 정보
|
||
material_standard = ""
|
||
material_grade = ""
|
||
if isinstance(material_info, dict):
|
||
material_standard = material_info.get("standard", "")
|
||
material_grade = material_info.get("grade", "")
|
||
|
||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||
if material_grade and material_grade != "UNKNOWN":
|
||
db.execute(text("""
|
||
UPDATE materials
|
||
SET material_grade = :new_material_grade
|
||
WHERE id = :material_id
|
||
"""), {
|
||
"new_material_grade": material_grade,
|
||
"material_id": material_id
|
||
})
|
||
print(f"BOLT material_grade 업데이트: {material_grade}")
|
||
|
||
# 압력 등급 (150LB 등)
|
||
pressure_rating = ""
|
||
if "150LB" in description.upper():
|
||
pressure_rating = "150LB"
|
||
elif "300LB" in description.upper():
|
||
pressure_rating = "300LB"
|
||
elif "600LB" in description.upper():
|
||
pressure_rating = "600LB"
|
||
|
||
# 코팅 타입 (ELEC.GALV 등)
|
||
coating_type = ""
|
||
if "ELEC.GALV" in description.upper() or "ELEC GALV" in description.upper():
|
||
coating_type = "ELECTRO_GALVANIZED"
|
||
elif "HOT.GALV" in description.upper() or "HOT GALV" in description.upper():
|
||
coating_type = "HOT_DIP_GALVANIZED"
|
||
elif "GALV" in description.upper():
|
||
coating_type = "GALVANIZED"
|
||
elif "ZINC" in description.upper():
|
||
coating_type = "ZINC_PLATED"
|
||
elif "DACROMET" in description.upper():
|
||
coating_type = "DACROMET"
|
||
elif "SS" in description.upper() or "STAINLESS" in description.upper():
|
||
coating_type = "STAINLESS"
|
||
elif "PLAIN" in description.upper() or "BLACK" in description.upper():
|
||
coating_type = "PLAIN"
|
||
|
||
db.execute(text("""
|
||
INSERT INTO bolt_details (
|
||
material_id, file_id, bolt_type, thread_type,
|
||
diameter, length, material_standard, material_grade,
|
||
coating_type, pressure_rating, classification_confidence
|
||
) VALUES (
|
||
:material_id, :file_id, :bolt_type, :thread_type,
|
||
:diameter, :length, :material_standard, :material_grade,
|
||
:coating_type, :pressure_rating, :classification_confidence
|
||
)
|
||
"""), {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"bolt_type": bolt_type,
|
||
"thread_type": thread_type,
|
||
"diameter": diameter,
|
||
"length": length,
|
||
"material_standard": material_standard,
|
||
"material_grade": material_grade,
|
||
"coating_type": coating_type,
|
||
"pressure_rating": pressure_rating,
|
||
"classification_confidence": classification_result.get("overall_confidence", 0.0)
|
||
})
|
||
|
||
print(f"BOLT 상세 정보 저장 완료: {bolt_type} - {material_standard} {material_grade}")
|
||
|
||
# SUPPORT 분류 결과인 경우 상세 정보 저장
|
||
if classification_result.get("category") == "SUPPORT":
|
||
print("SUPPORT 상세 정보 저장 시작")
|
||
|
||
support_type = classification_result.get("support_type", "UNKNOWN")
|
||
support_subtype = classification_result.get("support_subtype", "")
|
||
load_rating = classification_result.get("load_rating", "")
|
||
load_capacity = classification_result.get("load_capacity", "")
|
||
|
||
# 재질 정보
|
||
material_info = classification_result.get("material", {})
|
||
material_standard = material_info.get("standard", "UNKNOWN")
|
||
material_grade = material_info.get("grade", "UNKNOWN")
|
||
|
||
# 사이즈 정보
|
||
size_info = classification_result.get("size_info", {})
|
||
pipe_size = size_info.get("pipe_size", "")
|
||
dimensions = size_info.get("dimensions", {})
|
||
|
||
length_mm = None
|
||
width_mm = None
|
||
height_mm = None
|
||
|
||
if dimensions:
|
||
length_str = dimensions.get("length", "")
|
||
width_str = dimensions.get("width", "")
|
||
height_str = dimensions.get("height", "")
|
||
|
||
# mm 단위 추출
|
||
import re
|
||
if length_str:
|
||
length_match = re.search(r'(\d+(?:\.\d+)?)', length_str)
|
||
if length_match:
|
||
length_mm = float(length_match.group(1))
|
||
|
||
if width_str:
|
||
width_match = re.search(r'(\d+(?:\.\d+)?)', width_str)
|
||
if width_match:
|
||
width_mm = float(width_match.group(1))
|
||
|
||
if height_str:
|
||
height_match = re.search(r'(\d+(?:\.\d+)?)', height_str)
|
||
if height_match:
|
||
height_mm = float(height_match.group(1))
|
||
|
||
db.execute(text("""
|
||
INSERT INTO support_details (
|
||
material_id, file_id, support_type, support_subtype,
|
||
load_rating, load_capacity, material_standard, material_grade,
|
||
pipe_size, length_mm, width_mm, height_mm, classification_confidence
|
||
) VALUES (
|
||
:material_id, :file_id, :support_type, :support_subtype,
|
||
:load_rating, :load_capacity, :material_standard, :material_grade,
|
||
:pipe_size, :length_mm, :width_mm, :height_mm, :classification_confidence
|
||
)
|
||
"""), {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"support_type": support_type,
|
||
"support_subtype": support_subtype,
|
||
"load_rating": load_rating,
|
||
"load_capacity": load_capacity,
|
||
"material_standard": material_standard,
|
||
"material_grade": material_grade,
|
||
"pipe_size": pipe_size,
|
||
"length_mm": length_mm,
|
||
"width_mm": width_mm,
|
||
"height_mm": height_mm,
|
||
"classification_confidence": classification_result.get("overall_confidence", 0.0)
|
||
})
|
||
|
||
print(f"SUPPORT 상세 정보 저장 완료: {support_type} - {material_standard} {material_grade}")
|
||
|
||
# VALVE 분류 결과인 경우 상세 정보 저장
|
||
if classification_result.get("category") == "VALVE":
|
||
print("VALVE 상세 정보 저장 시작")
|
||
|
||
# 밸브 타입 정보
|
||
valve_type_info = classification_result.get("valve_type", {})
|
||
connection_info = classification_result.get("connection_method", {})
|
||
pressure_info = classification_result.get("pressure_rating", {})
|
||
material_info = classification_result.get("material", {})
|
||
|
||
# 밸브 타입 (GATE_VALVE, BALL_VALVE 등)
|
||
valve_type = ""
|
||
if isinstance(valve_type_info, dict):
|
||
valve_type = valve_type_info.get("type", "UNKNOWN")
|
||
else:
|
||
valve_type = str(valve_type_info) if valve_type_info else "UNKNOWN"
|
||
|
||
# 밸브 서브타입 (특수 기능)
|
||
valve_subtype = ""
|
||
special_features = classification_result.get("special_features", [])
|
||
if special_features:
|
||
valve_subtype = ", ".join(special_features)
|
||
|
||
# 작동 방식
|
||
actuator_type = "MANUAL" # 기본값
|
||
actuation_info = classification_result.get("actuation", {})
|
||
if isinstance(actuation_info, dict):
|
||
actuator_type = actuation_info.get("method", "MANUAL")
|
||
|
||
# 연결 방식 (FLANGED, THREADED 등)
|
||
connection_method = ""
|
||
if isinstance(connection_info, dict):
|
||
connection_method = connection_info.get("method", "UNKNOWN")
|
||
else:
|
||
connection_method = str(connection_info) if connection_info else "UNKNOWN"
|
||
|
||
# 압력 등급 (150LB, 300LB 등)
|
||
pressure_rating = ""
|
||
if isinstance(pressure_info, dict):
|
||
pressure_rating = pressure_info.get("rating", "UNKNOWN")
|
||
else:
|
||
pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN"
|
||
|
||
# 재질 정보
|
||
body_material = ""
|
||
trim_material = ""
|
||
if isinstance(material_info, dict):
|
||
body_material = material_info.get("grade", "")
|
||
# 트림 재질은 일반적으로 바디와 동일하거나 별도 명시
|
||
trim_material = body_material
|
||
|
||
# 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트
|
||
if body_material and body_material != "UNKNOWN":
|
||
db.execute(text("""
|
||
UPDATE materials
|
||
SET material_grade = :new_material_grade
|
||
WHERE id = :material_id
|
||
"""), {
|
||
"new_material_grade": body_material,
|
||
"material_id": material_id
|
||
})
|
||
print(f"VALVE material_grade 업데이트: {body_material}")
|
||
|
||
# 사이즈 정보
|
||
size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
|
||
|
||
# 특수 기능 (Fire Safe, Anti-Static 등)
|
||
fire_safe = any("FIRE" in feature.upper() for feature in special_features)
|
||
low_temp_service = any("CRYO" in feature.upper() or "LOW" in feature.upper() for feature in special_features)
|
||
|
||
# 추가 정보 JSON 생성
|
||
additional_info = {
|
||
"characteristics": valve_type_info.get("characteristics", "") if isinstance(valve_type_info, dict) else "",
|
||
"typical_connections": valve_type_info.get("typical_connections", []) if isinstance(valve_type_info, dict) else [],
|
||
"special_features": special_features,
|
||
"manufacturing": classification_result.get("manufacturing", {}),
|
||
"evidence": valve_type_info.get("evidence", []) if isinstance(valve_type_info, dict) else []
|
||
}
|
||
additional_info_json = json.dumps(additional_info, ensure_ascii=False)
|
||
|
||
db.execute(text("""
|
||
INSERT INTO valve_details (
|
||
material_id, file_id, valve_type, valve_subtype,
|
||
actuator_type, connection_method, pressure_rating,
|
||
body_material, trim_material, size_inches,
|
||
fire_safe, low_temp_service, classification_confidence, additional_info
|
||
) VALUES (
|
||
:material_id, :file_id, :valve_type, :valve_subtype,
|
||
:actuator_type, :connection_method, :pressure_rating,
|
||
:body_material, :trim_material, :size_inches,
|
||
:fire_safe, :low_temp_service, :classification_confidence, :additional_info
|
||
)
|
||
"""), {
|
||
"material_id": material_id,
|
||
"file_id": file_id,
|
||
"valve_type": valve_type,
|
||
"valve_subtype": valve_subtype,
|
||
"actuator_type": actuator_type,
|
||
"connection_method": connection_method,
|
||
"pressure_rating": pressure_rating,
|
||
"body_material": body_material,
|
||
"trim_material": trim_material,
|
||
"size_inches": size_inches,
|
||
"fire_safe": fire_safe,
|
||
"low_temp_service": low_temp_service,
|
||
"classification_confidence": classification_result.get("overall_confidence", 0.0),
|
||
"additional_info": additional_info_json
|
||
})
|
||
|
||
print(f"VALVE 상세 정보 저장 완료: {valve_type} - {connection_method} - {pressure_rating}")
|
||
|
||
db.commit()
|
||
print(f"자재 저장 완료: {materials_inserted}개")
|
||
|
||
# 리비전 업로드인 경우 구매신청 정보 수량 기반 상속
|
||
if parent_file_id is not None:
|
||
try:
|
||
print(f"🔄 구매신청 정보 상속 처리 시작...")
|
||
|
||
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
|
||
prev_purchase_summary = text("""
|
||
SELECT
|
||
m.original_description,
|
||
m.size_spec,
|
||
m.material_grade,
|
||
m.drawing_name,
|
||
COUNT(DISTINCT pri.material_id) as purchased_count,
|
||
SUM(pri.quantity) as total_purchased_qty,
|
||
MIN(pri.request_id) as request_id
|
||
FROM materials m
|
||
JOIN purchase_request_items pri ON m.id = pri.material_id
|
||
WHERE m.file_id = :parent_file_id
|
||
GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name
|
||
""")
|
||
|
||
prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall()
|
||
|
||
# 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속
|
||
for prev_purchase in prev_purchases:
|
||
purchased_count = prev_purchase.purchased_count
|
||
|
||
# 새 리비전에서 같은 그룹의 자재 조회 (순서대로)
|
||
new_group_materials = text("""
|
||
SELECT id, quantity
|
||
FROM materials
|
||
WHERE file_id = :file_id
|
||
AND original_description = :description
|
||
AND COALESCE(size_spec, '') = :size_spec
|
||
AND COALESCE(material_grade, '') = :material_grade
|
||
AND COALESCE(drawing_name, '') = :drawing_name
|
||
ORDER BY id
|
||
LIMIT :limit
|
||
""")
|
||
|
||
new_materials = db.execute(new_group_materials, {
|
||
"file_id": file_id,
|
||
"description": prev_purchase.original_description,
|
||
"size_spec": prev_purchase.size_spec or '',
|
||
"material_grade": prev_purchase.material_grade or '',
|
||
"drawing_name": prev_purchase.drawing_name or '',
|
||
"limit": purchased_count
|
||
}).fetchall()
|
||
|
||
# 구매신청 수량만큼만 상속
|
||
for new_mat in new_materials:
|
||
inherit_query = text("""
|
||
INSERT INTO purchase_request_items (
|
||
request_id, material_id, quantity, unit, user_requirement
|
||
) VALUES (
|
||
:request_id, :material_id, :quantity, 'EA', ''
|
||
)
|
||
ON CONFLICT DO NOTHING
|
||
""")
|
||
db.execute(inherit_query, {
|
||
"request_id": prev_purchase.request_id,
|
||
"material_id": new_mat.id,
|
||
"quantity": new_mat.quantity
|
||
})
|
||
|
||
inherited_count = len(new_materials)
|
||
if inherited_count > 0:
|
||
print(f" ✅ {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속")
|
||
|
||
db.commit()
|
||
print(f"✅ 구매신청 정보 상속 완료")
|
||
|
||
except Exception as e:
|
||
print(f"❌ 구매신청 정보 상속 실패: {str(e)}")
|
||
db.rollback()
|
||
# 상속 실패는 업로드 성공에 영향 없음
|
||
|
||
# 활동 로그 기록
|
||
try:
|
||
activity_logger = ActivityLogger(db)
|
||
activity_logger.log_file_upload(
|
||
username=username,
|
||
file_id=file_id,
|
||
filename=file.filename,
|
||
file_size=file.size or 0,
|
||
job_no=job_no,
|
||
revision=revision,
|
||
user_id=user_id,
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get('user-agent')
|
||
)
|
||
print(f"활동 로그 기록 완료: {username} - 파일 업로드")
|
||
except Exception as e:
|
||
print(f"활동 로그 기록 실패: {str(e)}")
|
||
# 로그 실패는 업로드 성공에 영향을 주지 않음
|
||
|
||
# 리비전 업로드인 경우 누락된 도면 감지 및 구매신청 여부 확인
|
||
missing_drawings_info = None
|
||
has_previous_purchase = False
|
||
|
||
if parent_file_id is not None:
|
||
try:
|
||
# 이전 리비전의 구매신청 여부 확인
|
||
purchase_check_query = text("""
|
||
SELECT COUNT(*) as purchase_count
|
||
FROM purchase_request_items pri
|
||
JOIN materials m ON pri.material_id = m.id
|
||
WHERE m.file_id = :parent_file_id
|
||
""")
|
||
purchase_result = db.execute(purchase_check_query, {"parent_file_id": parent_file_id}).fetchone()
|
||
has_previous_purchase = purchase_result.purchase_count > 0
|
||
|
||
print(f"📦 이전 리비전 구매신청 여부: {has_previous_purchase} ({purchase_result.purchase_count}개 자재)")
|
||
print(f"📂 parent_file_id: {parent_file_id}, new file_id: {file_id}")
|
||
|
||
# 이전 리비전의 도면 목록 조회
|
||
prev_drawings_query = text("""
|
||
SELECT DISTINCT drawing_name, line_no, COUNT(*) as material_count
|
||
FROM materials
|
||
WHERE file_id = :parent_file_id
|
||
AND (drawing_name IS NOT NULL OR line_no IS NOT NULL)
|
||
GROUP BY drawing_name, line_no
|
||
""")
|
||
prev_drawings_result = db.execute(prev_drawings_query, {"parent_file_id": parent_file_id}).fetchall()
|
||
print(f"📋 이전 리비전 도면 수: {len(prev_drawings_result)}")
|
||
|
||
# 새 리비전의 도면 목록 조회
|
||
new_drawings_query = text("""
|
||
SELECT DISTINCT drawing_name, line_no
|
||
FROM materials
|
||
WHERE file_id = :file_id
|
||
AND (drawing_name IS NOT NULL OR line_no IS NOT NULL)
|
||
""")
|
||
new_drawings_result = db.execute(new_drawings_query, {"file_id": file_id}).fetchall()
|
||
print(f"📋 새 리비전 도면 수: {len(new_drawings_result)}")
|
||
|
||
prev_drawings = set()
|
||
for row in prev_drawings_result:
|
||
if row.drawing_name:
|
||
prev_drawings.add(row.drawing_name)
|
||
elif row.line_no:
|
||
prev_drawings.add(row.line_no)
|
||
|
||
new_drawings = set()
|
||
for row in new_drawings_result:
|
||
if row.drawing_name:
|
||
new_drawings.add(row.drawing_name)
|
||
elif row.line_no:
|
||
new_drawings.add(row.line_no)
|
||
|
||
missing_drawings = prev_drawings - new_drawings
|
||
|
||
print(f"📊 이전 도면: {list(prev_drawings)[:5]}")
|
||
print(f"📊 새 도면: {list(new_drawings)[:5]}")
|
||
print(f"❌ 누락 도면: {len(missing_drawings)}개")
|
||
|
||
if missing_drawings:
|
||
# 누락된 도면의 자재 상세 정보
|
||
missing_materials = []
|
||
for row in prev_drawings_result:
|
||
drawing = row.drawing_name or row.line_no
|
||
if drawing in missing_drawings:
|
||
missing_materials.append({
|
||
"drawing_name": drawing,
|
||
"material_count": row.material_count
|
||
})
|
||
|
||
missing_drawings_info = {
|
||
"drawings": list(missing_drawings),
|
||
"materials": missing_materials,
|
||
"count": len(missing_drawings),
|
||
"requires_confirmation": True,
|
||
"has_previous_purchase": has_previous_purchase,
|
||
"action": "mark_as_inventory" if has_previous_purchase else "hide_materials"
|
||
}
|
||
|
||
# 누락된 도면의 자재 상태 업데이트
|
||
if missing_drawings:
|
||
for drawing in missing_drawings:
|
||
status_to_set = 'inventory' if has_previous_purchase else 'deleted_not_purchased'
|
||
|
||
update_status_query = text("""
|
||
UPDATE materials
|
||
SET revision_status = :status
|
||
WHERE file_id = :parent_file_id
|
||
AND (drawing_name = :drawing OR line_no = :drawing)
|
||
""")
|
||
|
||
db.execute(update_status_query, {
|
||
"status": status_to_set,
|
||
"parent_file_id": parent_file_id,
|
||
"drawing": drawing
|
||
})
|
||
|
||
db.commit()
|
||
print(f"✅ 누락 도면 자재 상태 업데이트: {len(missing_drawings)}개 도면 → {status_to_set}")
|
||
except Exception as e:
|
||
print(f"누락 도면 감지 실패: {str(e)}")
|
||
db.rollback()
|
||
# 감지 실패는 업로드 성공에 영향 없음
|
||
|
||
# 리비전 업로드인 경우 메시지 다르게 표시
|
||
if parent_file_id is not None:
|
||
message = f"리비전 업로드 성공! {new_materials_count}개의 신규 자재가 추가되었습니다."
|
||
else:
|
||
message = f"업로드 성공! {materials_inserted}개 자재가 분류되었습니다."
|
||
|
||
response_data = {
|
||
"success": True,
|
||
"message": message,
|
||
"original_filename": file.filename,
|
||
"file_id": file_id,
|
||
"materials_count": materials_inserted,
|
||
"saved_materials_count": materials_inserted,
|
||
"new_materials_count": new_materials_count if parent_file_id is not None else None,
|
||
"revision": revision,
|
||
"uploaded_by": username,
|
||
"parsed_count": parsed_count
|
||
}
|
||
|
||
# 누락된 도면 정보 추가
|
||
if missing_drawings_info:
|
||
response_data["missing_drawings"] = missing_drawings_info
|
||
|
||
return response_data
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
if os.path.exists(file_path):
|
||
os.remove(file_path)
|
||
raise HTTPException(status_code=500, detail=f"파일 처리 실패: {str(e)}")
|
||
|
||
@router.get("/")
|
||
async def get_files(
|
||
job_no: Optional[str] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""파일 목록 조회"""
|
||
try:
|
||
query = """
|
||
SELECT id, filename, original_filename, bom_name, job_no, revision,
|
||
description, file_size, parsed_count, upload_date, is_active
|
||
FROM files
|
||
WHERE is_active = TRUE
|
||
"""
|
||
params = {}
|
||
|
||
if job_no:
|
||
query += " AND job_no = :job_no"
|
||
params["job_no"] = job_no
|
||
|
||
query += " ORDER BY upload_date DESC"
|
||
|
||
result = db.execute(text(query), params)
|
||
files = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"id": file.id,
|
||
"filename": file.filename,
|
||
"original_filename": file.original_filename,
|
||
"bom_name": file.bom_name,
|
||
"job_no": file.job_no,
|
||
"revision": file.revision,
|
||
"description": file.description,
|
||
"file_size": file.file_size,
|
||
"parsed_count": file.parsed_count,
|
||
"created_at": file.upload_date,
|
||
"is_active": file.is_active
|
||
}
|
||
for file in files
|
||
]
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||
|
||
@router.get("/stats")
|
||
async def get_files_stats(db: Session = Depends(get_db)):
|
||
"""파일 및 자재 통계 조회"""
|
||
try:
|
||
# 총 파일 수
|
||
files_query = text("SELECT COUNT(*) FROM files WHERE is_active = true")
|
||
total_files = db.execute(files_query).fetchone()[0]
|
||
|
||
# 총 자재 수
|
||
materials_query = text("SELECT COUNT(*) FROM materials")
|
||
total_materials = db.execute(materials_query).fetchone()[0]
|
||
|
||
# 최근 업로드 (최근 5개)
|
||
recent_query = text("""
|
||
SELECT f.original_filename, f.upload_date, f.parsed_count, j.job_name
|
||
FROM files f
|
||
LEFT JOIN jobs j ON f.job_no = j.job_no
|
||
WHERE f.is_active = true
|
||
ORDER BY f.upload_date DESC
|
||
LIMIT 5
|
||
""")
|
||
recent_uploads = db.execute(recent_query).fetchall()
|
||
|
||
return {
|
||
"success": True,
|
||
"totalFiles": total_files,
|
||
"totalMaterials": total_materials,
|
||
"recentUploads": [
|
||
{
|
||
"filename": upload.original_filename,
|
||
"created_at": upload.upload_date,
|
||
"parsed_count": upload.parsed_count or 0,
|
||
"project_name": upload.job_name or "Unknown"
|
||
}
|
||
for upload in recent_uploads
|
||
]
|
||
}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"통계 조회 실패: {str(e)}")
|
||
|
||
@router.delete("/delete/{file_id}")
|
||
async def delete_file(file_id: int, db: Session = Depends(get_db)):
|
||
"""파일 삭제"""
|
||
try:
|
||
# 자재 먼저 삭제
|
||
db.execute(text("DELETE FROM materials WHERE file_id = :file_id"), {"file_id": file_id})
|
||
|
||
# 파일 삭제
|
||
result = db.execute(text("DELETE FROM files WHERE id = :file_id"), {"file_id": file_id})
|
||
|
||
if result.rowcount == 0:
|
||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "파일이 삭제되었습니다"
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
|
||
|
||
@router.get("/materials-v2") # 완전히 새로운 엔드포인트
|
||
async def get_materials(
|
||
project_id: Optional[int] = None,
|
||
file_id: Optional[int] = None,
|
||
job_no: Optional[str] = None,
|
||
filename: Optional[str] = None,
|
||
revision: Optional[str] = None,
|
||
skip: int = 0,
|
||
limit: int = 100,
|
||
search: Optional[str] = None,
|
||
item_type: Optional[str] = None,
|
||
material_grade: Optional[str] = None,
|
||
size_spec: Optional[str] = None,
|
||
file_filter: Optional[str] = None,
|
||
sort_by: Optional[str] = None,
|
||
exclude_requested: bool = True, # 구매신청된 자재 제외 여부
|
||
group_by_spec: bool = False, # 같은 사양끼리 그룹화
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
저장된 자재 목록 조회 (job_no, filename, revision 3가지로 필터링 가능) - 신버전
|
||
"""
|
||
try:
|
||
# 로그 제거 - 과도한 출력 방지
|
||
query = """
|
||
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
|
||
m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.full_material_grade, m.line_number, m.row_number,
|
||
m.drawing_name, m.line_no, m.revision_status,
|
||
m.created_at, m.classified_category, m.classification_confidence,
|
||
m.classification_details,
|
||
m.is_verified, m.verified_by, m.verified_at,
|
||
f.original_filename, f.project_id, f.job_no, f.revision,
|
||
p.official_project_code, p.project_name,
|
||
pd.outer_diameter, pd.schedule, pd.material_spec, pd.manufacturing_method,
|
||
pd.end_preparation, pd.length_mm,
|
||
pep.end_preparation_type, pep.end_preparation_code, pep.machining_required,
|
||
pep.cutting_note, pep.clean_description as pipe_clean_description,
|
||
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.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,
|
||
-- 구매수량 계산에서 분류된 정보를 우선 사용
|
||
CASE
|
||
WHEN mpt.id IS NOT NULL THEN
|
||
CASE
|
||
WHEN mpt.description LIKE '%PIPE%' OR mpt.description LIKE '%파이프%' THEN 'PIPE'
|
||
WHEN mpt.description LIKE '%FITTING%' OR mpt.description LIKE '%피팅%' OR mpt.description LIKE '%NIPPLE%' OR mpt.description LIKE '%ELBOW%' OR mpt.description LIKE '%TEE%' OR mpt.description LIKE '%REDUCER%' THEN 'FITTING'
|
||
WHEN mpt.description LIKE '%VALVE%' OR mpt.description LIKE '%밸브%' THEN 'VALVE'
|
||
WHEN mpt.description LIKE '%FLANGE%' OR mpt.description LIKE '%플랜지%' THEN 'FLANGE'
|
||
WHEN mpt.description LIKE '%BOLT%' OR mpt.description LIKE '%볼트%' OR mpt.description LIKE '%STUD%' THEN 'BOLT'
|
||
WHEN mpt.description LIKE '%GASKET%' OR mpt.description LIKE '%가스켓%' THEN 'GASKET'
|
||
WHEN mpt.description LIKE '%INSTRUMENT%' OR mpt.description LIKE '%계기%' THEN 'INSTRUMENT'
|
||
ELSE m.classified_category
|
||
END
|
||
ELSE m.classified_category
|
||
END as final_classified_category,
|
||
-- 구매수량 계산 완료 여부
|
||
CASE WHEN mpt.id IS NOT NULL THEN true ELSE m.is_verified END as final_is_verified,
|
||
CASE WHEN mpt.id IS NOT NULL THEN 'purchase_calculation' ELSE m.verified_by END as final_verified_by
|
||
FROM materials m
|
||
LEFT JOIN files f ON m.file_id = f.id
|
||
LEFT JOIN projects p ON f.project_id = p.id
|
||
-- 구매신청된 자재 제외
|
||
LEFT JOIN purchase_request_items pri ON m.id = pri.material_id
|
||
LEFT JOIN pipe_details pd ON m.id = pd.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 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 (
|
||
m.material_hash = mpt.material_hash
|
||
AND f.job_no = mpt.job_no
|
||
AND f.revision = mpt.revision
|
||
)
|
||
WHERE 1=1
|
||
"""
|
||
params = {}
|
||
|
||
# 구매신청된 자재 제외
|
||
if exclude_requested:
|
||
query += " AND pri.material_id IS NULL"
|
||
|
||
if project_id:
|
||
query += " AND f.project_id = :project_id"
|
||
params["project_id"] = project_id
|
||
if file_id:
|
||
query += " AND m.file_id = :file_id"
|
||
params["file_id"] = file_id
|
||
if job_no:
|
||
query += " AND f.job_no = :job_no"
|
||
params["job_no"] = job_no
|
||
if filename:
|
||
query += " AND f.original_filename = :filename"
|
||
params["filename"] = filename
|
||
if revision:
|
||
query += " AND f.revision = :revision"
|
||
params["revision"] = revision
|
||
if search:
|
||
query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)"
|
||
params["search"] = f"%{search}%"
|
||
if item_type:
|
||
query += " AND m.classified_category = :item_type"
|
||
params["item_type"] = item_type
|
||
if material_grade:
|
||
query += " AND m.material_grade ILIKE :material_grade"
|
||
params["material_grade"] = f"%{material_grade}%"
|
||
if size_spec:
|
||
query += " AND m.size_spec ILIKE :size_spec"
|
||
params["size_spec"] = f"%{size_spec}%"
|
||
if file_filter:
|
||
query += " AND f.original_filename ILIKE :file_filter"
|
||
params["file_filter"] = f"%{file_filter}%"
|
||
|
||
# 정렬 처리
|
||
if sort_by:
|
||
if sort_by == "quantity_desc":
|
||
query += " ORDER BY m.quantity DESC"
|
||
elif sort_by == "quantity_asc":
|
||
query += " ORDER BY m.quantity ASC"
|
||
elif sort_by == "name_asc":
|
||
query += " ORDER BY m.original_description ASC"
|
||
elif sort_by == "name_desc":
|
||
query += " ORDER BY m.original_description DESC"
|
||
elif sort_by == "created_desc":
|
||
query += " ORDER BY m.created_at DESC"
|
||
elif sort_by == "created_asc":
|
||
query += " ORDER BY m.created_at ASC"
|
||
else:
|
||
query += " ORDER BY m.line_number ASC"
|
||
else:
|
||
query += " ORDER BY m.line_number ASC"
|
||
|
||
query += " LIMIT :limit OFFSET :skip"
|
||
params["limit"] = limit
|
||
params["skip"] = skip
|
||
|
||
result = db.execute(text(query), params)
|
||
materials = result.fetchall()
|
||
|
||
# 전체 개수 조회
|
||
count_query = """
|
||
SELECT COUNT(*) as total
|
||
FROM materials m
|
||
LEFT JOIN files f ON m.file_id = f.id
|
||
WHERE 1=1
|
||
"""
|
||
count_params = {}
|
||
|
||
if project_id:
|
||
count_query += " AND f.project_id = :project_id"
|
||
count_params["project_id"] = project_id
|
||
|
||
if file_id:
|
||
count_query += " AND m.file_id = :file_id"
|
||
count_params["file_id"] = file_id
|
||
|
||
if search:
|
||
count_query += " AND (m.original_description ILIKE :search OR m.material_grade ILIKE :search)"
|
||
count_params["search"] = f"%{search}%"
|
||
|
||
if item_type:
|
||
count_query += " AND m.classified_category = :item_type"
|
||
count_params["item_type"] = item_type
|
||
|
||
if material_grade:
|
||
count_query += " AND m.material_grade ILIKE :material_grade"
|
||
count_params["material_grade"] = f"%{material_grade}%"
|
||
|
||
if size_spec:
|
||
count_query += " AND m.size_spec ILIKE :size_spec"
|
||
count_params["size_spec"] = f"%{size_spec}%"
|
||
|
||
if file_filter:
|
||
count_query += " AND f.original_filename ILIKE :file_filter"
|
||
count_params["file_filter"] = f"%{file_filter}%"
|
||
|
||
count_result = db.execute(text(count_query), count_params)
|
||
total_count = count_result.fetchone()[0]
|
||
|
||
# 파이프 그룹핑을 위한 딕셔너리
|
||
pipe_groups = {}
|
||
# 니플 그룹핑을 위한 딕셔너리 (길이 기반)
|
||
nipple_groups = {}
|
||
# 일반 피팅 그룹핑을 위한 딕셔너리 (수량 기반)
|
||
fitting_groups = {}
|
||
# 플랜지 그룹핑을 위한 딕셔너리
|
||
flange_groups = {}
|
||
# 밸브 그룹핑을 위한 딕셔너리
|
||
valve_groups = {}
|
||
# 볼트 그룹핑을 위한 딕셔너리
|
||
bolt_groups = {}
|
||
# 가스켓 그룹핑을 위한 딕셔너리
|
||
gasket_groups = {}
|
||
# UNKNOWN 그룹핑을 위한 딕셔너리
|
||
unknown_groups = {}
|
||
|
||
# 각 자재의 상세 정보도 가져오기
|
||
material_list = []
|
||
valve_count = 0
|
||
for m in materials:
|
||
if m.classified_category == 'VALVE':
|
||
valve_count += 1
|
||
# 디버깅: 첫 번째 자재의 모든 속성 출력
|
||
if len(material_list) == 0:
|
||
# 로그 제거
|
||
pass
|
||
|
||
# 개선된 재질 정보 추출
|
||
final_category = m.final_classified_category or m.classified_category
|
||
enhanced_material_grade = extract_enhanced_material_grade(
|
||
m.original_description,
|
||
m.material_grade,
|
||
final_category
|
||
)
|
||
|
||
material_dict = {
|
||
"id": m.id,
|
||
"file_id": m.file_id,
|
||
"filename": m.original_filename,
|
||
"project_id": m.project_id,
|
||
"project_code": m.official_project_code,
|
||
"project_name": m.project_name,
|
||
"original_description": m.original_description,
|
||
"quantity": float(m.quantity) if m.quantity else 0,
|
||
"unit": m.unit,
|
||
"size_spec": m.size_spec,
|
||
"main_nom": m.main_nom,
|
||
"red_nom": m.red_nom,
|
||
"material_grade": m.full_material_grade or enhanced_material_grade,
|
||
"original_material_grade": m.material_grade,
|
||
"full_material_grade": m.full_material_grade,
|
||
"drawing_name": m.drawing_name,
|
||
"line_no": m.line_no,
|
||
"revision_status": m.revision_status or 'active',
|
||
"line_number": m.line_number,
|
||
"row_number": m.row_number,
|
||
# 구매수량 계산에서 분류된 정보를 우선 사용
|
||
"classified_category": final_category,
|
||
"classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0,
|
||
"classification_details": m.classification_details,
|
||
"is_verified": m.final_is_verified if m.final_is_verified is not None else m.is_verified,
|
||
"verified_by": m.final_verified_by or m.verified_by,
|
||
"verified_at": m.verified_at,
|
||
"purchase_confirmed": bool(m.confirmed_quantity),
|
||
"confirmed_quantity": float(m.confirmed_quantity) if m.confirmed_quantity else None,
|
||
"purchase_status": m.purchase_status,
|
||
"purchase_confirmed_by": m.confirmed_by,
|
||
"purchase_confirmed_at": m.confirmed_at,
|
||
"created_at": m.created_at
|
||
}
|
||
|
||
# 카테고리별 상세 정보 추가 (JOIN 결과 사용)
|
||
if m.classified_category == 'PIPE':
|
||
# JOIN된 결과에서 pipe_details 정보 가져오기
|
||
if hasattr(m, 'outer_diameter') and m.outer_diameter is not None:
|
||
pipe_details = {
|
||
"outer_diameter": m.outer_diameter,
|
||
"schedule": m.schedule,
|
||
"material_spec": m.material_spec,
|
||
"manufacturing_method": m.manufacturing_method,
|
||
"end_preparation": m.end_preparation,
|
||
"length_mm": float(m.length_mm) if m.length_mm else None
|
||
}
|
||
|
||
# 파이프 그룹핑 키 생성 (끝단 가공 정보 제외하고 그룹핑)
|
||
# pep 테이블에서 clean_description을 가져오거나, 없으면 직접 계산
|
||
if hasattr(m, 'pipe_clean_description') and m.pipe_clean_description:
|
||
clean_description = m.pipe_clean_description
|
||
else:
|
||
from ..services.pipe_classifier import get_purchase_pipe_description
|
||
clean_description = get_purchase_pipe_description(m.original_description)
|
||
pipe_key = f"{clean_description}|{m.size_spec}|{m.material_grade}"
|
||
|
||
# 로그 제거 - 과도한 출력 방지
|
||
|
||
if pipe_key not in pipe_groups:
|
||
pipe_groups[pipe_key] = {
|
||
"total_length_mm": 0,
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
# 개별 파이프 길이 합산 (DB에 저장된 실제 길이 사용)
|
||
if pipe_details["length_mm"]:
|
||
# ✅ DB에서 가져온 length_mm는 이미 개별 파이프의 실제 길이이므로 수량을 곱하지 않음
|
||
individual_length = float(pipe_details["length_mm"])
|
||
pipe_groups[pipe_key]["total_length_mm"] += individual_length
|
||
pipe_groups[pipe_key]["total_quantity"] += 1 # 파이프 개수는 1개씩 증가
|
||
pipe_groups[pipe_key]["materials"].append(material_dict)
|
||
|
||
# 개별 길이 정보를 pipe_details에 추가
|
||
pipe_details["individual_total_length"] = individual_length
|
||
|
||
# 구매용 깨끗한 설명도 추가
|
||
material_dict['clean_description'] = clean_description
|
||
material_dict['pipe_details'] = pipe_details
|
||
elif m.classified_category == 'FITTING':
|
||
# CAP과 PLUG 먼저 처리 (fitting_type이 없을 수 있음)
|
||
if 'CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper():
|
||
# CAP과 PLUG 그룹핑
|
||
from ..services.pipe_classifier import get_purchase_pipe_description
|
||
clean_description = get_purchase_pipe_description(m.original_description)
|
||
fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}"
|
||
|
||
if fitting_key not in fitting_groups:
|
||
fitting_groups[fitting_key] = {
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"]
|
||
fitting_groups[fitting_key]["materials"].append(material_dict)
|
||
material_dict['clean_description'] = clean_description
|
||
# JOIN된 fitting_details 데이터 직접 사용
|
||
elif hasattr(m, 'fitting_type') and m.fitting_type is not None:
|
||
# 로그 제거 - 과도한 출력 방지
|
||
|
||
fitting_details = {
|
||
"fitting_type": m.fitting_type,
|
||
"fitting_subtype": m.fitting_subtype,
|
||
"connection_method": m.connection_method,
|
||
"pressure_rating": m.pressure_rating,
|
||
"material_standard": m.material_standard,
|
||
"material_grade": m.fitting_material_grade,
|
||
"main_size": m.main_size,
|
||
"reduced_size": m.reduced_size,
|
||
"length_mm": float(m.fitting_length_mm) if m.fitting_length_mm else None,
|
||
"schedule": m.fitting_schedule
|
||
}
|
||
material_dict['fitting_details'] = fitting_details
|
||
|
||
# 니플인 경우 길이 기반 그룹핑
|
||
if 'NIPPLE' in m.original_description.upper() and m.fitting_length_mm:
|
||
# 끝단 가공 정보 제거
|
||
from ..services.pipe_classifier import get_purchase_pipe_description
|
||
clean_description = get_purchase_pipe_description(m.original_description)
|
||
nipple_key = f"{clean_description}|{m.size_spec}|{m.material_grade}|{m.fitting_length_mm}mm"
|
||
|
||
# 로그 제거 - 과도한 출력 방지
|
||
|
||
if nipple_key not in nipple_groups:
|
||
nipple_groups[nipple_key] = {
|
||
"total_length_mm": 0,
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
# 개별 니플 길이 합산 (수량 × 단위길이) - 타입 변환
|
||
individual_total_length = float(material_dict["quantity"]) * float(m.fitting_length_mm)
|
||
nipple_groups[nipple_key]["total_length_mm"] += individual_total_length
|
||
nipple_groups[nipple_key]["total_quantity"] += material_dict["quantity"]
|
||
nipple_groups[nipple_key]["materials"].append(material_dict)
|
||
|
||
# 총길이 정보를 fitting_details에 추가
|
||
fitting_details["individual_total_length"] = individual_total_length
|
||
fitting_details["is_nipple"] = True
|
||
|
||
# 구매용 깨끗한 설명도 추가
|
||
material_dict['clean_description'] = clean_description
|
||
else:
|
||
# 일반 피팅 (니플이 아닌 경우) - 수량 기반 그룹핑
|
||
from ..services.pipe_classifier import get_purchase_pipe_description
|
||
clean_description = get_purchase_pipe_description(m.original_description)
|
||
fitting_key = f"{clean_description}|{m.size_spec}|{m.material_grade}"
|
||
|
||
if fitting_key not in fitting_groups:
|
||
fitting_groups[fitting_key] = {
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
fitting_groups[fitting_key]["total_quantity"] += material_dict["quantity"]
|
||
fitting_groups[fitting_key]["materials"].append(material_dict)
|
||
|
||
# 구매용 깨끗한 설명도 추가
|
||
material_dict['clean_description'] = clean_description
|
||
elif m.classified_category == 'FLANGE':
|
||
flange_query = text("SELECT * FROM flange_details WHERE material_id = :material_id")
|
||
flange_result = db.execute(flange_query, {"material_id": m.id})
|
||
flange_detail = flange_result.fetchone()
|
||
if flange_detail:
|
||
# 개선된 플랜지 타입 (PIPE측 연결면 포함)
|
||
enhanced_flange_type = extract_enhanced_flange_type(
|
||
m.original_description,
|
||
flange_detail.flange_type
|
||
)
|
||
|
||
material_dict['flange_details'] = {
|
||
"flange_type": enhanced_flange_type, # 개선된 타입 사용
|
||
"original_flange_type": flange_detail.flange_type, # 원본 타입 보존
|
||
"facing_type": flange_detail.facing_type,
|
||
"pressure_rating": flange_detail.pressure_rating,
|
||
"material_standard": flange_detail.material_standard,
|
||
"material_grade": flange_detail.material_grade,
|
||
"size_inches": flange_detail.size_inches
|
||
}
|
||
|
||
# 플랜지 그룹핑 추가
|
||
from ..services.pipe_classifier import get_purchase_pipe_description
|
||
clean_description = get_purchase_pipe_description(m.original_description)
|
||
flange_key = f"{m.size_spec}|{m.material_grade}|{flange_detail.pressure_rating if flange_detail else ''}|{flange_detail.flange_type if flange_detail else ''}"
|
||
|
||
if flange_key not in flange_groups:
|
||
flange_groups[flange_key] = {
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
flange_groups[flange_key]["total_quantity"] += material_dict["quantity"]
|
||
flange_groups[flange_key]["materials"].append(material_dict)
|
||
material_dict['clean_description'] = clean_description
|
||
elif m.classified_category == 'GASKET':
|
||
# 이미 JOIN된 gasket_details 데이터 사용
|
||
if m.gasket_type: # gasket_details가 있는 경우
|
||
material_dict['gasket_details'] = {
|
||
"gasket_type": m.gasket_type,
|
||
"gasket_subtype": m.gasket_subtype,
|
||
"material_type": m.gasket_material_type,
|
||
"filler_material": m.filler_material,
|
||
"pressure_rating": m.gasket_pressure_rating,
|
||
"size_inches": m.gasket_size_inches,
|
||
"thickness": m.gasket_thickness,
|
||
"temperature_range": m.gasket_temperature_range,
|
||
"fire_safe": m.fire_safe
|
||
}
|
||
|
||
# 가스켓 그룹핑 - 크기, 압력, 재질로 그룹핑
|
||
# original_description에서 주요 정보 추출
|
||
description = m.original_description or ''
|
||
gasket_key = f"{m.size_spec}|{description}"
|
||
|
||
if gasket_key not in gasket_groups:
|
||
gasket_groups[gasket_key] = {
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
gasket_groups[gasket_key]["total_quantity"] += material_dict["quantity"]
|
||
gasket_groups[gasket_key]["materials"].append(material_dict)
|
||
elif m.classified_category == 'VALVE':
|
||
valve_query = text("SELECT * FROM valve_details WHERE material_id = :material_id")
|
||
valve_result = db.execute(valve_query, {"material_id": m.id})
|
||
valve_detail = valve_result.fetchone()
|
||
if valve_detail:
|
||
material_dict['valve_details'] = {
|
||
"valve_type": valve_detail.valve_type,
|
||
"valve_subtype": valve_detail.valve_subtype,
|
||
"actuator_type": valve_detail.actuator_type,
|
||
"connection_method": valve_detail.connection_method,
|
||
"pressure_rating": valve_detail.pressure_rating,
|
||
"body_material": valve_detail.body_material,
|
||
"size_inches": valve_detail.size_inches
|
||
}
|
||
|
||
# 밸브 그룹핑 추가
|
||
from ..services.pipe_classifier import get_purchase_pipe_description
|
||
clean_description = get_purchase_pipe_description(m.original_description)
|
||
valve_key = f"{clean_description}|{m.size_spec}|{m.material_grade}"
|
||
|
||
if valve_key not in valve_groups:
|
||
valve_groups[valve_key] = {
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
valve_groups[valve_key]["total_quantity"] += material_dict["quantity"]
|
||
valve_groups[valve_key]["materials"].append(material_dict)
|
||
material_dict['clean_description'] = clean_description
|
||
elif m.classified_category == 'BOLT':
|
||
bolt_query = text("SELECT * FROM bolt_details WHERE material_id = :material_id")
|
||
bolt_result = db.execute(bolt_query, {"material_id": m.id})
|
||
bolt_detail = bolt_result.fetchone()
|
||
if bolt_detail:
|
||
material_dict['bolt_details'] = {
|
||
"bolt_type": bolt_detail.bolt_type,
|
||
"thread_type": bolt_detail.thread_type,
|
||
"diameter": bolt_detail.diameter,
|
||
"length": bolt_detail.length,
|
||
"material_standard": bolt_detail.material_standard,
|
||
"material_grade": bolt_detail.material_grade,
|
||
"coating_type": bolt_detail.coating_type,
|
||
"pressure_rating": bolt_detail.pressure_rating
|
||
}
|
||
|
||
# 볼트 그룹핑 추가 - 크기, 재질, 길이로 그룹핑
|
||
# 원본 설명에서 길이 추출
|
||
import re
|
||
length_match = re.search(r'(\d+(?:\.\d+)?)\s*(?:LG|MM)', m.original_description.upper())
|
||
bolt_length = length_match.group(1) if length_match else 'UNKNOWN'
|
||
|
||
bolt_key = f"{m.size_spec}|{m.material_grade}|{bolt_length}"
|
||
|
||
if bolt_key not in bolt_groups:
|
||
bolt_groups[bolt_key] = {
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
bolt_groups[bolt_key]["total_quantity"] += material_dict["quantity"]
|
||
bolt_groups[bolt_key]["materials"].append(material_dict)
|
||
|
||
# 파이프, 니플, 일반 피팅, 플랜지가 아닌 경우만 바로 추가 (이들은 그룹핑 후 추가)
|
||
is_nipple = (m.classified_category == 'FITTING' and
|
||
('NIPPLE' in m.original_description.upper() or
|
||
(hasattr(m, 'fitting_type') and m.fitting_type == 'NIPPLE')))
|
||
|
||
# CAP과 PLUG도 일반 피팅으로 처리
|
||
is_cap_or_plug = (m.classified_category == 'FITTING' and
|
||
('CAP' in m.original_description.upper() or 'PLUG' in m.original_description.upper()))
|
||
|
||
is_general_fitting = (m.classified_category == 'FITTING' and not is_nipple and
|
||
((hasattr(m, 'fitting_type') and m.fitting_type is not None) or is_cap_or_plug))
|
||
|
||
is_flange = (m.classified_category == 'FLANGE')
|
||
|
||
is_valve = (m.classified_category == 'VALVE')
|
||
|
||
is_bolt = (m.classified_category == 'BOLT')
|
||
|
||
is_gasket = (m.classified_category == 'GASKET')
|
||
|
||
# UNKNOWN 카테고리 그룹핑 처리
|
||
if m.classified_category == 'UNKNOWN':
|
||
unknown_key = m.original_description or 'UNKNOWN'
|
||
|
||
if unknown_key not in unknown_groups:
|
||
unknown_groups[unknown_key] = {
|
||
"total_quantity": 0,
|
||
"materials": []
|
||
}
|
||
|
||
unknown_groups[unknown_key]["total_quantity"] += material_dict["quantity"]
|
||
unknown_groups[unknown_key]["materials"].append(material_dict)
|
||
elif m.classified_category != 'PIPE' and not is_nipple and not is_general_fitting and not is_flange and not is_valve and not is_bolt and not is_gasket:
|
||
material_list.append(material_dict)
|
||
|
||
# 파이프 그룹별로 대표 파이프 하나만 추가 (그룹핑된 정보로)
|
||
for pipe_key, group_info in pipe_groups.items():
|
||
if group_info["materials"]:
|
||
# 그룹의 첫 번째 파이프를 대표로 사용
|
||
representative_pipe = group_info["materials"][0].copy()
|
||
|
||
# 그룹핑된 정보로 업데이트
|
||
representative_pipe['quantity'] = group_info["total_quantity"]
|
||
representative_pipe['original_description'] = representative_pipe['clean_description'] # 깨끗한 설명 사용
|
||
|
||
if 'pipe_details' in representative_pipe:
|
||
representative_pipe['pipe_details']['total_length_mm'] = group_info["total_length_mm"]
|
||
representative_pipe['pipe_details']['pipe_count'] = group_info["total_quantity"] # ✅ pipe_count 추가
|
||
representative_pipe['pipe_details']['group_total_quantity'] = group_info["total_quantity"]
|
||
# 평균 단위 길이 계산
|
||
if group_info["total_quantity"] > 0:
|
||
representative_pipe['pipe_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"]
|
||
|
||
# 개별 파이프 길이 정보 수집
|
||
individual_pipes = []
|
||
for mat in group_info["materials"]:
|
||
if 'pipe_details' in mat and mat['pipe_details'].get('length_mm'):
|
||
individual_pipes.append({
|
||
'length': mat['pipe_details']['length_mm'],
|
||
'quantity': 1,
|
||
'id': mat['id']
|
||
})
|
||
representative_pipe['pipe_details']['individual_pipes'] = individual_pipes
|
||
|
||
# 그룹화된 모든 자재 ID 저장
|
||
representative_pipe['grouped_ids'] = [mat['id'] for mat in group_info["materials"]]
|
||
|
||
material_list.append(representative_pipe)
|
||
|
||
# 니플 그룹별로 대표 니플 하나만 추가 (그룹핑된 정보로)
|
||
try:
|
||
for nipple_key, group_info in nipple_groups.items():
|
||
if group_info["materials"]:
|
||
# 그룹의 첫 번째 니플을 대표로 사용
|
||
representative_nipple = group_info["materials"][0].copy()
|
||
|
||
# 그룹핑된 정보로 업데이트
|
||
representative_nipple['quantity'] = group_info["total_quantity"]
|
||
representative_nipple['original_description'] = representative_nipple.get('clean_description', representative_nipple['original_description']) # 깨끗한 설명 사용
|
||
|
||
if 'fitting_details' in representative_nipple:
|
||
representative_nipple['fitting_details']['total_length_mm'] = group_info["total_length_mm"]
|
||
representative_nipple['fitting_details']['group_total_quantity'] = group_info["total_quantity"]
|
||
# 평균 단위 길이 계산
|
||
if group_info["total_quantity"] > 0:
|
||
representative_nipple['fitting_details']['avg_length_mm'] = group_info["total_length_mm"] / group_info["total_quantity"]
|
||
|
||
material_list.append(representative_nipple)
|
||
except Exception as nipple_error:
|
||
# 로그 제거
|
||
# 니플 그룹핑 실패시에도 계속 진행
|
||
pass
|
||
|
||
# 일반 피팅 그룹별로 대표 피팅 하나만 추가 (그룹핑된 정보로)
|
||
try:
|
||
for fitting_key, group_info in fitting_groups.items():
|
||
if group_info["materials"]:
|
||
representative_fitting = group_info["materials"][0].copy()
|
||
representative_fitting['quantity'] = group_info["total_quantity"]
|
||
representative_fitting['original_description'] = representative_fitting.get('clean_description', representative_fitting['original_description'])
|
||
|
||
if 'fitting_details' in representative_fitting:
|
||
representative_fitting['fitting_details']['group_total_quantity'] = group_info["total_quantity"]
|
||
material_list.append(representative_fitting)
|
||
except Exception as fitting_error:
|
||
# 로그 제거
|
||
# 피팅 그룹핑 실패시에도 계속 진행
|
||
pass
|
||
|
||
# 플랜지 그룹별로 대표 플랜지 하나만 추가 (그룹핑된 정보로)
|
||
try:
|
||
for flange_key, group_info in flange_groups.items():
|
||
if group_info["materials"]:
|
||
representative_flange = group_info["materials"][0].copy()
|
||
representative_flange['quantity'] = group_info["total_quantity"]
|
||
# original_description은 그대로 유지 (SCH 정보 보존)
|
||
# representative_flange['original_description'] = representative_flange.get('clean_description', representative_flange['original_description'])
|
||
|
||
if 'flange_details' in representative_flange:
|
||
representative_flange['flange_details']['group_total_quantity'] = group_info["total_quantity"]
|
||
material_list.append(representative_flange)
|
||
except Exception as flange_error:
|
||
# 플랜지 그룹핑 실패시에도 계속 진행
|
||
pass
|
||
|
||
# 밸브 그룹별로 대표 밸브 하나만 추가 (그룹핑된 정보로)
|
||
print(f"DEBUG: 전체 밸브 수: {valve_count}, valve_groups 수: {len(valve_groups)}")
|
||
try:
|
||
for valve_key, group_info in valve_groups.items():
|
||
if group_info["materials"]:
|
||
representative_valve = group_info["materials"][0].copy()
|
||
representative_valve['quantity'] = group_info["total_quantity"]
|
||
|
||
if 'valve_details' in representative_valve:
|
||
representative_valve['valve_details']['group_total_quantity'] = group_info["total_quantity"]
|
||
material_list.append(representative_valve)
|
||
print(f"DEBUG: 밸브 추가됨 - {valve_key}, 수량: {group_info['total_quantity']}")
|
||
except Exception as valve_error:
|
||
print(f"ERROR: 밸브 그룹핑 실패 - {valve_error}")
|
||
# 밸브 그룹핑 실패시에도 계속 진행
|
||
pass
|
||
|
||
# 볼트 그룹별로 대표 볼트 하나만 추가 (그룹핑된 정보로)
|
||
print(f"DEBUG: bolt_groups 수: {len(bolt_groups)}")
|
||
try:
|
||
for bolt_key, group_info in bolt_groups.items():
|
||
if group_info["materials"]:
|
||
representative_bolt = group_info["materials"][0].copy()
|
||
representative_bolt['quantity'] = group_info["total_quantity"]
|
||
|
||
if 'bolt_details' in representative_bolt:
|
||
representative_bolt['bolt_details']['group_total_quantity'] = group_info["total_quantity"]
|
||
material_list.append(representative_bolt)
|
||
print(f"DEBUG: 볼트 추가됨 - {bolt_key}, 수량: {group_info['total_quantity']}")
|
||
except Exception as bolt_error:
|
||
print(f"ERROR: 볼트 그룹핑 실패 - {bolt_error}")
|
||
# 볼트 그룹핑 실패시에도 계속 진행
|
||
pass
|
||
|
||
# 가스켓 그룹별로 대표 가스켓 하나만 추가 (그룹핑된 정보로)
|
||
print(f"DEBUG: gasket_groups 수: {len(gasket_groups)}")
|
||
try:
|
||
for gasket_key, group_info in gasket_groups.items():
|
||
if group_info["materials"]:
|
||
representative_gasket = group_info["materials"][0].copy()
|
||
representative_gasket['quantity'] = group_info["total_quantity"]
|
||
|
||
if 'gasket_details' in representative_gasket:
|
||
representative_gasket['gasket_details']['group_total_quantity'] = group_info["total_quantity"]
|
||
material_list.append(representative_gasket)
|
||
print(f"DEBUG: 가스켓 추가됨 - {gasket_key}, 수량: {group_info['total_quantity']}")
|
||
except Exception as gasket_error:
|
||
print(f"ERROR: 가스켓 그룹핑 실패 - {gasket_error}")
|
||
# 가스켓 그룹핑 실패시에도 계속 진행
|
||
pass
|
||
|
||
# UNKNOWN 그룹별로 대표 항목 하나만 추가 (그룹핑된 정보로)
|
||
print(f"DEBUG: unknown_groups 수: {len(unknown_groups)}")
|
||
try:
|
||
for unknown_key, group_info in unknown_groups.items():
|
||
if group_info["materials"]:
|
||
representative_unknown = group_info["materials"][0].copy()
|
||
representative_unknown['quantity'] = group_info["total_quantity"]
|
||
material_list.append(representative_unknown)
|
||
print(f"DEBUG: UNKNOWN 추가됨 - {unknown_key[:50]}, 수량: {group_info['total_quantity']}")
|
||
except Exception as unknown_error:
|
||
print(f"ERROR: UNKNOWN 그룹핑 실패 - {unknown_error}")
|
||
# UNKNOWN 그룹핑 실패시에도 계속 진행
|
||
pass
|
||
|
||
return {
|
||
"success": True,
|
||
"total_count": total_count,
|
||
"returned_count": len(materials),
|
||
"skip": skip,
|
||
"limit": limit,
|
||
"materials": material_list
|
||
}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"자재 조회 실패: {str(e)}")
|
||
|
||
@router.get("/materials/summary")
|
||
async def get_materials_summary(
|
||
project_id: Optional[int] = None,
|
||
file_id: Optional[int] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""자재 요약 통계"""
|
||
try:
|
||
query = """
|
||
SELECT
|
||
COUNT(*) as total_items,
|
||
COUNT(DISTINCT m.original_description) as unique_descriptions,
|
||
COUNT(DISTINCT m.size_spec) as unique_sizes,
|
||
COUNT(DISTINCT m.material_grade) as unique_materials,
|
||
SUM(m.quantity) as total_quantity,
|
||
AVG(m.quantity) as avg_quantity,
|
||
MIN(m.created_at) as earliest_upload,
|
||
MAX(m.created_at) as latest_upload
|
||
FROM materials m
|
||
LEFT JOIN files f ON m.file_id = f.id
|
||
WHERE 1=1
|
||
"""
|
||
|
||
params = {}
|
||
|
||
if project_id:
|
||
query += " AND f.project_id = :project_id"
|
||
params["project_id"] = project_id
|
||
|
||
if file_id:
|
||
query += " AND m.file_id = :file_id"
|
||
params["file_id"] = file_id
|
||
|
||
result = db.execute(text(query), params)
|
||
summary = result.fetchone()
|
||
|
||
return {
|
||
"success": True,
|
||
"summary": {
|
||
"total_items": summary.total_items,
|
||
"unique_descriptions": summary.unique_descriptions,
|
||
"unique_sizes": summary.unique_sizes,
|
||
"unique_materials": summary.unique_materials,
|
||
"total_quantity": float(summary.total_quantity) if summary.total_quantity else 0,
|
||
"avg_quantity": round(float(summary.avg_quantity), 2) if summary.avg_quantity else 0,
|
||
"earliest_upload": summary.earliest_upload,
|
||
"latest_upload": summary.latest_upload
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"요약 조회 실패: {str(e)}")
|
||
|
||
@router.get("/materials/compare-revisions")
|
||
async def compare_revisions(
|
||
job_no: str,
|
||
filename: str,
|
||
old_revision: str,
|
||
new_revision: str,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
리비전 간 자재 비교
|
||
"""
|
||
try:
|
||
# 기존 리비전 자재 조회
|
||
old_materials_query = text("""
|
||
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
||
m.material_grade, m.classified_category, m.classification_confidence,
|
||
m.main_nom, m.red_nom, m.drawing_name, m.line_no
|
||
FROM materials m
|
||
JOIN files f ON m.file_id = f.id
|
||
WHERE f.job_no = :job_no
|
||
AND f.original_filename = :filename
|
||
AND f.revision = :old_revision
|
||
""")
|
||
|
||
old_result = db.execute(old_materials_query, {
|
||
"job_no": job_no,
|
||
"filename": filename,
|
||
"old_revision": old_revision
|
||
})
|
||
old_materials = old_result.fetchall()
|
||
|
||
# 새 리비전 자재 조회
|
||
new_materials_query = text("""
|
||
SELECT m.original_description, m.quantity, m.unit, m.size_spec,
|
||
m.material_grade, m.classified_category, m.classification_confidence,
|
||
m.main_nom, m.red_nom, m.drawing_name, m.line_no
|
||
FROM materials m
|
||
JOIN files f ON m.file_id = f.id
|
||
WHERE f.job_no = :job_no
|
||
AND f.original_filename = :filename
|
||
AND f.revision = :new_revision
|
||
""")
|
||
|
||
new_result = db.execute(new_materials_query, {
|
||
"job_no": job_no,
|
||
"filename": filename,
|
||
"new_revision": new_revision
|
||
})
|
||
new_materials = new_result.fetchall()
|
||
|
||
# 자재 키 생성 함수 (도면번호 + 자재 정보)
|
||
def create_material_key(material):
|
||
# 도면번호가 있으면 도면번호 + 자재 설명으로 고유 키 생성
|
||
# (같은 도면에 여러 자재가 있을 수 있으므로)
|
||
if hasattr(material, 'drawing_name') and material.drawing_name:
|
||
return f"{material.drawing_name}|{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||
elif hasattr(material, 'line_no') and material.line_no:
|
||
return f"{material.line_no}|{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||
else:
|
||
# 도면번호 없으면 기존 방식 (설명 + 크기 + 재질)
|
||
return f"{material.original_description}|{material.size_spec or ''}|{material.material_grade or ''}"
|
||
|
||
# 기존 자재를 딕셔너리로 변환 (수량 합산)
|
||
old_materials_dict = {}
|
||
for material in old_materials:
|
||
key = create_material_key(material)
|
||
if key in old_materials_dict:
|
||
# 동일한 자재가 있으면 수량 합산
|
||
old_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0
|
||
else:
|
||
old_materials_dict[key] = {
|
||
"original_description": material.original_description,
|
||
"quantity": float(material.quantity) if material.quantity else 0,
|
||
"unit": material.unit,
|
||
"size_spec": material.size_spec,
|
||
"material_grade": material.material_grade,
|
||
"classified_category": material.classified_category,
|
||
"classification_confidence": material.classification_confidence,
|
||
"main_nom": material.main_nom,
|
||
"red_nom": material.red_nom,
|
||
"drawing_name": material.drawing_name,
|
||
"line_no": material.line_no
|
||
}
|
||
|
||
# 새 자재를 딕셔너리로 변환 (수량 합산)
|
||
new_materials_dict = {}
|
||
for material in new_materials:
|
||
key = create_material_key(material)
|
||
if key in new_materials_dict:
|
||
# 동일한 자재가 있으면 수량 합산
|
||
new_materials_dict[key]["quantity"] += float(material.quantity) if material.quantity else 0
|
||
else:
|
||
new_materials_dict[key] = {
|
||
"original_description": material.original_description,
|
||
"quantity": float(material.quantity) if material.quantity else 0,
|
||
"unit": material.unit,
|
||
"size_spec": material.size_spec,
|
||
"material_grade": material.material_grade,
|
||
"classified_category": material.classified_category,
|
||
"classification_confidence": material.classification_confidence,
|
||
"main_nom": material.main_nom,
|
||
"red_nom": material.red_nom,
|
||
"drawing_name": material.drawing_name,
|
||
"line_no": material.line_no
|
||
}
|
||
|
||
# 변경 사항 분석
|
||
all_keys = set(old_materials_dict.keys()) | set(new_materials_dict.keys())
|
||
|
||
added_items = []
|
||
removed_items = []
|
||
changed_items = []
|
||
|
||
for key in all_keys:
|
||
old_item = old_materials_dict.get(key)
|
||
new_item = new_materials_dict.get(key)
|
||
|
||
if old_item and not new_item:
|
||
# 삭제된 항목
|
||
removed_items.append({
|
||
"key": key,
|
||
"item": old_item,
|
||
"change_type": "removed"
|
||
})
|
||
elif not old_item and new_item:
|
||
# 추가된 항목
|
||
added_items.append({
|
||
"key": key,
|
||
"item": new_item,
|
||
"change_type": "added"
|
||
})
|
||
elif old_item and new_item:
|
||
# 변경 사항 감지: 수량, 재질, 크기, 카테고리 등
|
||
changes_detected = []
|
||
|
||
old_qty = old_item["quantity"]
|
||
new_qty = new_item["quantity"]
|
||
qty_diff = new_qty - old_qty
|
||
|
||
# 1. 수량 변경 확인
|
||
if abs(qty_diff) > 0.001:
|
||
changes_detected.append({
|
||
"type": "quantity",
|
||
"old_value": old_qty,
|
||
"new_value": new_qty,
|
||
"diff": qty_diff
|
||
})
|
||
|
||
# 2. 재질 변경 확인
|
||
if old_item.get("material_grade") != new_item.get("material_grade"):
|
||
changes_detected.append({
|
||
"type": "material",
|
||
"old_value": old_item.get("material_grade", "-"),
|
||
"new_value": new_item.get("material_grade", "-")
|
||
})
|
||
|
||
# 3. 크기 변경 확인
|
||
if old_item.get("main_nom") != new_item.get("main_nom") or old_item.get("size_spec") != new_item.get("size_spec"):
|
||
changes_detected.append({
|
||
"type": "size",
|
||
"old_value": old_item.get("main_nom") or old_item.get("size_spec", "-"),
|
||
"new_value": new_item.get("main_nom") or new_item.get("size_spec", "-")
|
||
})
|
||
|
||
# 4. 카테고리 변경 확인 (자재 종류가 바뀜)
|
||
if old_item.get("classified_category") != new_item.get("classified_category"):
|
||
changes_detected.append({
|
||
"type": "category",
|
||
"old_value": old_item.get("classified_category", "-"),
|
||
"new_value": new_item.get("classified_category", "-")
|
||
})
|
||
|
||
# 변경사항이 있으면 changed_items에 추가
|
||
if changes_detected:
|
||
# 변경 유형 결정
|
||
has_qty_change = any(c["type"] == "quantity" for c in changes_detected)
|
||
has_spec_change = any(c["type"] in ["material", "size", "category"] for c in changes_detected)
|
||
|
||
if has_spec_change:
|
||
change_type = "specification_changed" # 자재 사양 변경
|
||
elif has_qty_change:
|
||
change_type = "quantity_changed" # 수량만 변경
|
||
else:
|
||
change_type = "modified"
|
||
|
||
changed_items.append({
|
||
"key": key,
|
||
"old_item": old_item,
|
||
"new_item": new_item,
|
||
"changes": changes_detected,
|
||
"change_type": change_type,
|
||
"quantity_change": qty_diff if has_qty_change else 0,
|
||
"drawing_name": new_item.get("drawing_name") or old_item.get("drawing_name"),
|
||
"line_no": new_item.get("line_no") or old_item.get("line_no")
|
||
})
|
||
|
||
# 분류별 통계
|
||
def calculate_category_stats(items):
|
||
stats = {}
|
||
for item in items:
|
||
category = item.get("item", {}).get("classified_category", "OTHER")
|
||
if category not in stats:
|
||
stats[category] = {"count": 0, "total_quantity": 0}
|
||
stats[category]["count"] += 1
|
||
stats[category]["total_quantity"] += item.get("item", {}).get("quantity", 0)
|
||
return stats
|
||
|
||
added_stats = calculate_category_stats(added_items)
|
||
removed_stats = calculate_category_stats(removed_items)
|
||
changed_stats = calculate_category_stats(changed_items)
|
||
|
||
# 누락된 도면 감지
|
||
old_drawings = set()
|
||
new_drawings = set()
|
||
|
||
for material in old_materials:
|
||
if hasattr(material, 'drawing_name') and material.drawing_name:
|
||
old_drawings.add(material.drawing_name)
|
||
elif hasattr(material, 'line_no') and material.line_no:
|
||
old_drawings.add(material.line_no)
|
||
|
||
for material in new_materials:
|
||
if hasattr(material, 'drawing_name') and material.drawing_name:
|
||
new_drawings.add(material.drawing_name)
|
||
elif hasattr(material, 'line_no') and material.line_no:
|
||
new_drawings.add(material.line_no)
|
||
|
||
missing_drawings = old_drawings - new_drawings # 이전에는 있었는데 새 파일에 없는 도면
|
||
new_only_drawings = new_drawings - old_drawings # 새로 추가된 도면
|
||
|
||
# 누락된 도면의 자재 목록
|
||
missing_drawing_materials = []
|
||
if missing_drawings:
|
||
for material in old_materials:
|
||
drawing = getattr(material, 'drawing_name', None) or getattr(material, 'line_no', None)
|
||
if drawing in missing_drawings:
|
||
missing_drawing_materials.append({
|
||
"drawing_name": drawing,
|
||
"description": material.original_description,
|
||
"quantity": float(material.quantity) if material.quantity else 0,
|
||
"category": material.classified_category,
|
||
"size": material.main_nom or material.size_spec,
|
||
"material_grade": material.material_grade
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"comparison": {
|
||
"old_revision": old_revision,
|
||
"new_revision": new_revision,
|
||
"filename": filename,
|
||
"job_no": job_no,
|
||
"summary": {
|
||
"added_count": len(added_items),
|
||
"removed_count": len(removed_items),
|
||
"changed_count": len(changed_items),
|
||
"total_changes": len(added_items) + len(removed_items) + len(changed_items),
|
||
"missing_drawings_count": len(missing_drawings),
|
||
"new_drawings_count": len(new_only_drawings)
|
||
},
|
||
"missing_drawings": {
|
||
"drawings": list(missing_drawings),
|
||
"materials": missing_drawing_materials,
|
||
"count": len(missing_drawings),
|
||
"requires_confirmation": len(missing_drawings) > 0
|
||
},
|
||
"changes": {
|
||
"added": added_items,
|
||
"removed": removed_items,
|
||
"changed": changed_items
|
||
},
|
||
"category_stats": {
|
||
"added": added_stats,
|
||
"removed": removed_stats,
|
||
"changed": changed_stats
|
||
}
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"리비전 비교 실패: {str(e)}")
|
||
|
||
@router.get("/pipe-details")
|
||
async def get_pipe_details(
|
||
file_id: Optional[int] = None,
|
||
job_no: Optional[str] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
PIPE 상세 정보 조회
|
||
"""
|
||
try:
|
||
query = """
|
||
SELECT pd.*, f.original_filename, f.job_no, f.revision,
|
||
m.original_description, m.quantity, m.unit
|
||
FROM pipe_details pd
|
||
LEFT JOIN files f ON pd.file_id = f.id
|
||
LEFT JOIN materials m ON pd.file_id = m.file_id
|
||
AND m.classified_category = 'PIPE'
|
||
WHERE 1=1
|
||
"""
|
||
params = {}
|
||
|
||
if file_id:
|
||
query += " AND pd.file_id = :file_id"
|
||
params["file_id"] = file_id
|
||
|
||
if job_no:
|
||
query += " AND f.job_no = :job_no"
|
||
params["job_no"] = job_no
|
||
|
||
query += " ORDER BY pd.created_at DESC"
|
||
|
||
result = db.execute(text(query), params)
|
||
pipe_details = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"id": pd.id,
|
||
"file_id": pd.file_id,
|
||
"original_filename": pd.original_filename,
|
||
"job_no": pd.job_no,
|
||
"revision": pd.revision,
|
||
"original_description": pd.original_description,
|
||
"quantity": pd.quantity,
|
||
"unit": pd.unit,
|
||
"material_spec": pd.material_spec,
|
||
"manufacturing_method": pd.manufacturing_method,
|
||
"end_preparation": pd.end_preparation,
|
||
"schedule": pd.schedule,
|
||
"outer_diameter": pd.outer_diameter,
|
||
"length_mm": pd.length_mm,
|
||
"created_at": pd.created_at,
|
||
"updated_at": pd.updated_at
|
||
}
|
||
for pd in pipe_details
|
||
]
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"PIPE 상세 정보 조회 실패: {str(e)}")
|
||
|
||
@router.get("/fitting-details")
|
||
async def get_fitting_details(
|
||
file_id: Optional[int] = None,
|
||
job_no: Optional[str] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
FITTING 상세 정보 조회
|
||
"""
|
||
try:
|
||
query = """
|
||
SELECT fd.*, f.original_filename, f.job_no, f.revision,
|
||
m.original_description, m.quantity, m.unit
|
||
FROM fitting_details fd
|
||
LEFT JOIN files f ON fd.file_id = f.id
|
||
LEFT JOIN materials m ON fd.material_id = m.id
|
||
WHERE 1=1
|
||
"""
|
||
params = {}
|
||
|
||
if file_id:
|
||
query += " AND fd.file_id = :file_id"
|
||
params["file_id"] = file_id
|
||
|
||
if job_no:
|
||
query += " AND f.job_no = :job_no"
|
||
params["job_no"] = job_no
|
||
|
||
query += " ORDER BY fd.created_at DESC"
|
||
|
||
result = db.execute(text(query), params)
|
||
fitting_details = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"id": fd.id,
|
||
"file_id": fd.file_id,
|
||
"fitting_type": fd.fitting_type,
|
||
"fitting_subtype": fd.fitting_subtype,
|
||
"connection_method": fd.connection_method,
|
||
"pressure_rating": fd.pressure_rating,
|
||
"material_standard": fd.material_standard,
|
||
"material_grade": fd.material_grade,
|
||
"main_size": fd.main_size,
|
||
"reduced_size": fd.reduced_size,
|
||
"classification_confidence": fd.classification_confidence,
|
||
"original_description": fd.original_description,
|
||
"quantity": fd.quantity
|
||
}
|
||
for fd in fitting_details
|
||
]
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"FITTING 상세 정보 조회 실패: {str(e)}")
|
||
|
||
@router.get("/valve-details")
|
||
async def get_valve_details(
|
||
file_id: Optional[int] = None,
|
||
job_no: Optional[str] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
VALVE 상세 정보 조회
|
||
"""
|
||
try:
|
||
query = """
|
||
SELECT vd.*, f.original_filename, f.job_no, f.revision,
|
||
m.original_description, m.quantity, m.unit
|
||
FROM valve_details vd
|
||
LEFT JOIN files f ON vd.file_id = f.id
|
||
LEFT JOIN materials m ON vd.material_id = m.id
|
||
WHERE 1=1
|
||
"""
|
||
params = {}
|
||
|
||
if file_id:
|
||
query += " AND vd.file_id = :file_id"
|
||
params["file_id"] = file_id
|
||
|
||
if job_no:
|
||
query += " AND f.job_no = :job_no"
|
||
params["job_no"] = job_no
|
||
|
||
query += " ORDER BY vd.created_at DESC"
|
||
|
||
result = db.execute(text(query), params)
|
||
valve_details = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"id": vd.id,
|
||
"file_id": vd.file_id,
|
||
"valve_type": vd.valve_type,
|
||
"valve_subtype": vd.valve_subtype,
|
||
"actuator_type": vd.actuator_type,
|
||
"connection_method": vd.connection_method,
|
||
"pressure_rating": vd.pressure_rating,
|
||
"body_material": vd.body_material,
|
||
"size_inches": vd.size_inches,
|
||
"fire_safe": vd.fire_safe,
|
||
"classification_confidence": vd.classification_confidence,
|
||
"original_description": vd.original_description,
|
||
"quantity": vd.quantity
|
||
}
|
||
for vd in valve_details
|
||
]
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"VALVE 상세 정보 조회 실패: {str(e)}")
|
||
|
||
@router.get("/{file_id}/user-requirements")
|
||
async def get_user_requirements(
|
||
file_id: int,
|
||
job_no: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
사용자 요구사항 조회
|
||
"""
|
||
try:
|
||
query = """
|
||
SELECT ur.*, f.original_filename, f.job_no, f.revision,
|
||
rt.type_name, rt.category
|
||
FROM user_requirements ur
|
||
LEFT JOIN files f ON ur.file_id = f.id
|
||
LEFT JOIN requirement_types rt ON ur.requirement_type = rt.type_code
|
||
WHERE 1=1
|
||
"""
|
||
params = {}
|
||
|
||
if file_id:
|
||
query += " AND ur.file_id = :file_id"
|
||
params["file_id"] = file_id
|
||
|
||
if job_no:
|
||
query += " AND f.job_no = :job_no"
|
||
params["job_no"] = job_no
|
||
|
||
if status:
|
||
query += " AND ur.status = :status"
|
||
params["status"] = status
|
||
|
||
query += " ORDER BY ur.created_at DESC"
|
||
|
||
result = db.execute(text(query), params)
|
||
requirements = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"id": req.id,
|
||
"file_id": req.file_id,
|
||
"material_id": req.material_id,
|
||
"original_filename": req.original_filename,
|
||
"job_no": req.job_no,
|
||
"revision": req.revision,
|
||
"requirement_type": req.requirement_type,
|
||
"type_name": req.type_name,
|
||
"category": req.category,
|
||
"requirement_title": req.requirement_title,
|
||
"requirement_description": req.requirement_description,
|
||
"requirement_spec": req.requirement_spec,
|
||
"status": req.status,
|
||
"priority": req.priority,
|
||
"assigned_to": req.assigned_to,
|
||
"due_date": req.due_date,
|
||
"created_at": req.created_at,
|
||
"updated_at": req.updated_at
|
||
}
|
||
for req in requirements
|
||
]
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"사용자 요구사항 조회 실패: {str(e)}")
|
||
|
||
class UserRequirementCreate(BaseModel):
|
||
file_id: int
|
||
material_id: Optional[int] = None
|
||
requirement_type: str
|
||
requirement_title: str
|
||
requirement_description: Optional[str] = None
|
||
requirement_spec: Optional[str] = None
|
||
priority: str = "NORMAL"
|
||
assigned_to: Optional[str] = None
|
||
due_date: Optional[str] = None
|
||
|
||
@router.post("/user-requirements")
|
||
async def create_user_requirement(
|
||
requirement: UserRequirementCreate,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
사용자 요구사항 생성
|
||
"""
|
||
try:
|
||
insert_query = text("""
|
||
INSERT INTO user_requirements (
|
||
file_id, material_id, requirement_type, requirement_title, requirement_description,
|
||
requirement_spec, priority, assigned_to, due_date
|
||
)
|
||
VALUES (
|
||
:file_id, :material_id, :requirement_type, :requirement_title, :requirement_description,
|
||
:requirement_spec, :priority, :assigned_to, :due_date
|
||
)
|
||
RETURNING id
|
||
""")
|
||
|
||
result = db.execute(insert_query, {
|
||
"file_id": requirement.file_id,
|
||
"material_id": requirement.material_id,
|
||
"requirement_type": requirement.requirement_type,
|
||
"requirement_title": requirement.requirement_title,
|
||
"requirement_description": requirement.requirement_description,
|
||
"requirement_spec": requirement.requirement_spec,
|
||
"priority": requirement.priority,
|
||
"assigned_to": requirement.assigned_to,
|
||
"due_date": requirement.due_date
|
||
})
|
||
|
||
requirement_id = result.fetchone()[0]
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "요구사항이 생성되었습니다",
|
||
"requirement_id": requirement_id
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
raise HTTPException(status_code=500, detail=f"요구사항 생성 실패: {str(e)}")
|
||
|
||
@router.delete("/user-requirements")
|
||
async def delete_user_requirements(
|
||
file_id: Optional[int] = None,
|
||
material_id: Optional[int] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
사용자 요구사항 삭제 (파일별 또는 자재별)
|
||
"""
|
||
try:
|
||
if file_id:
|
||
# 파일별 삭제
|
||
delete_query = text("DELETE FROM user_requirements WHERE file_id = :file_id")
|
||
result = db.execute(delete_query, {"file_id": file_id})
|
||
deleted_count = result.rowcount
|
||
elif material_id:
|
||
# 자재별 삭제
|
||
delete_query = text("DELETE FROM user_requirements WHERE material_id = :material_id")
|
||
result = db.execute(delete_query, {"material_id": material_id})
|
||
deleted_count = result.rowcount
|
||
else:
|
||
raise HTTPException(status_code=400, detail="file_id 또는 material_id가 필요합니다")
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"{deleted_count}개의 요구사항이 삭제되었습니다",
|
||
"deleted_count": deleted_count
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
raise HTTPException(status_code=500, detail=f"요구사항 삭제 실패: {str(e)}")
|
||
|
||
@router.post("/materials/{material_id}/verify")
|
||
async def verify_material_classification(
|
||
material_id: int,
|
||
request: Request,
|
||
verified_category: Optional[str] = None,
|
||
db: Session = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
자재 분류 결과 검증
|
||
"""
|
||
try:
|
||
username = current_user.get('username', 'unknown')
|
||
|
||
# 자재 존재 확인
|
||
material_query = text("SELECT * FROM materials WHERE id = :material_id")
|
||
material_result = db.execute(material_query, {"material_id": material_id})
|
||
material = material_result.fetchone()
|
||
|
||
if not material:
|
||
raise HTTPException(status_code=404, detail="자재를 찾을 수 없습니다")
|
||
|
||
# 검증 정보 업데이트
|
||
update_query = text("""
|
||
UPDATE materials
|
||
SET is_verified = TRUE,
|
||
verified_by = :username,
|
||
verified_at = CURRENT_TIMESTAMP,
|
||
classified_category = COALESCE(:verified_category, classified_category)
|
||
WHERE id = :material_id
|
||
""")
|
||
|
||
db.execute(update_query, {
|
||
"material_id": material_id,
|
||
"username": username,
|
||
"verified_category": verified_category
|
||
})
|
||
|
||
# 활동 로그 기록
|
||
try:
|
||
from ..services.activity_logger import log_activity_from_request
|
||
log_activity_from_request(
|
||
db, request, username,
|
||
"MATERIAL_VERIFY",
|
||
f"자재 분류 검증: {material.original_description}"
|
||
)
|
||
except Exception as e:
|
||
print(f"활동 로그 기록 실패: {str(e)}")
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "자재 분류가 검증되었습니다",
|
||
"material_id": material_id,
|
||
"verified_by": username
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
raise HTTPException(status_code=500, detail=f"자재 검증 실패: {str(e)}")
|
||
|
||
@router.put("/materials/{material_id}/update-classification")
|
||
async def update_material_classification(
|
||
material_id: int,
|
||
request: Request,
|
||
classified_category: str = Form(...),
|
||
classified_subcategory: str = Form(None),
|
||
material_grade: str = Form(None),
|
||
schedule: str = Form(None),
|
||
size_spec: str = Form(None),
|
||
db: Session = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""BOM 관리 페이지에서 사용자가 분류를 수정하는 API"""
|
||
try:
|
||
username = current_user.get("username", "unknown")
|
||
|
||
# 자재 존재 확인
|
||
check_query = text("SELECT id, original_description FROM materials WHERE id = :material_id")
|
||
result = db.execute(check_query, {"material_id": material_id})
|
||
material = result.fetchone()
|
||
|
||
if not material:
|
||
raise HTTPException(status_code=404, detail="자재를 찾을 수 없습니다")
|
||
|
||
# 분류 정보 업데이트
|
||
update_query = text("""
|
||
UPDATE materials
|
||
SET classified_category = :classified_category,
|
||
classified_subcategory = :classified_subcategory,
|
||
material_grade = :material_grade,
|
||
schedule = :schedule,
|
||
size_spec = :size_spec,
|
||
is_verified = true,
|
||
verified_by = :verified_by,
|
||
verified_at = NOW(),
|
||
updated_at = NOW()
|
||
WHERE id = :material_id
|
||
""")
|
||
|
||
db.execute(update_query, {
|
||
"material_id": material_id,
|
||
"classified_category": classified_category,
|
||
"classified_subcategory": classified_subcategory or "",
|
||
"material_grade": material_grade or "",
|
||
"schedule": schedule or "",
|
||
"size_spec": size_spec or "",
|
||
"verified_by": username
|
||
})
|
||
|
||
db.commit()
|
||
|
||
# 활동 로그 기록
|
||
await log_activity_from_request(
|
||
request,
|
||
db,
|
||
"material_classification_update",
|
||
f"자재 분류 수정: {material.original_description} -> {classified_category}",
|
||
{"material_id": material_id, "category": classified_category}
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "자재 분류가 성공적으로 업데이트되었습니다",
|
||
"material_id": material_id,
|
||
"classified_category": classified_category
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
print(f"자재 분류 업데이트 실패: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"자재 분류 업데이트 실패: {str(e)}")
|
||
|
||
@router.post("/materials/confirm-purchase")
|
||
async def confirm_material_purchase_api(
|
||
request: Request,
|
||
job_no: str = Query(...),
|
||
revision: str = Query(...),
|
||
confirmed_by: str = Query("user"),
|
||
confirmations_data: List[Dict] = Body(...),
|
||
db: Session = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""자재 구매수량 확정 API (프론트엔드 호환)"""
|
||
try:
|
||
|
||
# 입력 데이터 검증
|
||
if not job_no or not revision:
|
||
raise HTTPException(status_code=400, detail="Job 번호와 리비전은 필수입니다")
|
||
|
||
if not confirmations_data:
|
||
raise HTTPException(status_code=400, detail="확정할 자재가 없습니다")
|
||
|
||
# 각 확정 항목 검증
|
||
for i, confirmation in enumerate(confirmations_data):
|
||
if not confirmation.get("material_hash"):
|
||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 material_hash가 없습니다")
|
||
|
||
confirmed_qty = confirmation.get("confirmed_quantity")
|
||
if confirmed_qty is None or confirmed_qty < 0:
|
||
raise HTTPException(status_code=400, detail=f"{i+1}번째 항목의 확정 수량이 유효하지 않습니다")
|
||
|
||
confirmed_items = []
|
||
|
||
for confirmation in confirmations_data:
|
||
# 발주 추적 테이블에 저장/업데이트
|
||
upsert_query = text("""
|
||
INSERT INTO material_purchase_tracking (
|
||
job_no, material_hash, revision, description, size_spec, unit,
|
||
bom_quantity, calculated_quantity, confirmed_quantity,
|
||
purchase_status, supplier_name, unit_price, total_price,
|
||
confirmed_by, confirmed_at
|
||
)
|
||
SELECT
|
||
:job_no, m.material_hash, :revision, m.original_description,
|
||
m.size_spec, m.unit, m.quantity, :calculated_qty, :confirmed_qty,
|
||
'CONFIRMED', :supplier_name, :unit_price, :total_price,
|
||
:confirmed_by, CURRENT_TIMESTAMP
|
||
FROM materials m
|
||
WHERE m.material_hash = :material_hash
|
||
AND m.file_id = (
|
||
SELECT id FROM files
|
||
WHERE job_no = :job_no AND revision = :revision
|
||
ORDER BY upload_date DESC LIMIT 1
|
||
)
|
||
LIMIT 1
|
||
ON CONFLICT (job_no, material_hash, revision)
|
||
DO UPDATE SET
|
||
confirmed_quantity = :confirmed_qty,
|
||
purchase_status = 'CONFIRMED',
|
||
supplier_name = :supplier_name,
|
||
unit_price = :unit_price,
|
||
total_price = :total_price,
|
||
confirmed_by = :confirmed_by,
|
||
confirmed_at = CURRENT_TIMESTAMP,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
RETURNING id, description, confirmed_quantity
|
||
""")
|
||
|
||
calculated_qty = confirmation.get("calculated_quantity", confirmation["confirmed_quantity"])
|
||
total_price = confirmation["confirmed_quantity"] * confirmation.get("unit_price", 0)
|
||
|
||
result = db.execute(upsert_query, {
|
||
"job_no": job_no,
|
||
"revision": revision,
|
||
"material_hash": confirmation["material_hash"],
|
||
"calculated_qty": calculated_qty,
|
||
"confirmed_qty": confirmation["confirmed_quantity"],
|
||
"supplier_name": confirmation.get("supplier_name", ""),
|
||
"unit_price": confirmation.get("unit_price", 0),
|
||
"total_price": total_price,
|
||
"confirmed_by": confirmed_by
|
||
})
|
||
|
||
confirmed_item = result.fetchone()
|
||
if confirmed_item:
|
||
confirmed_items.append({
|
||
"id": confirmed_item[0],
|
||
"description": confirmed_item[1],
|
||
"confirmed_quantity": confirmed_item[2]
|
||
})
|
||
|
||
db.commit()
|
||
|
||
# 활동 로그 기록
|
||
await log_activity_from_request(
|
||
request,
|
||
db,
|
||
"material_purchase_confirm",
|
||
f"구매수량 확정: {job_no} {revision} - {len(confirmed_items)}개 품목",
|
||
{"job_no": job_no, "revision": revision, "items_count": len(confirmed_items)}
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"{len(confirmed_items)}개 품목의 구매수량이 확정되었습니다",
|
||
"job_no": job_no,
|
||
"revision": revision,
|
||
"confirmed_items": confirmed_items
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
print(f"구매수량 확정 실패: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"구매수량 확정 실패: {str(e)}")
|
||
|
||
|
||
@router.put("/{file_id}")
|
||
async def update_file_info(
|
||
file_id: int,
|
||
bom_name: Optional[str] = Body(None),
|
||
description: Optional[str] = Body(None),
|
||
db: Session = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""파일 정보 수정"""
|
||
try:
|
||
# 파일 존재 확인 및 관련 정보 조회
|
||
file_query = text("""
|
||
SELECT id, bom_name, description, job_no, original_filename
|
||
FROM files
|
||
WHERE id = :file_id
|
||
""")
|
||
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||
|
||
if not file_result:
|
||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||
|
||
# 업데이트할 필드 준비
|
||
update_fields = []
|
||
params = {}
|
||
|
||
if bom_name is not None:
|
||
update_fields.append("bom_name = :bom_name")
|
||
params["bom_name"] = bom_name
|
||
|
||
if description is not None:
|
||
update_fields.append("description = :description")
|
||
params["description"] = description
|
||
|
||
if not update_fields:
|
||
raise HTTPException(status_code=400, detail="수정할 정보가 없습니다")
|
||
|
||
# BOM 이름 수정인 경우, 같은 job_no의 모든 리비전을 함께 업데이트
|
||
if bom_name is not None and file_result.job_no:
|
||
# 같은 job_no의 모든 파일 업데이트
|
||
update_query = text(f"""
|
||
UPDATE files
|
||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||
WHERE job_no = :job_no
|
||
""")
|
||
params["job_no"] = file_result.job_no
|
||
|
||
else:
|
||
# 단일 파일만 업데이트
|
||
update_query = text(f"""
|
||
UPDATE files
|
||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = :file_id
|
||
""")
|
||
params["file_id"] = file_id
|
||
|
||
db.execute(update_query, params)
|
||
db.commit()
|
||
|
||
# 활동 로그 기록 - 간단하게 처리
|
||
logger.info(f"파일 정보 수정 완료: 사용자={current_user['username']}, 파일ID={file_id}, BOM명={bom_name or 'N/A'}")
|
||
|
||
return {"message": "파일 정보가 성공적으로 수정되었습니다"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"파일 정보 수정 실패: {str(e)}")
|
||
db.rollback()
|
||
raise HTTPException(status_code=500, detail=f"파일 정보 수정 실패: {str(e)}")
|
||
|
||
|
||
@router.get("/{file_id}/export-excel")
|
||
async def export_materials_to_excel(
|
||
file_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""자재 목록을 엑셀로 내보내기"""
|
||
try:
|
||
# 파일 정보 조회
|
||
file_query = text("""
|
||
SELECT f.id, f.original_filename, f.bom_name, f.job_no, f.revision,
|
||
p.project_name, p.official_project_code
|
||
FROM files f
|
||
LEFT JOIN projects p ON f.project_id = p.id
|
||
WHERE f.id = :file_id
|
||
""")
|
||
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||
|
||
if not file_result:
|
||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||
|
||
# 자재 목록 조회
|
||
materials_query = text("""
|
||
SELECT
|
||
m.line_number,
|
||
m.original_description,
|
||
m.quantity,
|
||
m.unit,
|
||
m.size_spec,
|
||
m.main_nom,
|
||
m.red_nom,
|
||
m.material_grade,
|
||
m.classified_category,
|
||
m.classification_confidence,
|
||
m.is_verified,
|
||
m.verified_by,
|
||
m.created_at
|
||
FROM materials m
|
||
WHERE m.file_id = :file_id
|
||
ORDER BY m.line_number ASC
|
||
""")
|
||
|
||
materials_result = db.execute(materials_query, {"file_id": file_id}).fetchall()
|
||
|
||
# 엑셀 데이터 준비
|
||
excel_data = []
|
||
for material in materials_result:
|
||
excel_data.append({
|
||
"라인번호": material.line_number,
|
||
"품명": material.original_description,
|
||
"수량": material.quantity,
|
||
"단위": material.unit,
|
||
"사이즈": material.size_spec,
|
||
"주요NOM": material.main_nom,
|
||
"축소NOM": material.red_nom,
|
||
"재질등급": material.material_grade,
|
||
"분류": material.classified_category,
|
||
"신뢰도": material.classification_confidence,
|
||
"검증여부": "검증완료" if material.is_verified else "미검증",
|
||
"검증자": material.verified_by or "",
|
||
"등록일": material.created_at.strftime("%Y-%m-%d %H:%M:%S") if material.created_at else ""
|
||
})
|
||
|
||
# 활동 로그 기록
|
||
activity_logger = ActivityLogger(db)
|
||
activity_logger.log_activity(
|
||
username=current_user["username"],
|
||
activity_type="엑셀 내보내기",
|
||
activity_description=f"자재 목록 엑셀 내보내기: {file_result.original_filename}",
|
||
target_type="file",
|
||
target_id=file_id,
|
||
metadata={
|
||
"file_name": file_result.original_filename,
|
||
"bom_name": file_result.bom_name,
|
||
"job_no": file_result.job_no,
|
||
"revision": file_result.revision,
|
||
"materials_count": len(excel_data)
|
||
}
|
||
)
|
||
|
||
return {
|
||
"message": "엑셀 내보내기 준비 완료",
|
||
"file_info": {
|
||
"filename": file_result.original_filename,
|
||
"bom_name": file_result.bom_name,
|
||
"job_no": file_result.job_no,
|
||
"revision": file_result.revision,
|
||
"project_name": file_result.project_name
|
||
},
|
||
"materials": excel_data,
|
||
"total_count": len(excel_data)
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"엑셀 내보내기 실패: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"엑셀 내보내기 실패: {str(e)}")
|
||
|
||
|
||
@router.get("/{file_id}/materials/view-log")
|
||
async def log_materials_view(
|
||
file_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""자재 목록 조회 로그 기록"""
|
||
try:
|
||
# 파일 정보 조회
|
||
file_query = text("SELECT original_filename, bom_name FROM files WHERE id = :file_id")
|
||
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||
|
||
if file_result:
|
||
# 활동 로그 기록
|
||
activity_logger = ActivityLogger(db)
|
||
activity_logger.log_activity(
|
||
username=current_user["username"],
|
||
activity_type="자재 목록 조회",
|
||
activity_description=f"자재 목록 조회: {file_result.original_filename}",
|
||
target_type="file",
|
||
target_id=file_id,
|
||
metadata={
|
||
"file_name": file_result.original_filename,
|
||
"bom_name": file_result.bom_name
|
||
}
|
||
)
|
||
|
||
return {"message": "조회 로그 기록 완료"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"조회 로그 기록 실패: {str(e)}")
|
||
return {"message": "조회 로그 기록 실패"}
|
||
|
||
|
||
@router.post("/{file_id}/process-missing-drawings")
|
||
async def process_missing_drawings(
|
||
file_id: int,
|
||
action: str = "delete",
|
||
drawings: List[str] = [],
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
누락된 도면 처리
|
||
- action='delete': 누락된 도면의 이전 리비전 자재 삭제 처리
|
||
- action='keep': 누락된 도면의 이전 리비전 자재 유지
|
||
"""
|
||
try:
|
||
# 현재 파일 정보 확인
|
||
from pydantic import BaseModel
|
||
|
||
class MissingDrawingsRequest(BaseModel):
|
||
action: str
|
||
drawings: List[str]
|
||
|
||
# parent_file_id 조회는 files 테이블에 없을 수 있으므로 revision으로 판단
|
||
file_query = text("""
|
||
SELECT f.id, f.job_no, f.revision, f.original_filename
|
||
FROM files f
|
||
WHERE f.id = :file_id
|
||
""")
|
||
file_result = db.execute(file_query, {"file_id": file_id}).fetchone()
|
||
|
||
if not file_result:
|
||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||
|
||
# 이전 리비전 파일 찾기
|
||
prev_revision_query = text("""
|
||
SELECT id FROM files
|
||
WHERE job_no = :job_no
|
||
AND original_filename = :filename
|
||
AND revision < :current_revision
|
||
ORDER BY revision DESC
|
||
LIMIT 1
|
||
""")
|
||
prev_result = db.execute(prev_revision_query, {
|
||
"job_no": file_result.job_no,
|
||
"filename": file_result.original_filename,
|
||
"current_revision": file_result.revision
|
||
}).fetchone()
|
||
|
||
if not prev_result:
|
||
raise HTTPException(status_code=400, detail="이전 리비전을 찾을 수 없습니다")
|
||
|
||
parent_file_id = prev_result.id
|
||
|
||
if action == "delete":
|
||
# 누락된 도면의 자재를 deleted_not_purchased 상태로 변경
|
||
for drawing in drawings:
|
||
update_query = text("""
|
||
UPDATE materials
|
||
SET revision_status = 'deleted_not_purchased'
|
||
WHERE file_id = :parent_file_id
|
||
AND (drawing_name = :drawing OR line_no = :drawing)
|
||
""")
|
||
db.execute(update_query, {
|
||
"parent_file_id": parent_file_id,
|
||
"drawing": drawing
|
||
})
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"{len(drawings)}개 도면의 자재가 삭제 처리되었습니다",
|
||
"action": "deleted",
|
||
"drawings_count": len(drawings)
|
||
}
|
||
else:
|
||
# keep - 이미 처리됨 (inventory 또는 active 상태 유지)
|
||
return {
|
||
"success": True,
|
||
"message": "누락된 도면의 자재가 유지됩니다",
|
||
"action": "kept",
|
||
"drawings_count": len(drawings)
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
raise HTTPException(status_code=500, detail=f"도면 처리 실패: {str(e)}")
|
||
|
||
@router.post("/save-excel")
|
||
async def save_excel_file(
|
||
request: ExcelSaveRequest,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
엑셀 파일을 서버에 저장하고 메타데이터를 기록
|
||
"""
|
||
try:
|
||
# 엑셀 저장 디렉토리 생성
|
||
excel_dir = Path("uploads/excel_exports")
|
||
excel_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 파일 경로 생성
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
safe_filename = f"{request.category}_{timestamp}_{request.filename}"
|
||
file_path = excel_dir / safe_filename
|
||
|
||
# 엑셀 파일 생성 (openpyxl 사용)
|
||
import openpyxl
|
||
from openpyxl.styles import Font, PatternFill, Alignment
|
||
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = request.category
|
||
|
||
# 헤더 설정
|
||
headers = ['TAGNO', '품목명', '수량', '통화구분', '단가', '크기', '압력등급', '스케줄',
|
||
'재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3',
|
||
'관리항목4', '납기일(YYYY-MM-DD)']
|
||
|
||
# 헤더 스타일
|
||
header_font = Font(bold=True, color="FFFFFF")
|
||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||
|
||
# 헤더 작성
|
||
for col, header in enumerate(headers, 1):
|
||
cell = ws.cell(row=1, column=col, value=header)
|
||
cell.font = header_font
|
||
cell.fill = header_fill
|
||
cell.alignment = header_alignment
|
||
|
||
# 데이터 작성
|
||
for row_idx, material in enumerate(request.materials, 2):
|
||
# 기본 데이터
|
||
data = [
|
||
'', # TAGNO
|
||
request.category, # 품목명
|
||
material.get('quantity', 0), # 수량
|
||
'KRW', # 통화구분
|
||
1, # 단가
|
||
material.get('size_spec', '-'), # 크기
|
||
'-', # 압력등급
|
||
material.get('schedule', '-'), # 스케줄
|
||
material.get('full_material_grade', material.get('material_grade', '-')), # 재질
|
||
'-', # 상세내역
|
||
material.get('user_requirement', ''), # 사용자요구
|
||
'', '', '', '', # 관리항목들
|
||
datetime.now().strftime('%Y-%m-%d') # 납기일
|
||
]
|
||
|
||
# 데이터 입력
|
||
for col, value in enumerate(data, 1):
|
||
ws.cell(row=row_idx, column=col, value=value)
|
||
|
||
# 엑셀 파일 저장
|
||
wb.save(file_path)
|
||
|
||
# 데이터베이스에 메타데이터 저장 (테이블이 없으면 무시)
|
||
try:
|
||
save_query = text("""
|
||
INSERT INTO excel_exports (
|
||
file_id, category, filename, file_path,
|
||
material_count, created_by, created_at
|
||
) VALUES (
|
||
:file_id, :category, :filename, :file_path,
|
||
:material_count, :user_id, :created_at
|
||
)
|
||
""")
|
||
|
||
db.execute(save_query, {
|
||
"file_id": request.file_id,
|
||
"category": request.category,
|
||
"filename": safe_filename,
|
||
"file_path": str(file_path),
|
||
"material_count": len(request.materials),
|
||
"user_id": current_user.get('id'),
|
||
"created_at": datetime.now()
|
||
})
|
||
|
||
db.commit()
|
||
except Exception as db_error:
|
||
logger.warning(f"엑셀 메타데이터 저장 실패 (파일은 저장됨): {str(db_error)}")
|
||
# 메타데이터 저장 실패해도 파일은 저장되었으므로 계속 진행
|
||
|
||
logger.info(f"엑셀 파일 저장 완료: {safe_filename}")
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "엑셀 파일이 성공적으로 저장되었습니다.",
|
||
"filename": safe_filename,
|
||
"file_path": str(file_path),
|
||
"material_count": len(request.materials)
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"엑셀 파일 저장 실패: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=f"엑셀 파일 저장 실패: {str(e)}")
|
||
|
||
@router.get("/excel-exports")
|
||
async def get_excel_exports(
|
||
file_id: Optional[int] = None,
|
||
category: Optional[str] = None,
|
||
current_user: dict = Depends(get_current_user),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
저장된 엑셀 파일 목록 조회
|
||
"""
|
||
try:
|
||
query = text("""
|
||
SELECT
|
||
id, file_id, category, filename, file_path,
|
||
material_count, created_by, created_at
|
||
FROM excel_exports
|
||
WHERE 1=1
|
||
""")
|
||
|
||
params = {}
|
||
|
||
if file_id:
|
||
query = text(str(query) + " AND file_id = :file_id")
|
||
params["file_id"] = file_id
|
||
|
||
if category:
|
||
query = text(str(query) + " AND category = :category")
|
||
params["category"] = category
|
||
|
||
query = text(str(query) + " ORDER BY created_at DESC")
|
||
|
||
result = db.execute(query, params).fetchall()
|
||
|
||
exports = []
|
||
for row in result:
|
||
exports.append({
|
||
"id": row.id,
|
||
"file_id": row.file_id,
|
||
"category": row.category,
|
||
"filename": row.filename,
|
||
"file_path": row.file_path,
|
||
"material_count": row.material_count,
|
||
"created_by": row.created_by,
|
||
"created_at": row.created_at.isoformat() if row.created_at else None
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"exports": exports
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"엑셀 내보내기 목록 조회 실패: {str(e)}")
|
||
return {
|
||
"success": False,
|
||
"exports": [],
|
||
"message": "엑셀 내보내기 목록을 조회할 수 없습니다."
|
||
} |