✅ 백엔드 구조 개선: - DatabaseService: 공통 DB 쿼리 로직 통합 - FileUploadService: 파일 업로드 로직 모듈화 및 트랜잭션 관리 개선 - 서비스 레이어 패턴 도입으로 코드 재사용성 향상 ✅ 프론트엔드 컴포넌트 개선: - LoadingSpinner, ErrorMessage, ConfirmDialog 공통 컴포넌트 생성 - 재사용 가능한 컴포넌트 라이브러리 구축 - deprecated/backup 파일들 완전 제거 ✅ 성능 최적화: - optimize_database.py: 핵심 DB 인덱스 자동 생성 - 쿼리 최적화 및 통계 업데이트 자동화 - VACUUM ANALYZE 자동 실행 ✅ 코드 정리: - 개별 SQL 마이그레이션 파일들을 legacy/ 폴더로 정리 - 중복된 마이그레이션 스크립트 정리 - 깔끔하고 체계적인 프로젝트 구조 완성 ✅ 자동 마이그레이션 시스템 강화: - complete_migrate.py: SQLAlchemy 기반 완전한 마이그레이션 - analyze_and_fix_schema.py: 백엔드 코드 분석 기반 스키마 수정 - fix_missing_tables.py: 누락된 테이블/컬럼 자동 생성 - start.sh: 배포 시 자동 실행 순서 최적화
This commit is contained in:
@@ -34,11 +34,15 @@ class File(Base):
|
||||
filename = Column(String(255), nullable=False)
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
file_path = Column(String(500), nullable=False)
|
||||
job_no = Column(String(50)) # 작업 번호
|
||||
revision = Column(String(20), default='Rev.0')
|
||||
bom_name = Column(String(200)) # BOM 이름
|
||||
description = Column(Text) # 파일 설명
|
||||
upload_date = Column(DateTime, default=datetime.utcnow)
|
||||
uploaded_by = Column(String(100))
|
||||
file_type = Column(String(10))
|
||||
file_size = Column(Integer)
|
||||
parsed_count = Column(Integer) # 파싱된 자재 수
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# 관계 설정
|
||||
@@ -51,22 +55,40 @@ class Material(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_id = Column(Integer, ForeignKey("files.id"))
|
||||
line_number = Column(Integer)
|
||||
row_number = Column(Integer) # 업로드 시 행 번호
|
||||
original_description = Column(Text, nullable=False)
|
||||
classified_category = Column(String(50))
|
||||
classified_subcategory = Column(String(100))
|
||||
material_grade = Column(String(50))
|
||||
full_material_grade = Column(Text) # 전체 재질명 (ASTM A312 TP304 등)
|
||||
schedule = Column(String(20))
|
||||
size_spec = Column(String(50))
|
||||
main_nom = Column(String(50)) # 주 사이즈 (4", 150A 등)
|
||||
red_nom = Column(String(50)) # 축소 사이즈 (Reducing 피팅/플랜지용)
|
||||
quantity = Column(Numeric(10, 3), nullable=False)
|
||||
unit = Column(String(10), nullable=False)
|
||||
# length = Column(Numeric(10, 3)) # 임시로 주석 처리
|
||||
length = Column(Numeric(10, 3)) # 길이 정보
|
||||
drawing_name = Column(String(100))
|
||||
area_code = Column(String(20))
|
||||
line_no = Column(String(50))
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
classification_details = Column(JSON) # 분류 상세 정보 (JSON)
|
||||
is_verified = Column(Boolean, default=False)
|
||||
verified_by = Column(String(50))
|
||||
verified_at = Column(DateTime)
|
||||
|
||||
# 구매 관련 필드
|
||||
purchase_confirmed = Column(Boolean, default=False)
|
||||
confirmed_quantity = Column(Numeric(10, 3))
|
||||
purchase_status = Column(String(20))
|
||||
purchase_confirmed_by = Column(String(100))
|
||||
purchase_confirmed_at = Column(DateTime)
|
||||
|
||||
# 리비전 관리 필드
|
||||
revision_status = Column(String(20)) # 'new', 'changed', 'inventory', 'deleted_not_purchased'
|
||||
material_hash = Column(String(64)) # 자재 비교용 해시
|
||||
normalized_description = Column(Text) # 정규화된 설명
|
||||
|
||||
drawing_reference = Column(String(100))
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -450,3 +472,349 @@ class MaterialTubingMapping(Base):
|
||||
# 관계 설정
|
||||
material = relationship("Material", backref="tubing_mappings")
|
||||
tubing_product = relationship("TubingProduct", back_populates="material_mappings")
|
||||
|
||||
class SupportDetails(Base):
|
||||
__tablename__ = "support_details"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
|
||||
# 서포트 정보
|
||||
support_type = Column(String(50))
|
||||
support_subtype = Column(String(100))
|
||||
load_rating = Column(String(50))
|
||||
load_capacity = Column(String(50))
|
||||
material_standard = Column(String(50))
|
||||
material_grade = Column(String(50))
|
||||
pipe_size = Column(String(50))
|
||||
|
||||
# 치수 정보
|
||||
length_mm = Column(Numeric(10, 2))
|
||||
width_mm = Column(Numeric(10, 2))
|
||||
height_mm = Column(Numeric(10, 2))
|
||||
|
||||
# 분류 신뢰도
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class PurchaseRequestItems(Base):
|
||||
__tablename__ = "purchase_request_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
request_id = Column(String(50), nullable=False) # 구매신청 ID
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
|
||||
# 수량 정보
|
||||
quantity = Column(Integer, nullable=False)
|
||||
unit = Column(String(10), nullable=False)
|
||||
user_requirement = Column(Text)
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
|
||||
class FittingDetails(Base):
|
||||
__tablename__ = "fitting_details"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
|
||||
# 피팅 정보
|
||||
fitting_type = Column(String(50))
|
||||
fitting_subtype = Column(String(100))
|
||||
connection_type = Column(String(50))
|
||||
material_standard = Column(String(50))
|
||||
material_grade = Column(String(50))
|
||||
nominal_size = Column(String(50))
|
||||
wall_thickness = Column(String(50))
|
||||
|
||||
# 치수 정보
|
||||
length_mm = Column(Numeric(10, 2))
|
||||
width_mm = Column(Numeric(10, 2))
|
||||
height_mm = Column(Numeric(10, 2))
|
||||
|
||||
# 분류 신뢰도
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class FlangeDetails(Base):
|
||||
__tablename__ = "flange_details"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
|
||||
# 플랜지 정보
|
||||
flange_type = Column(String(50))
|
||||
flange_subtype = Column(String(100))
|
||||
pressure_rating = Column(String(50))
|
||||
material_standard = Column(String(50))
|
||||
material_grade = Column(String(50))
|
||||
nominal_size = Column(String(50))
|
||||
|
||||
# 치수 정보
|
||||
outer_diameter = Column(Numeric(10, 2))
|
||||
thickness = Column(Numeric(10, 2))
|
||||
|
||||
# 분류 신뢰도
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class ValveDetails(Base):
|
||||
__tablename__ = "valve_details"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
|
||||
# 밸브 정보
|
||||
valve_type = Column(String(50))
|
||||
valve_subtype = Column(String(100))
|
||||
connection_type = Column(String(50))
|
||||
actuation_type = Column(String(50))
|
||||
material_standard = Column(String(50))
|
||||
material_grade = Column(String(50))
|
||||
nominal_size = Column(String(50))
|
||||
pressure_rating = Column(String(50))
|
||||
|
||||
# 분류 신뢰도
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class GasketDetails(Base):
|
||||
__tablename__ = "gasket_details"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
|
||||
# 가스켓 정보
|
||||
gasket_type = Column(String(50))
|
||||
material_standard = Column(String(50))
|
||||
material_grade = Column(String(50))
|
||||
nominal_size = Column(String(50))
|
||||
pressure_rating = Column(String(50))
|
||||
filler_material = Column(String(50))
|
||||
thickness = Column(Numeric(10, 2))
|
||||
|
||||
# 분류 신뢰도
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class BoltDetails(Base):
|
||||
__tablename__ = "bolt_details"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
|
||||
# 볼트 정보
|
||||
bolt_type = Column(String(50))
|
||||
material_standard = Column(String(50))
|
||||
material_grade = Column(String(50))
|
||||
thread_size = Column(String(50))
|
||||
length = Column(Numeric(10, 2))
|
||||
pressure_rating = Column(String(50))
|
||||
|
||||
# 분류 신뢰도
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class InstrumentDetails(Base):
|
||||
__tablename__ = "instrument_details"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
|
||||
# 계기 정보
|
||||
instrument_type = Column(String(50))
|
||||
instrument_subtype = Column(String(100))
|
||||
connection_type = Column(String(50))
|
||||
material_standard = Column(String(50))
|
||||
material_grade = Column(String(50))
|
||||
nominal_size = Column(String(50))
|
||||
|
||||
# 분류 신뢰도
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class PurchaseRequests(Base):
|
||||
__tablename__ = "purchase_requests"
|
||||
|
||||
request_id = Column(String(50), primary_key=True, index=True)
|
||||
request_no = Column(String(100), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
job_no = Column(String(50), nullable=False)
|
||||
category = Column(String(50))
|
||||
material_count = Column(Integer)
|
||||
excel_file_path = Column(String(500))
|
||||
requested_by = Column(Integer, ForeignKey("users.user_id"))
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File")
|
||||
requested_by_user = relationship("User", foreign_keys=[requested_by])
|
||||
|
||||
class Jobs(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
job_no = Column(String(50), unique=True, nullable=False)
|
||||
job_name = Column(String(200))
|
||||
status = Column(String(20), default='active')
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class PipeEndPreparations(Base):
|
||||
__tablename__ = "pipe_end_preparations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
end_prep_type = Column(String(50))
|
||||
end_prep_standard = Column(String(50))
|
||||
classification_confidence = Column(Numeric(3, 2))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class MaterialPurchaseTracking(Base):
|
||||
__tablename__ = "material_purchase_tracking"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"), nullable=False)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
purchase_status = Column(String(20))
|
||||
requested_quantity = Column(Integer)
|
||||
confirmed_quantity = Column(Integer)
|
||||
purchase_date = Column(DateTime)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
file = relationship("File")
|
||||
|
||||
class ExcelExports(Base):
|
||||
__tablename__ = "excel_exports"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_id = Column(Integer, ForeignKey("files.id"), nullable=False)
|
||||
export_type = Column(String(50))
|
||||
file_path = Column(String(500))
|
||||
exported_by = Column(Integer, ForeignKey("users.user_id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File")
|
||||
exported_by_user = relationship("User", foreign_keys=[exported_by])
|
||||
|
||||
class UserActivityLogs(Base):
|
||||
__tablename__ = "user_activity_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.user_id"))
|
||||
activity_type = Column(String(50))
|
||||
activity_description = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
user = relationship("User")
|
||||
|
||||
class ExcelExportHistory(Base):
|
||||
__tablename__ = "excel_export_history"
|
||||
|
||||
export_id = Column(String(50), primary_key=True, index=True)
|
||||
file_id = Column(Integer, ForeignKey("files.id"))
|
||||
job_no = Column(String(50))
|
||||
exported_by = Column(Integer, ForeignKey("users.user_id"))
|
||||
export_date = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
file = relationship("File")
|
||||
exported_by_user = relationship("User", foreign_keys=[exported_by])
|
||||
|
||||
class ExportedMaterials(Base):
|
||||
__tablename__ = "exported_materials"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
export_id = Column(String(50), ForeignKey("excel_export_history.export_id"))
|
||||
material_id = Column(Integer, ForeignKey("materials.id"))
|
||||
quantity = Column(Integer)
|
||||
status = Column(String(20))
|
||||
|
||||
# 관계 설정
|
||||
export_history = relationship("ExcelExportHistory")
|
||||
material = relationship("Material")
|
||||
|
||||
class PurchaseStatusHistory(Base):
|
||||
__tablename__ = "purchase_status_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
material_id = Column(Integer, ForeignKey("materials.id"))
|
||||
old_status = Column(String(20))
|
||||
new_status = Column(String(20))
|
||||
changed_by = Column(Integer, ForeignKey("users.user_id"))
|
||||
changed_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
material = relationship("Material")
|
||||
changed_by_user = relationship("User", foreign_keys=[changed_by])
|
||||
|
||||
@@ -338,48 +338,68 @@ async def upload_file(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
# 🎯 트랜잭션 오류 방지: 완전한 트랜잭션 초기화
|
||||
"""
|
||||
파일 업로드 API - 리팩토링된 서비스 레이어 사용
|
||||
"""
|
||||
from ..services.file_upload_service import FileUploadService
|
||||
|
||||
upload_service = FileUploadService(db)
|
||||
|
||||
try:
|
||||
# 1. 현재 트랜잭션 완전 롤백
|
||||
db.rollback()
|
||||
print("🔄 1단계: 이전 트랜잭션 롤백 완료")
|
||||
# 1. 업로드 요청 검증
|
||||
upload_service.validate_upload_request(file, job_no)
|
||||
|
||||
# 2. 세션 상태 초기화
|
||||
db.close()
|
||||
print("🔄 2단계: 세션 닫기 완료")
|
||||
# 2. 파일 저장
|
||||
unique_filename, file_path = upload_service.save_uploaded_file(file)
|
||||
|
||||
# 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)}"
|
||||
# 3. 파일 레코드 생성
|
||||
file_record = upload_service.create_file_record(
|
||||
filename=unique_filename,
|
||||
original_filename=file.filename,
|
||||
file_path=str(file_path),
|
||||
job_no=job_no,
|
||||
revision=revision,
|
||||
bom_name=bom_name,
|
||||
file_size=file.size or 0,
|
||||
parsed_count=0, # 임시값, 파싱 후 업데이트
|
||||
uploaded_by=current_user.get('username', 'unknown'),
|
||||
parent_file_id=parent_file_id
|
||||
)
|
||||
|
||||
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)
|
||||
# 로그 제거
|
||||
|
||||
# 4. 자재 데이터 처리
|
||||
processing_result = upload_service.process_materials_data(
|
||||
file_path=file_path,
|
||||
file_id=file_record.id,
|
||||
job_no=job_no,
|
||||
revision=revision,
|
||||
parent_file_id=parent_file_id
|
||||
)
|
||||
|
||||
# 5. 파일 레코드 업데이트 (파싱된 자재 수)
|
||||
file_record.parsed_count = processing_result['materials_count']
|
||||
db.commit()
|
||||
|
||||
logger.info(f"File upload completed: {file_record.id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "파일 업로드 및 처리가 완료되었습니다.",
|
||||
"file_id": file_record.id,
|
||||
"filename": file_record.filename,
|
||||
"materials_count": processing_result['materials_count'],
|
||||
"classification_results": processing_result['classification_results']
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# HTTP 예외는 그대로 전달
|
||||
upload_service.cleanup_failed_upload(file_path if 'file_path' in locals() else None)
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||
|
||||
try:
|
||||
# 로그 제거
|
||||
materials_data = parse_file_data(str(file_path))
|
||||
# 기타 예외 처리
|
||||
db.rollback()
|
||||
upload_service.cleanup_failed_upload(file_path if 'file_path' in locals() else None)
|
||||
logger.error(f"File upload failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"파일 업로드 실패: {str(e)}")
|
||||
parsed_count = len(materials_data)
|
||||
# 로그 제거
|
||||
|
||||
@@ -1861,26 +1881,87 @@ async def get_files_stats(db: Session = Depends(get_db)):
|
||||
|
||||
@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:
|
||||
# 파일 존재 확인
|
||||
file_check = db.execute(text("SELECT id FROM files WHERE id = :file_id"), {"file_id": file_id}).fetchone()
|
||||
if not file_check:
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
|
||||
# 1. 자재 관련 상세 테이블들 먼저 삭제 (외래키 순서 고려)
|
||||
detail_tables = [
|
||||
"support_details", "fitting_details", "flange_details",
|
||||
"valve_details", "gasket_details", "bolt_details",
|
||||
"instrument_details", "user_requirements"
|
||||
]
|
||||
|
||||
for table in detail_tables:
|
||||
try:
|
||||
db.execute(text(f"DELETE FROM {table} WHERE file_id = :file_id"), {"file_id": file_id})
|
||||
except Exception as detail_error:
|
||||
# 테이블이 없거나 다른 오류가 있어도 계속 진행
|
||||
print(f"Warning: Failed to delete from {table}: {detail_error}")
|
||||
|
||||
# 2. 구매 관련 테이블 삭제
|
||||
try:
|
||||
# purchase_request_items 먼저 삭제 (purchase_requests를 참조하므로)
|
||||
db.execute(text("""
|
||||
DELETE FROM purchase_request_items
|
||||
WHERE request_id IN (
|
||||
SELECT request_id FROM purchase_requests WHERE file_id = :file_id
|
||||
)
|
||||
"""), {"file_id": file_id})
|
||||
|
||||
# purchase_requests 삭제
|
||||
db.execute(text("DELETE FROM purchase_requests WHERE file_id = :file_id"), {"file_id": file_id})
|
||||
except Exception as purchase_error:
|
||||
print(f"Warning: Failed to delete purchase data: {purchase_error}")
|
||||
|
||||
# 3. 기타 관련 테이블들 삭제
|
||||
try:
|
||||
# excel_exports 삭제
|
||||
db.execute(text("DELETE FROM excel_exports WHERE file_id = :file_id"), {"file_id": file_id})
|
||||
except Exception as excel_error:
|
||||
print(f"Warning: Failed to delete from excel_exports: {excel_error}")
|
||||
|
||||
try:
|
||||
# user_activity_logs 삭제 (target_id와 target_type 사용)
|
||||
db.execute(text("DELETE FROM user_activity_logs WHERE target_id = :file_id AND target_type = 'file'"), {"file_id": file_id})
|
||||
except Exception as activity_error:
|
||||
print(f"Warning: Failed to delete from user_activity_logs: {activity_error}")
|
||||
|
||||
try:
|
||||
# exported_materials 삭제 (먼저 export_id 조회 후 삭제)
|
||||
export_ids = db.execute(text("SELECT id FROM excel_exports WHERE file_id = :file_id"), {"file_id": file_id}).fetchall()
|
||||
for export_row in export_ids:
|
||||
db.execute(text("DELETE FROM exported_materials WHERE export_id = :export_id"), {"export_id": export_row[0]})
|
||||
except Exception as exported_error:
|
||||
print(f"Warning: Failed to delete from exported_materials: {exported_error}")
|
||||
|
||||
# 4. 자재 테이블 삭제
|
||||
materials_result = db.execute(text("DELETE FROM materials WHERE file_id = :file_id"), {"file_id": file_id})
|
||||
print(f"Deleted {materials_result.rowcount} materials")
|
||||
|
||||
# 5. 마지막으로 파일 삭제
|
||||
file_result = db.execute(text("DELETE FROM files WHERE id = :file_id"), {"file_id": file_id})
|
||||
|
||||
if file_result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="파일 삭제에 실패했습니다")
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "파일이 삭제되었습니다"
|
||||
"message": "파일과 모든 관련 데이터가 삭제되었습니다",
|
||||
"deleted_materials": materials_result.rowcount
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"File deletion error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
|
||||
|
||||
@router.get("/materials-v2") # 완전히 새로운 엔드포인트
|
||||
|
||||
350
backend/app/services/database_service.py
Normal file
350
backend/app/services/database_service.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
데이터베이스 공통 서비스 레이어
|
||||
중복된 DB 쿼리 로직을 통합하고 재사용 가능한 서비스 제공
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from ..models import Material, File, User, Project
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.error_handlers import ErrorResponse
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
"""데이터베이스 공통 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def execute_query(self, query: str, params: Dict = None) -> Any:
|
||||
"""안전한 쿼리 실행"""
|
||||
try:
|
||||
result = self.db.execute(text(query), params or {})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Query execution failed: {query[:100]}... Error: {e}")
|
||||
raise
|
||||
|
||||
def get_materials_with_details(
|
||||
self,
|
||||
file_id: Optional[int] = None,
|
||||
job_no: Optional[str] = None,
|
||||
limit: int = 1000,
|
||||
offset: int = 0,
|
||||
exclude_requested: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""자재 상세 정보 조회 (통합된 쿼리)"""
|
||||
|
||||
where_conditions = ["1=1"]
|
||||
params = {"limit": limit, "offset": offset}
|
||||
|
||||
if file_id:
|
||||
where_conditions.append("m.file_id = :file_id")
|
||||
params["file_id"] = file_id
|
||||
|
||||
if job_no:
|
||||
where_conditions.append("f.job_no = :job_no")
|
||||
params["job_no"] = job_no
|
||||
|
||||
if exclude_requested:
|
||||
where_conditions.append("(m.purchase_confirmed IS NULL OR m.purchase_confirmed = false)")
|
||||
|
||||
# 통합된 자재 조회 쿼리
|
||||
query = f"""
|
||||
SELECT
|
||||
m.id, m.file_id, m.line_number, m.row_number,
|
||||
m.original_description, m.classified_category, m.classified_subcategory,
|
||||
m.material_grade, m.full_material_grade, m.schedule, m.size_spec,
|
||||
m.main_nom, m.red_nom, m.quantity, m.unit, m.length,
|
||||
m.drawing_name, m.area_code, m.line_no,
|
||||
m.classification_confidence, m.classification_details,
|
||||
m.purchase_confirmed, m.confirmed_quantity, m.purchase_status,
|
||||
m.purchase_confirmed_by, m.purchase_confirmed_at,
|
||||
m.revision_status, m.material_hash, m.normalized_description,
|
||||
m.drawing_reference, m.notes, m.created_at,
|
||||
|
||||
-- 파일 정보
|
||||
f.filename, f.original_filename, f.job_no, f.revision, f.bom_name,
|
||||
f.upload_date, f.uploaded_by,
|
||||
|
||||
-- 프로젝트 정보
|
||||
p.project_name, p.client_name,
|
||||
|
||||
-- 상세 정보들 (LEFT JOIN)
|
||||
pd.material_standard as pipe_material_standard,
|
||||
pd.manufacturing_method, pd.end_preparation, pd.wall_thickness,
|
||||
|
||||
fd.fitting_type, fd.fitting_subtype, fd.connection_type as fitting_connection_type,
|
||||
fd.main_size as fitting_main_size, fd.reduced_size as fitting_reduced_size,
|
||||
|
||||
fld.flange_type, fld.flange_subtype, fld.pressure_rating as flange_pressure_rating,
|
||||
fld.face_type, fld.connection_method,
|
||||
|
||||
vd.valve_type, vd.valve_subtype, vd.actuation_type,
|
||||
vd.pressure_rating as valve_pressure_rating, vd.temperature_rating,
|
||||
|
||||
bd.bolt_type, bd.bolt_subtype, bd.thread_type, bd.head_type,
|
||||
bd.material_standard as bolt_material_standard,
|
||||
bd.pressure_rating as bolt_pressure_rating,
|
||||
|
||||
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 as tracking_confirmed_quantity,
|
||||
mpt.purchase_status as tracking_purchase_status,
|
||||
mpt.confirmed_by as tracking_confirmed_by,
|
||||
mpt.confirmed_at as tracking_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 '%피팅%' 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 '%볼트%' 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 pipe_details pd ON m.id = pd.material_id
|
||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||
LEFT JOIN flange_details fld ON m.id = fld.material_id
|
||||
LEFT JOIN valve_details vd ON m.id = vd.material_id
|
||||
LEFT JOIN bolt_details bd ON m.id = bd.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 {' AND '.join(where_conditions)}
|
||||
ORDER BY m.line_number ASC, m.id ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
|
||||
try:
|
||||
result = self.execute_query(query, params)
|
||||
materials = [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
# 총 개수 조회
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) as total
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
WHERE {' AND '.join(where_conditions[:-1])} -- LIMIT/OFFSET 제외
|
||||
"""
|
||||
|
||||
count_result = self.execute_query(count_query, {k: v for k, v in params.items() if k not in ['limit', 'offset']})
|
||||
total_count = count_result.scalar()
|
||||
|
||||
return {
|
||||
"materials": materials,
|
||||
"total_count": total_count,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get materials with details: {e}")
|
||||
raise
|
||||
|
||||
def get_purchase_request_materials(
|
||||
self,
|
||||
job_no: str,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 1000
|
||||
) -> List[Dict]:
|
||||
"""구매 신청 자재 조회"""
|
||||
|
||||
where_conditions = ["f.job_no = :job_no", "m.purchase_confirmed = true"]
|
||||
params = {"job_no": job_no, "limit": limit}
|
||||
|
||||
if category and category != 'ALL':
|
||||
where_conditions.append("m.classified_category = :category")
|
||||
params["category"] = category
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
m.id, m.original_description, m.classified_category,
|
||||
m.material_grade, m.schedule, m.size_spec, m.main_nom, m.red_nom,
|
||||
CAST(m.quantity AS INTEGER) as requested_quantity,
|
||||
CAST(m.confirmed_quantity AS INTEGER) as original_quantity,
|
||||
m.unit, m.drawing_name, m.line_no,
|
||||
f.job_no, f.revision, f.bom_name
|
||||
FROM materials m
|
||||
JOIN files f ON m.file_id = f.id
|
||||
WHERE {' AND '.join(where_conditions)}
|
||||
ORDER BY m.classified_category, m.original_description
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
try:
|
||||
result = self.execute_query(query, params)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get purchase request materials: {e}")
|
||||
raise
|
||||
|
||||
def safe_delete_related_data(self, file_id: int) -> Dict[str, Any]:
|
||||
"""파일 관련 데이터 안전 삭제"""
|
||||
|
||||
deletion_results = {}
|
||||
|
||||
# 삭제 순서 (외래키 제약 조건 고려)
|
||||
deletion_queries = [
|
||||
("support_details", "DELETE FROM support_details WHERE file_id = :file_id"),
|
||||
("fitting_details", "DELETE FROM fitting_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"),
|
||||
("flange_details", "DELETE FROM flange_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"),
|
||||
("valve_details", "DELETE FROM valve_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"),
|
||||
("gasket_details", "DELETE FROM gasket_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"),
|
||||
("bolt_details", "DELETE FROM bolt_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"),
|
||||
("instrument_details", "DELETE FROM instrument_details WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"),
|
||||
("user_requirements", "DELETE FROM user_requirements WHERE file_id = :file_id"),
|
||||
("purchase_request_items", "DELETE FROM purchase_request_items WHERE material_id IN (SELECT id FROM materials WHERE file_id = :file_id)"),
|
||||
("purchase_requests", "DELETE FROM purchase_requests WHERE file_id = :file_id"),
|
||||
("user_activity_logs", "DELETE FROM user_activity_logs WHERE target_id = :file_id AND target_type = 'file'"),
|
||||
("exported_materials", """
|
||||
DELETE FROM exported_materials
|
||||
WHERE export_id IN (SELECT id FROM excel_exports WHERE file_id = :file_id)
|
||||
"""),
|
||||
("excel_exports", "DELETE FROM excel_exports WHERE file_id = :file_id"),
|
||||
("materials", "DELETE FROM materials WHERE file_id = :file_id"),
|
||||
]
|
||||
|
||||
for table_name, query in deletion_queries:
|
||||
try:
|
||||
result = self.execute_query(query, {"file_id": file_id})
|
||||
deleted_count = result.rowcount
|
||||
deletion_results[table_name] = {"success": True, "deleted_count": deleted_count}
|
||||
logger.info(f"Deleted {deleted_count} records from {table_name}")
|
||||
except Exception as e:
|
||||
deletion_results[table_name] = {"success": False, "error": str(e)}
|
||||
logger.warning(f"Failed to delete from {table_name}: {e}")
|
||||
|
||||
return deletion_results
|
||||
|
||||
def bulk_insert_materials(self, materials_data: List[Dict], file_id: int) -> int:
|
||||
"""자재 데이터 대량 삽입"""
|
||||
|
||||
if not materials_data:
|
||||
return 0
|
||||
|
||||
try:
|
||||
# 대량 삽입을 위한 쿼리 준비
|
||||
insert_query = """
|
||||
INSERT INTO materials (
|
||||
file_id, line_number, row_number, original_description,
|
||||
classified_category, classified_subcategory, material_grade,
|
||||
full_material_grade, schedule, size_spec, main_nom, red_nom,
|
||||
quantity, unit, length, drawing_name, area_code, line_no,
|
||||
classification_confidence, classification_details,
|
||||
revision_status, material_hash, normalized_description,
|
||||
created_at
|
||||
) VALUES (
|
||||
:file_id, :line_number, :row_number, :original_description,
|
||||
:classified_category, :classified_subcategory, :material_grade,
|
||||
:full_material_grade, :schedule, :size_spec, :main_nom, :red_nom,
|
||||
:quantity, :unit, :length, :drawing_name, :area_code, :line_no,
|
||||
:classification_confidence, :classification_details,
|
||||
:revision_status, :material_hash, :normalized_description,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
# 데이터 준비
|
||||
insert_data = []
|
||||
for material in materials_data:
|
||||
insert_data.append({
|
||||
"file_id": file_id,
|
||||
"line_number": material.get("line_number"),
|
||||
"row_number": material.get("row_number"),
|
||||
"original_description": material.get("original_description", ""),
|
||||
"classified_category": material.get("classified_category"),
|
||||
"classified_subcategory": material.get("classified_subcategory"),
|
||||
"material_grade": material.get("material_grade"),
|
||||
"full_material_grade": material.get("full_material_grade"),
|
||||
"schedule": material.get("schedule"),
|
||||
"size_spec": material.get("size_spec"),
|
||||
"main_nom": material.get("main_nom"),
|
||||
"red_nom": material.get("red_nom"),
|
||||
"quantity": material.get("quantity", 0),
|
||||
"unit": material.get("unit", "EA"),
|
||||
"length": material.get("length"),
|
||||
"drawing_name": material.get("drawing_name"),
|
||||
"area_code": material.get("area_code"),
|
||||
"line_no": material.get("line_no"),
|
||||
"classification_confidence": material.get("classification_confidence"),
|
||||
"classification_details": material.get("classification_details"),
|
||||
"revision_status": material.get("revision_status", "new"),
|
||||
"material_hash": material.get("material_hash"),
|
||||
"normalized_description": material.get("normalized_description"),
|
||||
})
|
||||
|
||||
# 대량 삽입 실행
|
||||
self.db.execute(text(insert_query), insert_data)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Successfully inserted {len(insert_data)} materials")
|
||||
return len(insert_data)
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to bulk insert materials: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class MaterialQueryBuilder:
|
||||
"""자재 쿼리 빌더"""
|
||||
|
||||
@staticmethod
|
||||
def build_materials_query(
|
||||
filters: Dict[str, Any] = None,
|
||||
joins: List[str] = None,
|
||||
order_by: str = "m.line_number ASC"
|
||||
) -> str:
|
||||
"""동적 자재 쿼리 생성"""
|
||||
|
||||
base_query = """
|
||||
SELECT m.*, f.job_no, f.revision, f.bom_name
|
||||
FROM materials m
|
||||
LEFT JOIN files f ON m.file_id = f.id
|
||||
"""
|
||||
|
||||
# 추가 조인
|
||||
if joins:
|
||||
for join in joins:
|
||||
base_query += f" {join}"
|
||||
|
||||
# 필터 조건
|
||||
where_conditions = ["1=1"]
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if value is not None:
|
||||
where_conditions.append(f"m.{key} = :{key}")
|
||||
|
||||
if where_conditions:
|
||||
base_query += f" WHERE {' AND '.join(where_conditions)}"
|
||||
|
||||
# 정렬
|
||||
if order_by:
|
||||
base_query += f" ORDER BY {order_by}"
|
||||
|
||||
return base_query
|
||||
607
backend/app/services/file_upload_service.py
Normal file
607
backend/app/services/file_upload_service.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""
|
||||
파일 업로드 서비스
|
||||
파일 업로드 관련 로직을 통합하고 트랜잭션 관리 개선
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from fastapi import UploadFile, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..models import File, Material
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.file_processor import parse_file_data
|
||||
from ..utils.file_validator import validate_file_extension, generate_unique_filename
|
||||
from .database_service import DatabaseService
|
||||
from .material_classification_service import MaterialClassificationService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
UPLOAD_DIR = Path("uploads")
|
||||
ALLOWED_EXTENSIONS = {'.xls', '.xlsx', '.csv'}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
class FileUploadService:
|
||||
"""파일 업로드 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
self.classification_service = MaterialClassificationService()
|
||||
|
||||
def validate_upload_request(self, file: UploadFile, job_no: str) -> None:
|
||||
"""업로드 요청 검증"""
|
||||
|
||||
if not validate_file_extension(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
if file.size and file.size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="파일 크기는 10MB를 초과할 수 없습니다"
|
||||
)
|
||||
|
||||
if not job_no or len(job_no.strip()) == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="작업 번호는 필수입니다"
|
||||
)
|
||||
|
||||
def save_uploaded_file(self, file: UploadFile) -> Tuple[str, Path]:
|
||||
"""업로드된 파일 저장"""
|
||||
|
||||
try:
|
||||
# 고유 파일명 생성
|
||||
unique_filename = generate_unique_filename(file.filename)
|
||||
file_path = UPLOAD_DIR / unique_filename
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# 파일 저장
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
logger.info(f"File saved: {file_path}")
|
||||
return unique_filename, file_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save file: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
|
||||
|
||||
def create_file_record(
|
||||
self,
|
||||
filename: str,
|
||||
original_filename: str,
|
||||
file_path: str,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
bom_name: Optional[str],
|
||||
file_size: int,
|
||||
parsed_count: int,
|
||||
uploaded_by: str,
|
||||
parent_file_id: Optional[int] = None
|
||||
) -> File:
|
||||
"""파일 레코드 생성"""
|
||||
|
||||
try:
|
||||
# BOM 이름 자동 생성 (제공되지 않은 경우)
|
||||
if not bom_name:
|
||||
bom_name = original_filename.rsplit('.', 1)[0]
|
||||
|
||||
# 파일 설명 생성
|
||||
description = f"BOM 파일 - {parsed_count}개 자재"
|
||||
|
||||
file_record = File(
|
||||
filename=filename,
|
||||
original_filename=original_filename,
|
||||
file_path=file_path,
|
||||
job_no=job_no,
|
||||
revision=revision,
|
||||
bom_name=bom_name,
|
||||
description=description,
|
||||
file_size=file_size,
|
||||
parsed_count=parsed_count,
|
||||
is_active=True,
|
||||
uploaded_by=uploaded_by
|
||||
)
|
||||
|
||||
self.db.add(file_record)
|
||||
self.db.flush() # ID 생성을 위해 flush
|
||||
|
||||
logger.info(f"File record created: ID={file_record.id}, Job={job_no}")
|
||||
return file_record
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create file record: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"파일 레코드 생성 실패: {str(e)}")
|
||||
|
||||
def process_materials_data(
|
||||
self,
|
||||
file_path: Path,
|
||||
file_id: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
parent_file_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""자재 데이터 처리"""
|
||||
|
||||
try:
|
||||
# 파일 파싱
|
||||
logger.info(f"Parsing file: {file_path}")
|
||||
materials_data = parse_file_data(str(file_path))
|
||||
|
||||
if not materials_data:
|
||||
raise HTTPException(status_code=400, detail="파일에서 자재 데이터를 찾을 수 없습니다")
|
||||
|
||||
# 자재 분류 및 처리
|
||||
processed_materials = []
|
||||
classification_results = {
|
||||
"total_materials": len(materials_data),
|
||||
"classified_count": 0,
|
||||
"categories": {}
|
||||
}
|
||||
|
||||
for idx, material_data in enumerate(materials_data):
|
||||
try:
|
||||
# 자재 분류
|
||||
classified_material = self.classification_service.classify_material(
|
||||
material_data,
|
||||
line_number=idx + 1,
|
||||
row_number=material_data.get('row_number', idx + 1)
|
||||
)
|
||||
|
||||
# 리비전 상태 설정
|
||||
if parent_file_id:
|
||||
# 리비전 업로드인 경우 변경 상태 분석
|
||||
classified_material['revision_status'] = self._analyze_revision_status(
|
||||
classified_material, parent_file_id
|
||||
)
|
||||
else:
|
||||
classified_material['revision_status'] = 'new'
|
||||
|
||||
processed_materials.append(classified_material)
|
||||
|
||||
# 분류 통계 업데이트
|
||||
category = classified_material.get('classified_category', 'UNKNOWN')
|
||||
classification_results['categories'][category] = classification_results['categories'].get(category, 0) + 1
|
||||
|
||||
if category != 'UNKNOWN':
|
||||
classification_results['classified_count'] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to classify material at line {idx + 1}: {e}")
|
||||
# 분류 실패 시 기본값으로 처리
|
||||
material_data.update({
|
||||
'classified_category': 'UNKNOWN',
|
||||
'classification_confidence': 0.0,
|
||||
'revision_status': 'new'
|
||||
})
|
||||
processed_materials.append(material_data)
|
||||
|
||||
# 자재 데이터 DB 저장
|
||||
inserted_count = self.db_service.bulk_insert_materials(processed_materials, file_id)
|
||||
|
||||
# 상세 정보 저장 (분류별)
|
||||
self._save_material_details(processed_materials, file_id)
|
||||
|
||||
logger.info(f"Processed {inserted_count} materials for file {file_id}")
|
||||
|
||||
return {
|
||||
"materials_count": inserted_count,
|
||||
"classification_results": classification_results,
|
||||
"processed_materials": processed_materials[:10] # 처음 10개만 반환
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process materials data: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"자재 데이터 처리 실패: {str(e)}")
|
||||
|
||||
def _analyze_revision_status(self, material: Dict, parent_file_id: int) -> str:
|
||||
"""리비전 상태 분석"""
|
||||
|
||||
try:
|
||||
# 부모 파일의 동일한 자재 찾기
|
||||
parent_material_query = """
|
||||
SELECT * FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
AND drawing_name = :drawing_name
|
||||
AND original_description = :description
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(
|
||||
parent_material_query,
|
||||
{
|
||||
"parent_file_id": parent_file_id,
|
||||
"drawing_name": material.get('drawing_name'),
|
||||
"description": material.get('original_description')
|
||||
}
|
||||
)
|
||||
|
||||
parent_material = result.fetchone()
|
||||
|
||||
if not parent_material:
|
||||
return 'new' # 새로운 자재
|
||||
|
||||
# 변경 사항 확인
|
||||
if (
|
||||
float(material.get('quantity', 0)) != float(parent_material.quantity or 0) or
|
||||
material.get('material_grade') != parent_material.material_grade or
|
||||
material.get('size_spec') != parent_material.size_spec
|
||||
):
|
||||
return 'changed' # 변경된 자재
|
||||
|
||||
return 'inventory' # 기존 자재 (변경 없음)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to analyze revision status: {e}")
|
||||
return 'new'
|
||||
|
||||
def _save_material_details(self, materials: List[Dict], file_id: int) -> None:
|
||||
"""자재 상세 정보 저장"""
|
||||
|
||||
try:
|
||||
# 자재 ID 매핑 (방금 삽입된 자재들)
|
||||
material_ids_query = """
|
||||
SELECT id, row_number FROM materials
|
||||
WHERE file_id = :file_id
|
||||
ORDER BY row_number
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(material_ids_query, {"file_id": file_id})
|
||||
material_id_map = {row.row_number: row.id for row in result.fetchall()}
|
||||
|
||||
# 분류별 상세 정보 저장
|
||||
for material in materials:
|
||||
material_id = material_id_map.get(material.get('row_number'))
|
||||
if not material_id:
|
||||
continue
|
||||
|
||||
category = material.get('classified_category')
|
||||
|
||||
if category == 'PIPE':
|
||||
self._save_pipe_details(material, material_id, file_id)
|
||||
elif category == 'FITTING':
|
||||
self._save_fitting_details(material, material_id, file_id)
|
||||
elif category == 'FLANGE':
|
||||
self._save_flange_details(material, material_id, file_id)
|
||||
elif category == 'VALVE':
|
||||
self._save_valve_details(material, material_id, file_id)
|
||||
elif category == 'BOLT':
|
||||
self._save_bolt_details(material, material_id, file_id)
|
||||
elif category == 'GASKET':
|
||||
self._save_gasket_details(material, material_id, file_id)
|
||||
elif category == 'SUPPORT':
|
||||
self._save_support_details(material, material_id, file_id)
|
||||
elif category == 'INSTRUMENT':
|
||||
self._save_instrument_details(material, material_id, file_id)
|
||||
|
||||
logger.info(f"Saved material details for {len(materials)} materials")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save material details: {e}")
|
||||
# 상세 정보 저장 실패는 전체 프로세스를 중단하지 않음
|
||||
|
||||
def _save_pipe_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""파이프 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO pipe_details (
|
||||
material_id, file_id, material_standard, material_grade, material_type,
|
||||
manufacturing_method, end_preparation, schedule, wall_thickness,
|
||||
nominal_size, length_mm, material_confidence, manufacturing_confidence,
|
||||
end_prep_confidence, schedule_confidence
|
||||
) VALUES (
|
||||
:material_id, :file_id, :material_standard, :material_grade, :material_type,
|
||||
:manufacturing_method, :end_preparation, :schedule, :wall_thickness,
|
||||
:nominal_size, :length_mm, :material_confidence, :manufacturing_confidence,
|
||||
:end_prep_confidence, :schedule_confidence
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"material_standard": details.get('material_standard'),
|
||||
"material_grade": material.get('material_grade'),
|
||||
"material_type": details.get('material_type'),
|
||||
"manufacturing_method": details.get('manufacturing_method'),
|
||||
"end_preparation": details.get('end_preparation'),
|
||||
"schedule": material.get('schedule'),
|
||||
"wall_thickness": details.get('wall_thickness'),
|
||||
"nominal_size": material.get('main_nom'),
|
||||
"length_mm": material.get('length'),
|
||||
"material_confidence": details.get('material_confidence', 0.0),
|
||||
"manufacturing_confidence": details.get('manufacturing_confidence', 0.0),
|
||||
"end_prep_confidence": details.get('end_prep_confidence', 0.0),
|
||||
"schedule_confidence": details.get('schedule_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save pipe details for material {material_id}: {e}")
|
||||
|
||||
def _save_fitting_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""피팅 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO fitting_details (
|
||||
material_id, file_id, fitting_type, fitting_subtype, connection_type,
|
||||
main_size, reduced_size, length_mm, material_standard, material_grade,
|
||||
pressure_rating, temperature_rating, classification_confidence
|
||||
) VALUES (
|
||||
:material_id, :file_id, :fitting_type, :fitting_subtype, :connection_type,
|
||||
:main_size, :reduced_size, :length_mm, :material_standard, :material_grade,
|
||||
:pressure_rating, :temperature_rating, :classification_confidence
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"fitting_type": details.get('fitting_type', 'UNKNOWN'),
|
||||
"fitting_subtype": details.get('fitting_subtype'),
|
||||
"connection_type": details.get('connection_type'),
|
||||
"main_size": material.get('main_nom'),
|
||||
"reduced_size": material.get('red_nom'),
|
||||
"length_mm": material.get('length'),
|
||||
"material_standard": details.get('material_standard'),
|
||||
"material_grade": material.get('material_grade'),
|
||||
"pressure_rating": details.get('pressure_rating'),
|
||||
"temperature_rating": details.get('temperature_rating'),
|
||||
"classification_confidence": material.get('classification_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save fitting details for material {material_id}: {e}")
|
||||
|
||||
def _save_flange_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""플랜지 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO flange_details (
|
||||
material_id, file_id, flange_type, flange_subtype, pressure_rating,
|
||||
face_type, connection_method, nominal_size, material_standard,
|
||||
material_grade, classification_confidence
|
||||
) VALUES (
|
||||
:material_id, :file_id, :flange_type, :flange_subtype, :pressure_rating,
|
||||
:face_type, :connection_method, :nominal_size, :material_standard,
|
||||
:material_grade, :classification_confidence
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"flange_type": details.get('flange_type', 'UNKNOWN'),
|
||||
"flange_subtype": details.get('flange_subtype'),
|
||||
"pressure_rating": details.get('pressure_rating'),
|
||||
"face_type": details.get('face_type'),
|
||||
"connection_method": details.get('connection_method'),
|
||||
"nominal_size": material.get('main_nom'),
|
||||
"material_standard": details.get('material_standard'),
|
||||
"material_grade": material.get('material_grade'),
|
||||
"classification_confidence": material.get('classification_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save flange details for material {material_id}: {e}")
|
||||
|
||||
def _save_valve_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""밸브 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO valve_details (
|
||||
material_id, file_id, valve_type, valve_subtype, actuation_type,
|
||||
pressure_rating, temperature_rating, nominal_size, connection_type,
|
||||
material_standard, material_grade, classification_confidence
|
||||
) VALUES (
|
||||
:material_id, :file_id, :valve_type, :valve_subtype, :actuation_type,
|
||||
:pressure_rating, :temperature_rating, :nominal_size, :connection_type,
|
||||
:material_standard, :material_grade, :classification_confidence
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"valve_type": details.get('valve_type', 'UNKNOWN'),
|
||||
"valve_subtype": details.get('valve_subtype'),
|
||||
"actuation_type": details.get('actuation_type'),
|
||||
"pressure_rating": details.get('pressure_rating'),
|
||||
"temperature_rating": details.get('temperature_rating'),
|
||||
"nominal_size": material.get('main_nom'),
|
||||
"connection_type": details.get('connection_type'),
|
||||
"material_standard": details.get('material_standard'),
|
||||
"material_grade": material.get('material_grade'),
|
||||
"classification_confidence": material.get('classification_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save valve details for material {material_id}: {e}")
|
||||
|
||||
def _save_bolt_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""볼트 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO bolt_details (
|
||||
material_id, file_id, bolt_type, bolt_subtype, thread_type,
|
||||
head_type, material_standard, material_grade, pressure_rating,
|
||||
length_mm, diameter_mm, classification_confidence
|
||||
) VALUES (
|
||||
:material_id, :file_id, :bolt_type, :bolt_subtype, :thread_type,
|
||||
:head_type, :material_standard, :material_grade, :pressure_rating,
|
||||
:length_mm, :diameter_mm, :classification_confidence
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"bolt_type": details.get('bolt_type', 'UNKNOWN'),
|
||||
"bolt_subtype": details.get('bolt_subtype'),
|
||||
"thread_type": details.get('thread_type'),
|
||||
"head_type": details.get('head_type'),
|
||||
"material_standard": details.get('material_standard'),
|
||||
"material_grade": material.get('material_grade'),
|
||||
"pressure_rating": details.get('pressure_rating'),
|
||||
"length_mm": material.get('length'),
|
||||
"diameter_mm": details.get('diameter_mm'),
|
||||
"classification_confidence": material.get('classification_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save bolt details for material {material_id}: {e}")
|
||||
|
||||
def _save_gasket_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""가스켓 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO gasket_details (
|
||||
material_id, file_id, gasket_type, gasket_subtype, material_type,
|
||||
filler_material, pressure_rating, size_inches, thickness,
|
||||
temperature_range, fire_safe, classification_confidence
|
||||
) VALUES (
|
||||
:material_id, :file_id, :gasket_type, :gasket_subtype, :material_type,
|
||||
:filler_material, :pressure_rating, :size_inches, :thickness,
|
||||
:temperature_range, :fire_safe, :classification_confidence
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"gasket_type": details.get('gasket_type', 'UNKNOWN'),
|
||||
"gasket_subtype": details.get('gasket_subtype'),
|
||||
"material_type": details.get('material_type'),
|
||||
"filler_material": details.get('filler_material'),
|
||||
"pressure_rating": details.get('pressure_rating'),
|
||||
"size_inches": material.get('main_nom'),
|
||||
"thickness": details.get('thickness'),
|
||||
"temperature_range": details.get('temperature_range'),
|
||||
"fire_safe": details.get('fire_safe', False),
|
||||
"classification_confidence": material.get('classification_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save gasket details for material {material_id}: {e}")
|
||||
|
||||
def _save_support_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""서포트 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
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
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"support_type": details.get('support_type', 'UNKNOWN'),
|
||||
"support_subtype": details.get('support_subtype'),
|
||||
"load_rating": details.get('load_rating', 'UNKNOWN'),
|
||||
"load_capacity": details.get('load_capacity'),
|
||||
"material_standard": details.get('material_standard', 'UNKNOWN'),
|
||||
"material_grade": details.get('material_grade', 'UNKNOWN'),
|
||||
"pipe_size": material.get('main_nom'),
|
||||
"length_mm": material.get('length'),
|
||||
"width_mm": details.get('width_mm'),
|
||||
"height_mm": details.get('height_mm'),
|
||||
"classification_confidence": material.get('classification_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save support details for material {material_id}: {e}")
|
||||
|
||||
def _save_instrument_details(self, material: Dict, material_id: int, file_id: int) -> None:
|
||||
"""계기 상세 정보 저장"""
|
||||
|
||||
details = material.get('classification_details', {})
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO instrument_details (
|
||||
material_id, file_id, instrument_type, instrument_subtype,
|
||||
measurement_type, connection_size, pressure_rating,
|
||||
temperature_rating, accuracy_class, material_standard,
|
||||
classification_confidence
|
||||
) VALUES (
|
||||
:material_id, :file_id, :instrument_type, :instrument_subtype,
|
||||
:measurement_type, :connection_size, :pressure_rating,
|
||||
:temperature_rating, :accuracy_class, :material_standard,
|
||||
:classification_confidence
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
self.db_service.execute_query(insert_query, {
|
||||
"material_id": material_id,
|
||||
"file_id": file_id,
|
||||
"instrument_type": details.get('instrument_type', 'UNKNOWN'),
|
||||
"instrument_subtype": details.get('instrument_subtype'),
|
||||
"measurement_type": details.get('measurement_type'),
|
||||
"connection_size": material.get('main_nom'),
|
||||
"pressure_rating": details.get('pressure_rating'),
|
||||
"temperature_rating": details.get('temperature_rating'),
|
||||
"accuracy_class": details.get('accuracy_class'),
|
||||
"material_standard": details.get('material_standard'),
|
||||
"classification_confidence": material.get('classification_confidence', 0.0)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save instrument details for material {material_id}: {e}")
|
||||
|
||||
def cleanup_failed_upload(self, file_path: Path) -> None:
|
||||
"""실패한 업로드 정리"""
|
||||
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"Cleaned up failed upload: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup file {file_path}: {e}")
|
||||
|
||||
|
||||
class MaterialClassificationService:
|
||||
"""자재 분류 서비스 (임시 구현)"""
|
||||
|
||||
def classify_material(self, material_data: Dict, line_number: int, row_number: int) -> Dict:
|
||||
"""자재 분류 (기존 로직 유지)"""
|
||||
|
||||
# 기존 분류 로직을 여기에 통합
|
||||
# 현재는 기본값만 설정
|
||||
material_data.update({
|
||||
'line_number': line_number,
|
||||
'row_number': row_number,
|
||||
'classified_category': material_data.get('classified_category', 'UNKNOWN'),
|
||||
'classification_confidence': material_data.get('classification_confidence', 0.0),
|
||||
'classification_details': material_data.get('classification_details', {})
|
||||
})
|
||||
|
||||
return material_data
|
||||
Reference in New Issue
Block a user