""" 엑셀 내보내기 및 구매 배치 관리 API """ from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi.responses import FileResponse from sqlalchemy import text from sqlalchemy.orm import Session from typing import Optional, List, Dict, Any from datetime import datetime import json import os import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter import uuid from ..database import get_db from ..auth.jwt_service import get_current_user from ..utils.logger import logger router = APIRouter(prefix="/export", tags=["Export Management"]) # 엑셀 파일 저장 경로 EXPORT_DIR = "exports" os.makedirs(EXPORT_DIR, exist_ok=True) def create_excel_from_materials(materials: List[Dict], batch_info: Dict) -> str: """ 자재 목록으로 엑셀 파일 생성 """ wb = openpyxl.Workbook() ws = wb.active ws.title = batch_info.get("category", "자재목록") # 헤더 스타일 header_fill = PatternFill(start_color="B8E6FF", end_color="B8E6FF", fill_type="solid") header_font = Font(bold=True, size=11) header_alignment = Alignment(horizontal="center", vertical="center") thin_border = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin') ) # 배치 정보 추가 (상단 3줄) ws.merge_cells('A1:J1') ws['A1'] = f"구매 배치: {batch_info.get('batch_no', 'N/A')}" ws['A1'].font = Font(bold=True, size=14) ws.merge_cells('A2:J2') ws['A2'] = f"프로젝트: {batch_info.get('job_no', '')} - {batch_info.get('job_name', '')}" ws.merge_cells('A3:J3') ws['A3'] = f"내보낸 날짜: {batch_info.get('export_date', datetime.now().strftime('%Y-%m-%d %H:%M'))}" # 빈 줄 ws.append([]) # 헤더 행 headers = [ "No.", "카테고리", "자재 설명", "크기", "스케줄/등급", "재질", "수량", "단위", "추가요구", "사용자요구", "구매상태", "PR번호", "PO번호", "공급업체", "예정일" ] for col, header in enumerate(headers, 1): cell = ws.cell(row=5, column=col, value=header) cell.fill = header_fill cell.font = header_font cell.alignment = header_alignment cell.border = thin_border # 데이터 행 row_num = 6 for idx, material in enumerate(materials, 1): row_data = [ idx, material.get("category", ""), material.get("description", ""), material.get("size", ""), material.get("schedule", ""), material.get("material_grade", ""), material.get("quantity", ""), material.get("unit", ""), material.get("additional_req", ""), material.get("user_requirement", ""), material.get("purchase_status", "pending"), material.get("purchase_request_no", ""), material.get("purchase_order_no", ""), material.get("vendor_name", ""), material.get("expected_date", "") ] for col, value in enumerate(row_data, 1): cell = ws.cell(row=row_num, column=col, value=value) cell.border = thin_border if col == 11: # 구매상태 컬럼 if value == "pending": cell.fill = PatternFill(start_color="FFFFE0", end_color="FFFFE0", fill_type="solid") elif value == "requested": cell.fill = PatternFill(start_color="FFE4B5", end_color="FFE4B5", fill_type="solid") elif value == "ordered": cell.fill = PatternFill(start_color="ADD8E6", end_color="ADD8E6", fill_type="solid") elif value == "received": cell.fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid") row_num += 1 # 열 너비 자동 조정 for column in ws.columns: max_length = 0 column_letter = get_column_letter(column[0].column) for cell in column: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass adjusted_width = min(max_length + 2, 50) ws.column_dimensions[column_letter].width = adjusted_width # 파일 저장 file_name = f"batch_{batch_info.get('batch_no', uuid.uuid4().hex[:8])}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" file_path = os.path.join(EXPORT_DIR, file_name) wb.save(file_path) return file_name @router.post("/create-batch") async def create_export_batch( file_id: int, job_no: Optional[str] = None, category: Optional[str] = None, materials: List[Dict] = [], current_user: dict = Depends(get_current_user), db: Session = Depends(get_db) ): """ 엑셀 내보내기 배치 생성 (자재 그룹화) """ try: # 배치 번호 생성 (YYYYMMDD-XXX 형식) batch_date = datetime.now().strftime('%Y%m%d') # 오늘 생성된 배치 수 확인 count_query = text(""" SELECT COUNT(*) as count FROM excel_export_history WHERE DATE(export_date) = CURRENT_DATE """) count_result = db.execute(count_query).fetchone() batch_seq = (count_result.count + 1) if count_result else 1 batch_no = f"{batch_date}-{str(batch_seq).zfill(3)}" # Job 정보 조회 job_name = "" if job_no: job_query = text("SELECT job_name FROM jobs WHERE job_no = :job_no") job_result = db.execute(job_query, {"job_no": job_no}).fetchone() if job_result: job_name = job_result.job_name # 배치 정보 batch_info = { "batch_no": batch_no, "job_no": job_no, "job_name": job_name, "category": category, "export_date": datetime.now().strftime('%Y-%m-%d %H:%M') } # 엑셀 파일 생성 excel_file_name = create_excel_from_materials(materials, batch_info) # 내보내기 이력 저장 insert_history = text(""" INSERT INTO excel_export_history ( file_id, job_no, exported_by, export_type, category, material_count, file_name, notes ) VALUES ( :file_id, :job_no, :exported_by, :export_type, :category, :material_count, :file_name, :notes ) RETURNING export_id """) result = db.execute(insert_history, { "file_id": file_id, "job_no": job_no, "exported_by": current_user.get("user_id"), "export_type": "batch", "category": category, "material_count": len(materials), "file_name": excel_file_name, "notes": f"배치번호: {batch_no}" }) export_id = result.fetchone().export_id # 자재별 내보내기 기록 material_ids = [] for material in materials: material_id = material.get("id") if material_id: material_ids.append(material_id) insert_material = text(""" INSERT INTO exported_materials ( export_id, material_id, purchase_status, quantity_exported ) VALUES ( :export_id, :material_id, 'pending', :quantity ) """) db.execute(insert_material, { "export_id": export_id, "material_id": material_id, "quantity": material.get("quantity", 0) }) db.commit() logger.info(f"Export batch created: {batch_no} with {len(materials)} materials") return { "success": True, "batch_no": batch_no, "export_id": export_id, "file_name": excel_file_name, "material_count": len(materials), "message": f"배치 {batch_no}가 생성되었습니다" } except Exception as e: db.rollback() logger.error(f"Failed to create export batch: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"배치 생성 실패: {str(e)}" ) @router.get("/batches") async def get_export_batches( file_id: Optional[int] = None, job_no: Optional[str] = None, status: Optional[str] = None, limit: int = 50, current_user: dict = Depends(get_current_user), db: Session = Depends(get_db) ): """ 내보내기 배치 목록 조회 """ try: query = text(""" SELECT eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date, eeh.category, eeh.material_count, eeh.file_name, eeh.notes, u.name as exported_by, j.job_name, f.original_filename, -- 상태별 집계 COUNT(DISTINCT em.material_id) as total_materials, COUNT(DISTINCT CASE WHEN em.purchase_status = 'pending' THEN em.material_id END) as pending_count, COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) as requested_count, COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) as ordered_count, COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) as received_count, -- 전체 상태 계산 CASE WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) THEN 'completed' WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0 THEN 'in_progress' WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0 THEN 'requested' ELSE 'pending' END as batch_status FROM excel_export_history eeh LEFT JOIN users u ON eeh.exported_by = u.user_id LEFT JOIN jobs j ON eeh.job_no = j.job_no LEFT JOIN files f ON eeh.file_id = f.id LEFT JOIN exported_materials em ON eeh.export_id = em.export_id WHERE eeh.export_type = 'batch' AND (:file_id IS NULL OR eeh.file_id = :file_id) AND (:job_no IS NULL OR eeh.job_no = :job_no) GROUP BY eeh.export_id, eeh.file_id, eeh.job_no, eeh.export_date, eeh.category, eeh.material_count, eeh.file_name, eeh.notes, u.name, j.job_name, f.original_filename HAVING (:status IS NULL OR CASE WHEN COUNT(DISTINCT em.material_id) = COUNT(DISTINCT CASE WHEN em.purchase_status = 'received' THEN em.material_id END) THEN 'completed' WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'ordered' THEN em.material_id END) > 0 THEN 'in_progress' WHEN COUNT(DISTINCT CASE WHEN em.purchase_status = 'requested' THEN em.material_id END) > 0 THEN 'requested' ELSE 'pending' END = :status) ORDER BY eeh.export_date DESC LIMIT :limit """) results = db.execute(query, { "file_id": file_id, "job_no": job_no, "status": status, "limit": limit }).fetchall() batches = [] for row in results: # 배치 번호 추출 (notes에서) batch_no = "" if row.notes and "배치번호:" in row.notes: batch_no = row.notes.split("배치번호:")[1].strip() batches.append({ "export_id": row.export_id, "batch_no": batch_no, "file_id": row.file_id, "job_no": row.job_no, "job_name": row.job_name, "export_date": row.export_date.isoformat() if row.export_date else None, "category": row.category, "material_count": row.total_materials, "file_name": row.file_name, "exported_by": row.exported_by, "source_file": row.original_filename, "batch_status": row.batch_status, "status_detail": { "pending": row.pending_count, "requested": row.requested_count, "ordered": row.ordered_count, "received": row.received_count, "total": row.total_materials } }) return { "success": True, "batches": batches, "count": len(batches) } except Exception as e: logger.error(f"Failed to get export batches: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"배치 목록 조회 실패: {str(e)}" ) @router.get("/batch/{export_id}/materials") async def get_batch_materials( export_id: int, current_user: dict = Depends(get_current_user), db: Session = Depends(get_db) ): """ 배치에 포함된 자재 목록 조회 """ try: query = text(""" SELECT em.id as exported_material_id, em.material_id, m.original_description, m.classified_category, m.size_inch, m.schedule, m.material_grade, m.quantity, m.unit, em.purchase_status, em.purchase_request_no, em.purchase_order_no, em.vendor_name, em.expected_date, em.quantity_ordered, em.quantity_received, em.unit_price, em.total_price, em.notes, ur.requirement as user_requirement FROM exported_materials em JOIN materials m ON em.material_id = m.id LEFT JOIN user_requirements ur ON m.id = ur.material_id WHERE em.export_id = :export_id ORDER BY m.classified_category, m.original_description """) results = db.execute(query, {"export_id": export_id}).fetchall() materials = [] for row in results: materials.append({ "exported_material_id": row.exported_material_id, "material_id": row.material_id, "description": row.original_description, "category": row.classified_category, "size": row.size_inch, "schedule": row.schedule, "material_grade": row.material_grade, "quantity": row.quantity, "unit": row.unit, "user_requirement": row.user_requirement, "purchase_status": row.purchase_status, "purchase_request_no": row.purchase_request_no, "purchase_order_no": row.purchase_order_no, "vendor_name": row.vendor_name, "expected_date": row.expected_date.isoformat() if row.expected_date else None, "quantity_ordered": row.quantity_ordered, "quantity_received": row.quantity_received, "unit_price": float(row.unit_price) if row.unit_price else None, "total_price": float(row.total_price) if row.total_price else None, "notes": row.notes }) return { "success": True, "materials": materials, "count": len(materials) } except Exception as e: logger.error(f"Failed to get batch materials: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"배치 자재 조회 실패: {str(e)}" ) @router.get("/batch/{export_id}/download") async def download_batch_excel( export_id: int, current_user: dict = Depends(get_current_user), db: Session = Depends(get_db) ): """ 저장된 배치 엑셀 파일 다운로드 """ try: # 배치 정보 조회 query = text(""" SELECT file_name, notes FROM excel_export_history WHERE export_id = :export_id """) result = db.execute(query, {"export_id": export_id}).fetchone() if not result: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="배치를 찾을 수 없습니다" ) file_path = os.path.join(EXPORT_DIR, result.file_name) if not os.path.exists(file_path): # 파일이 없으면 재생성 materials = await get_batch_materials(export_id, current_user, db) batch_no = "" if result.notes and "배치번호:" in result.notes: batch_no = result.notes.split("배치번호:")[1].strip() batch_info = { "batch_no": batch_no, "export_date": datetime.now().strftime('%Y-%m-%d %H:%M') } file_name = create_excel_from_materials(materials["materials"], batch_info) file_path = os.path.join(EXPORT_DIR, file_name) # DB 업데이트 update_query = text(""" UPDATE excel_export_history SET file_name = :file_name WHERE export_id = :export_id """) db.execute(update_query, { "file_name": file_name, "export_id": export_id }) db.commit() return FileResponse( path=file_path, filename=result.file_name, media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) except HTTPException: raise except Exception as e: logger.error(f"Failed to download batch excel: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"엑셀 다운로드 실패: {str(e)}" ) @router.patch("/batch/{export_id}/status") async def update_batch_status( export_id: int, status: str, purchase_request_no: Optional[str] = None, purchase_order_no: Optional[str] = None, vendor_name: Optional[str] = None, current_user: dict = Depends(get_current_user), db: Session = Depends(get_db) ): """ 배치 전체 상태 일괄 업데이트 """ try: # 배치의 모든 자재 상태 업데이트 update_query = text(""" UPDATE exported_materials SET purchase_status = :status, purchase_request_no = COALESCE(:pr_no, purchase_request_no), purchase_order_no = COALESCE(:po_no, purchase_order_no), vendor_name = COALESCE(:vendor, vendor_name), updated_by = :updated_by, requested_date = CASE WHEN :status = 'requested' THEN CURRENT_TIMESTAMP ELSE requested_date END, ordered_date = CASE WHEN :status = 'ordered' THEN CURRENT_TIMESTAMP ELSE ordered_date END, received_date = CASE WHEN :status = 'received' THEN CURRENT_TIMESTAMP ELSE received_date END WHERE export_id = :export_id """) result = db.execute(update_query, { "export_id": export_id, "status": status, "pr_no": purchase_request_no, "po_no": purchase_order_no, "vendor": vendor_name, "updated_by": current_user.get("user_id") }) # 이력 기록 history_query = text(""" INSERT INTO purchase_status_history ( exported_material_id, material_id, previous_status, new_status, changed_by, reason ) SELECT em.id, em.material_id, em.purchase_status, :new_status, :changed_by, :reason FROM exported_materials em WHERE em.export_id = :export_id """) db.execute(history_query, { "export_id": export_id, "new_status": status, "changed_by": current_user.get("user_id"), "reason": f"배치 일괄 업데이트" }) db.commit() logger.info(f"Batch {export_id} status updated to {status}") return { "success": True, "message": f"배치의 모든 자재가 {status} 상태로 변경되었습니다", "updated_count": result.rowcount } except Exception as e: db.rollback() logger.error(f"Failed to update batch status: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"배치 상태 업데이트 실패: {str(e)}" )