diff --git a/backend/database/models.py b/backend/database/models.py index a7f1276..87a1f19 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -95,7 +95,10 @@ class Issue(Base): id = Column(Integer, primary_key=True, index=True) photo_path = Column(String) - photo_path2 = Column(String) # 두 번째 사진 경로 + photo_path2 = Column(String) + photo_path3 = Column(String) + photo_path4 = Column(String) + photo_path5 = Column(String) category = Column(Enum(IssueCategory), nullable=False) description = Column(Text, nullable=False) status = Column(Enum(IssueStatus), default=IssueStatus.new) @@ -120,7 +123,6 @@ class Issue(Base): duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록 # 관리함에서 사용할 추가 필드들 - completion_photo_path = Column(String) # 완료 사진 경로 solution = Column(Text) # 해결방안 (관리함에서 입력) responsible_department = Column(Enum(DepartmentType)) # 담당부서 responsible_person = Column(String(100)) # 담당자 @@ -141,7 +143,11 @@ class Issue(Base): # 완료 신청 관련 필드들 completion_requested_at = Column(DateTime) # 완료 신청 시간 completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자 - completion_photo_path = Column(String(500)) # 완료 사진 경로 + completion_photo_path = Column(String(500)) # 완료 사진 1 + completion_photo_path2 = Column(String(500)) # 완료 사진 2 + completion_photo_path3 = Column(String(500)) # 완료 사진 3 + completion_photo_path4 = Column(String(500)) # 완료 사진 4 + completion_photo_path5 = Column(String(500)) # 완료 사진 5 completion_comment = Column(Text) # 완료 코멘트 # 완료 반려 관련 필드들 @@ -149,6 +155,10 @@ class Issue(Base): completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자 completion_rejection_reason = Column(Text) # 완료 반려 사유 + # 일일보고서 추출 이력 + last_exported_at = Column(DateTime) # 마지막 일일보고서 추출 시간 + export_count = Column(Integer, default=0) # 추출 횟수 + # Relationships reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id]) reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues") diff --git a/backend/database/schemas.py b/backend/database/schemas.py index aea4838..ec1aa79 100644 --- a/backend/database/schemas.py +++ b/backend/database/schemas.py @@ -89,7 +89,10 @@ class IssueBase(BaseModel): class IssueCreate(IssueBase): photo: Optional[str] = None # Base64 encoded image - photo2: Optional[str] = None # Second Base64 encoded image + photo2: Optional[str] = None + photo3: Optional[str] = None + photo4: Optional[str] = None + photo5: Optional[str] = None class IssueUpdate(BaseModel): category: Optional[IssueCategory] = None @@ -99,12 +102,18 @@ class IssueUpdate(BaseModel): detail_notes: Optional[str] = None status: Optional[IssueStatus] = None photo: Optional[str] = None # Base64 encoded image for update - photo2: Optional[str] = None # Second Base64 encoded image for update + photo2: Optional[str] = None + photo3: Optional[str] = None + photo4: Optional[str] = None + photo5: Optional[str] = None class Issue(IssueBase): id: int photo_path: Optional[str] = None - photo_path2: Optional[str] = None # 두 번째 사진 경로 + photo_path2: Optional[str] = None + photo_path3: Optional[str] = None + photo_path4: Optional[str] = None + photo_path5: Optional[str] = None status: IssueStatus reporter_id: int reporter: User @@ -129,7 +138,6 @@ class Issue(IssueBase): duplicate_reporters: Optional[List[Dict[str, Any]]] = None # 관리함에서 사용할 추가 필드들 - completion_photo_path: Optional[str] = None # 완료 사진 경로 solution: Optional[str] = None # 해결방안 responsible_department: Optional[DepartmentType] = None # 담당부서 responsible_person: Optional[str] = None # 담당자 @@ -150,7 +158,11 @@ class Issue(IssueBase): # 완료 신청 관련 필드들 completion_requested_at: Optional[datetime] = None # 완료 신청 시간 completion_requested_by_id: Optional[int] = None # 완료 신청자 - completion_photo_path: Optional[str] = None # 완료 사진 경로 + completion_photo_path: Optional[str] = None # 완료 사진 1 + completion_photo_path2: Optional[str] = None # 완료 사진 2 + completion_photo_path3: Optional[str] = None # 완료 사진 3 + completion_photo_path4: Optional[str] = None # 완료 사진 4 + completion_photo_path5: Optional[str] = None # 완료 사진 5 completion_comment: Optional[str] = None # 완료 코멘트 # 완료 반려 관련 필드들 @@ -158,6 +170,10 @@ class Issue(IssueBase): completion_rejected_by_id: Optional[int] = None # 완료 반려자 completion_rejection_reason: Optional[str] = None # 완료 반려 사유 + # 일일보고서 추출 이력 + last_exported_at: Optional[datetime] = None # 마지막 일일보고서 추출 시간 + export_count: Optional[int] = 0 # 추출 횟수 + class Config: from_attributes = True @@ -202,7 +218,11 @@ class AdditionalInfoUpdateRequest(BaseModel): class CompletionRequestRequest(BaseModel): """완료 신청 요청""" - completion_photo: str # 완료 사진 (Base64) + completion_photo: Optional[str] = None # 완료 사진 1 (Base64) + completion_photo2: Optional[str] = None # 완료 사진 2 (Base64) + completion_photo3: Optional[str] = None # 완료 사진 3 (Base64) + completion_photo4: Optional[str] = None # 완료 사진 4 (Base64) + completion_photo5: Optional[str] = None # 완료 사진 5 (Base64) completion_comment: Optional[str] = None # 완료 코멘트 class CompletionRejectionRequest(BaseModel): @@ -220,7 +240,11 @@ class ManagementUpdateRequest(BaseModel): cause_department: Optional[DepartmentType] = None management_comment: Optional[str] = None completion_comment: Optional[str] = None - completion_photo: Optional[str] = None # Base64 + completion_photo: Optional[str] = None # Base64 - 완료 사진 1 + completion_photo2: Optional[str] = None # Base64 - 완료 사진 2 + completion_photo3: Optional[str] = None # Base64 - 완료 사진 3 + completion_photo4: Optional[str] = None # Base64 - 완료 사진 4 + completion_photo5: Optional[str] = None # Base64 - 완료 사진 5 review_status: Optional[ReviewStatus] = None class InboxIssue(BaseModel): diff --git a/backend/routers/issues.py b/backend/routers/issues.py index a73e964..8bdde62 100644 --- a/backend/routers/issues.py +++ b/backend/routers/issues.py @@ -20,22 +20,26 @@ async def create_issue( ): print(f"DEBUG: 받은 issue 데이터: {issue}") print(f"DEBUG: project_id: {issue.project_id}") - # 이미지 저장 - photo_path = None - photo_path2 = None - - if issue.photo: - photo_path = save_base64_image(issue.photo) - - if issue.photo2: - photo_path2 = save_base64_image(issue.photo2) - + # 이미지 저장 (최대 5장) + photo_paths = {} + for i in range(1, 6): + photo_field = f"photo{i if i > 1 else ''}" + path_field = f"photo_path{i if i > 1 else ''}" + photo_data = getattr(issue, photo_field, None) + if photo_data: + photo_paths[path_field] = save_base64_image(photo_data) + else: + photo_paths[path_field] = None + # Issue 생성 db_issue = Issue( category=issue.category, description=issue.description, - photo_path=photo_path, - photo_path2=photo_path2, + photo_path=photo_paths.get('photo_path'), + photo_path2=photo_paths.get('photo_path2'), + photo_path3=photo_paths.get('photo_path3'), + photo_path4=photo_paths.get('photo_path4'), + photo_path5=photo_paths.get('photo_path5'), reporter_id=current_user.id, project_id=issue.project_id, status=IssueStatus.new @@ -135,38 +139,27 @@ async def update_issue( # 업데이트 update_data = issue_update.dict(exclude_unset=True) - - # 첫 번째 사진이 업데이트되는 경우 처리 - if "photo" in update_data: - # 기존 사진 삭제 - if issue.photo_path: - delete_file(issue.photo_path) - - # 새 사진 저장 - if update_data["photo"]: - photo_path = save_base64_image(update_data["photo"]) - update_data["photo_path"] = photo_path - else: - update_data["photo_path"] = None - - # photo 필드는 제거 (DB에는 photo_path만 저장) - del update_data["photo"] - - # 두 번째 사진이 업데이트되는 경우 처리 - if "photo2" in update_data: - # 기존 사진 삭제 - if issue.photo_path2: - delete_file(issue.photo_path2) - - # 새 사진 저장 - if update_data["photo2"]: - photo_path2 = save_base64_image(update_data["photo2"]) - update_data["photo_path2"] = photo_path2 - else: - update_data["photo_path2"] = None - - # photo2 필드는 제거 (DB에는 photo_path2만 저장) - del update_data["photo2"] + + # 사진 업데이트 처리 (최대 5장) + for i in range(1, 6): + photo_field = f"photo{i if i > 1 else ''}" + path_field = f"photo_path{i if i > 1 else ''}" + + if photo_field in update_data: + # 기존 사진 삭제 + existing_path = getattr(issue, path_field, None) + if existing_path: + delete_file(existing_path) + + # 새 사진 저장 + if update_data[photo_field]: + new_path = save_base64_image(update_data[photo_field]) + update_data[path_field] = new_path + else: + update_data[path_field] = None + + # photo 필드는 제거 (DB에는 photo_path만 저장) + del update_data[photo_field] # work_hours가 입력되면 자동으로 상태를 complete로 변경 if "work_hours" in update_data and update_data["work_hours"] > 0: @@ -235,13 +228,19 @@ async def delete_issue( ) db.add(deletion_log) - # 이미지 파일 삭제 - if issue.photo_path: - delete_file(issue.photo_path) - if issue.photo_path2: - delete_file(issue.photo_path2) - if issue.completion_photo_path: - delete_file(issue.completion_photo_path) + # 이미지 파일 삭제 (신고 사진 최대 5장) + for i in range(1, 6): + path_field = f"photo_path{i if i > 1 else ''}" + path = getattr(issue, path_field, None) + if path: + delete_file(path) + + # 완료 사진 삭제 (최대 5장) + for i in range(1, 6): + path_field = f"completion_photo_path{i if i > 1 else ''}" + path = getattr(issue, path_field, None) + if path: + delete_file(path) db.delete(issue) db.commit() @@ -300,18 +299,32 @@ async def update_issue_management( update_data = management_update.dict(exclude_unset=True) print(f"DEBUG: Update data dict: {update_data}") - for field, value in update_data.items(): - print(f"DEBUG: Processing field {field} = {value}") - if field == 'completion_photo' and value: - # 완료 사진 업로드 처리 + # 완료 사진 처리 (최대 5장) + completion_photo_fields = [] + for i in range(1, 6): + photo_field = f"completion_photo{i if i > 1 else ''}" + path_field = f"completion_photo_path{i if i > 1 else ''}" + + if photo_field in update_data and update_data[photo_field]: + completion_photo_fields.append(photo_field) try: - completion_photo_path = save_base64_image(value, "completion") - setattr(issue, 'completion_photo_path', completion_photo_path) - print(f"DEBUG: Saved completion photo: {completion_photo_path}") + # 기존 사진 삭제 + existing_path = getattr(issue, path_field, None) + if existing_path: + delete_file(existing_path) + + # 새 사진 저장 + new_path = save_base64_image(update_data[photo_field], "completion") + setattr(issue, path_field, new_path) + print(f"DEBUG: Saved {photo_field}: {new_path}") except Exception as e: - print(f"DEBUG: Photo save error: {str(e)}") + print(f"DEBUG: Photo save error for {photo_field}: {str(e)}") raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}") - elif field != 'completion_photo': # completion_photo는 위에서 처리됨 + + # 나머지 필드 처리 (완료 사진 제외) + for field, value in update_data.items(): + if field not in completion_photo_fields: + print(f"DEBUG: Processing field {field} = {value}") try: setattr(issue, field, value) print(f"DEBUG: Set {field} = {value}") @@ -359,22 +372,28 @@ async def request_completion( try: print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}") - - # 완료 사진 저장 - completion_photo_path = None - if request.completion_photo: - print(f"DEBUG: 완료 사진 저장 시작") - completion_photo_path = save_base64_image(request.completion_photo, "completion") - print(f"DEBUG: 완료 사진 저장 완료 - Path: {completion_photo_path}") - - if not completion_photo_path: - raise Exception("완료 사진 저장에 실패했습니다.") - + + # 완료 사진 저장 (최대 5장) + saved_paths = [] + for i in range(1, 6): + photo_field = f"completion_photo{i if i > 1 else ''}" + path_field = f"completion_photo_path{i if i > 1 else ''}" + photo_data = getattr(request, photo_field, None) + + if photo_data: + print(f"DEBUG: {photo_field} 저장 시작") + saved_path = save_base64_image(photo_data, "completion") + if saved_path: + setattr(issue, path_field, saved_path) + saved_paths.append(saved_path) + print(f"DEBUG: {photo_field} 저장 완료 - Path: {saved_path}") + else: + raise Exception(f"{photo_field} 저장에 실패했습니다.") + # 완료 신청 정보 업데이트 print(f"DEBUG: DB 업데이트 시작") issue.completion_requested_at = datetime.now() issue.completion_requested_by_id = current_user.id - issue.completion_photo_path = completion_photo_path issue.completion_comment = request.completion_comment db.commit() @@ -385,18 +404,19 @@ async def request_completion( "message": "완료 신청이 성공적으로 제출되었습니다.", "issue_id": issue.id, "completion_requested_at": issue.completion_requested_at, - "completion_photo_path": completion_photo_path + "completion_photo_paths": saved_paths } - + except Exception as e: print(f"ERROR: 완료 신청 처리 오류 - {str(e)}") db.rollback() # 업로드된 파일이 있다면 삭제 - if 'completion_photo_path' in locals() and completion_photo_path: - try: - delete_file(completion_photo_path) - except: - pass + if 'saved_paths' in locals(): + for path in saved_paths: + try: + delete_file(path) + except: + pass raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}") @router.post("/{issue_id}/reject-completion") @@ -425,18 +445,23 @@ async def reject_completion_request( try: print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}") - # 완료 사진 파일 삭제 - if issue.completion_photo_path: - try: - delete_file(issue.completion_photo_path) - print(f"DEBUG: 완료 사진 삭제 완료") - except Exception as e: - print(f"WARNING: 완료 사진 삭제 실패 - {str(e)}") + # 완료 사진 파일 삭제 (최대 5장) + for i in range(1, 6): + path_field = f"completion_photo_path{i if i > 1 else ''}" + photo_path = getattr(issue, path_field, None) + if photo_path: + try: + delete_file(photo_path) + print(f"DEBUG: {path_field} 삭제 완료") + except Exception as e: + print(f"WARNING: {path_field} 삭제 실패 - {str(e)}") # 완료 신청 정보 초기화 issue.completion_requested_at = None issue.completion_requested_by_id = None - issue.completion_photo_path = None + for i in range(1, 6): + path_field = f"completion_photo_path{i if i > 1 else ''}" + setattr(issue, path_field, None) issue.completion_comment = None # 완료 반려 정보 기록 (전용 필드 사용) diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 98410ac..30a95e6 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -5,9 +5,12 @@ from sqlalchemy import func, and_, or_ from datetime import datetime, date from typing import List import io +import re from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter +from openpyxl.drawing.image import Image as XLImage +import os from database.database import get_db from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus @@ -134,6 +137,53 @@ async def get_report_daily_works( "total_hours": work.total_hours } for work in works] +@router.get("/daily-preview") +async def preview_daily_report( + project_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """일일보고서 미리보기 - 추출될 항목 목록""" + + # 권한 확인 + if current_user.role != UserRole.admin: + raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다") + + # 프로젝트 확인 + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다") + + # 추출될 항목 조회 (진행 중 + 미추출 완료 항목) + issues_query = db.query(Issue).filter( + Issue.project_id == project_id, + or_( + Issue.review_status == ReviewStatus.in_progress, + and_( + Issue.review_status == ReviewStatus.completed, + Issue.last_exported_at == None + ) + ) + ) + + issues = issues_query.all() + + # 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순 + issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0)) + + # 통계 계산 + stats = calculate_project_stats(issues) + + # 이슈 리스트를 schema로 변환 + issues_data = [schemas.Issue.from_orm(issue) for issue in issues] + + return { + "project": schemas.Project.from_orm(project), + "stats": stats, + "issues": issues_data, + "total_issues": len(issues) + } + @router.post("/daily-export") async def export_daily_report( request: schemas.DailyReportRequest, @@ -151,23 +201,79 @@ async def export_daily_report( if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다") - # 관리함 데이터 조회 (진행 중 + 완료됨) - issues_query = db.query(Issue).filter( + # 관리함 데이터 조회 + # 1. 진행 중인 항목 (모두 포함) + in_progress_only = db.query(Issue).filter( Issue.project_id == request.project_id, - Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed]) - ).order_by(Issue.report_date.desc()) - - issues = issues_query.all() + Issue.review_status == ReviewStatus.in_progress + ).all() + + # 2. 완료된 항목 (모두 조회) + all_completed = db.query(Issue).filter( + Issue.project_id == request.project_id, + Issue.review_status == ReviewStatus.completed + ).all() + + # 완료 항목 중 "완료 후 추출 안된 것" 필터링 + # 규칙: 완료 처리 후 1회에 한해서만 "진행 중" 시트에 표시 + not_exported_after_completion = [] + for issue in all_completed: + if issue.last_exported_at is None: + # 한번도 추출 안됨 -> 진행 중 시트에 표시 (완료 후 첫 추출) + not_exported_after_completion.append(issue) + elif issue.actual_completion_date: + # actual_completion_date가 있는 경우: 완료일과 마지막 추출일 비교 + completion_date = issue.actual_completion_date.replace(tzinfo=None) if issue.actual_completion_date.tzinfo else issue.actual_completion_date + export_date = issue.last_exported_at.replace(tzinfo=None) if issue.last_exported_at.tzinfo else issue.last_exported_at + if completion_date > export_date: + # 완료일이 마지막 추출일보다 나중 -> 완료 후 아직 추출 안됨 -> 진행 중 시트에 표시 + not_exported_after_completion.append(issue) + # else: 완료일이 마지막 추출일보다 이전 -> 이미 완료 후 추출됨 -> 완료됨 시트로 + # else: actual_completion_date가 없고 last_exported_at가 있음 + # -> 이미 한번 이상 추출됨 -> 완료됨 시트로 + + # "진행 중" 시트용: 진행 중 + 완료되고 아직 추출 안된 것 + in_progress_issues = in_progress_only + not_exported_after_completion + + # 진행 중 시트 정렬: 지연중 -> 진행중 -> 완료됨 순서 + in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0)) + + # "완료됨" 시트용: 완료 항목 중 "완료 후 추출된 것"만 (진행 중 시트에 표시되는 것 제외) + not_exported_ids = {issue.id for issue in not_exported_after_completion} + completed_issues = [issue for issue in all_completed if issue.id not in not_exported_ids] + + # 완료됨 시트도 정렬 (완료일 최신순) + completed_issues = sorted(completed_issues, key=lambda x: -x.actual_completion_date.timestamp() if x.actual_completion_date else 0) + + # 웹과 동일한 로직: 진행중 + 완료를 함께 정렬하여 순번 할당 + # (웹에서는 in_progress와 completed를 함께 가져와서 전체를 reviewed_at 순으로 정렬 후 순번 매김) + all_management_issues = in_progress_only + all_completed + + # reviewed_at 기준으로 정렬 (웹과 동일) + all_management_issues = sorted(all_management_issues, key=lambda x: x.reviewed_at if x.reviewed_at else datetime.min) + + # 프로젝트별로 그룹화하여 순번 할당 (웹과 동일한 로직) + project_groups = {} + for issue in all_management_issues: + if issue.project_id not in project_groups: + project_groups[issue.project_id] = [] + project_groups[issue.project_id].append(issue) + + # 각 프로젝트별로 순번 재할당 (웹과 동일) + for project_id, project_issues in project_groups.items(): + for idx, issue in enumerate(project_issues, 1): + issue._display_no = idx + + # 전체 이슈 (통계 계산용 및 추출 이력 업데이트용) + issues = list(set(in_progress_only + all_completed)) # 중복 제거 # 통계 계산 stats = calculate_project_stats(issues) # 엑셀 파일 생성 wb = Workbook() - ws = wb.active - ws.title = "일일보고서" - - # 스타일 정의 + + # 스타일 정의 (공통) header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") stats_font = Font(bold=True, size=12) @@ -179,113 +285,452 @@ async def export_daily_report( bottom=Side(style='thin') ) center_alignment = Alignment(horizontal='center', vertical='center') - - # 제목 및 기본 정보 - ws.merge_cells('A1:L1') - ws['A1'] = f"{project.project_name} - 일일보고서" - ws['A1'].font = Font(bold=True, size=16) - ws['A1'].alignment = center_alignment - - ws.merge_cells('A2:L2') - ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}" - ws['A2'].alignment = center_alignment - - # 프로젝트 통계 (4행부터) - ws.merge_cells('A4:L4') - ws['A4'] = "프로젝트 현황" - ws['A4'].font = stats_font - ws['A4'].fill = stats_fill - ws['A4'].alignment = center_alignment - - # 통계 데이터 - stats_row = 5 - ws[f'A{stats_row}'] = "총 신고 수량" - ws[f'B{stats_row}'] = stats.total_count - ws[f'D{stats_row}'] = "관리처리 현황" - ws[f'E{stats_row}'] = stats.management_count - ws[f'G{stats_row}'] = "완료 현황" - ws[f'H{stats_row}'] = stats.completed_count - ws[f'J{stats_row}'] = "지연 중" - ws[f'K{stats_row}'] = stats.delayed_count - - # 통계 스타일 적용 - for col in ['A', 'D', 'G', 'J']: - ws[f'{col}{stats_row}'].font = Font(bold=True) - ws[f'{col}{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") - - # 데이터 테이블 헤더 (7행부터) - headers = [ - "번호", "프로젝트", "부적합명", "상세내용", "원인분류", - "해결방안", "담당부서", "담당자", "마감일", "상태", - "신고일", "완료일" + card_header_font = Font(bold=True, color="FFFFFF", size=11) + label_font = Font(bold=True, size=10) + label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid") + content_font = Font(size=10) + thick_border = Border( + left=Side(style='medium'), + right=Side(style='medium'), + top=Side(style='medium'), + bottom=Side(style='medium') + ) + + # 두 개의 시트를 생성하고 각각 데이터 입력 + sheets_data = [ + (wb.active, in_progress_issues, "진행 중"), + (wb.create_sheet(title="완료됨"), completed_issues, "완료됨") ] - - header_row = 7 - for col, header in enumerate(headers, 1): - cell = ws.cell(row=header_row, column=col, value=header) - cell.font = header_font - cell.fill = header_fill - cell.alignment = center_alignment - cell.border = border - - # 데이터 입력 - current_row = header_row + 1 - - for issue in issues: - # 완료됨 항목의 첫 내보내기 여부 확인 (실제로는 DB에 플래그를 저장해야 함) - # 지금은 모든 완료됨 항목을 포함 - - ws.cell(row=current_row, column=1, value=issue.id) - ws.cell(row=current_row, column=2, value=project.project_name) - ws.cell(row=current_row, column=3, value=issue.description or "") - ws.cell(row=current_row, column=4, value=issue.detail_notes or "") - ws.cell(row=current_row, column=5, value=get_category_text(issue.category)) - ws.cell(row=current_row, column=6, value=issue.solution or "") - ws.cell(row=current_row, column=7, value=get_department_text(issue.responsible_department)) - ws.cell(row=current_row, column=8, value=issue.responsible_person or "") - ws.cell(row=current_row, column=9, value=issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else "") - ws.cell(row=current_row, column=10, value=get_status_text(issue.review_status)) - ws.cell(row=current_row, column=11, value=issue.report_date.strftime('%Y-%m-%d') if issue.report_date else "") - ws.cell(row=current_row, column=12, value=issue.actual_completion_date.strftime('%Y-%m-%d') if issue.actual_completion_date else "") - - # 상태별 색상 적용 - status_color = get_status_color(issue.review_status) - if status_color: - for col in range(1, len(headers) + 1): - ws.cell(row=current_row, column=col).fill = PatternFill( - start_color=status_color, end_color=status_color, fill_type="solid" + + sheets_data[0][0].title = "진행 중" + + for ws, sheet_issues, sheet_title in sheets_data: + # 제목 및 기본 정보 + ws.merge_cells('A1:L1') + ws['A1'] = f"{project.project_name} - {sheet_title}" + ws['A1'].font = Font(bold=True, size=16) + ws['A1'].alignment = center_alignment + + ws.merge_cells('A2:L2') + ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}" + ws['A2'].alignment = center_alignment + + # 프로젝트 통계 (4행부터) - 진행 중 시트에만 표시 + if sheet_title == "진행 중": + ws.merge_cells('A4:L4') + ws['A4'] = "📊 프로젝트 현황" + ws['A4'].font = Font(bold=True, size=14, color="FFFFFF") + ws['A4'].fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + ws['A4'].alignment = center_alignment + ws.row_dimensions[4].height = 25 + + # 통계 데이터 - 박스 형태로 개선 + stats_row = 5 + ws.row_dimensions[stats_row].height = 30 + + # 총 신고 수량 (파란색 계열) + ws.merge_cells(f'A{stats_row}:B{stats_row}') + ws[f'A{stats_row}'] = "총 신고 수량" + ws[f'A{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF") + ws[f'A{stats_row}'].fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid") + ws[f'A{stats_row}'].alignment = center_alignment + + ws[f'C{stats_row}'] = stats.total_count + ws[f'C{stats_row}'].font = Font(bold=True, size=14) + ws[f'C{stats_row}'].fill = PatternFill(start_color="DEEBF7", end_color="DEEBF7", fill_type="solid") + ws[f'C{stats_row}'].alignment = center_alignment + + # 진행 현황 (노란색 계열) + ws.merge_cells(f'D{stats_row}:E{stats_row}') + ws[f'D{stats_row}'] = "진행 현황" + ws[f'D{stats_row}'].font = Font(bold=True, size=11, color="000000") + ws[f'D{stats_row}'].fill = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid") + ws[f'D{stats_row}'].alignment = center_alignment + + ws[f'F{stats_row}'] = stats.management_count + ws[f'F{stats_row}'].font = Font(bold=True, size=14) + ws[f'F{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") + ws[f'F{stats_row}'].alignment = center_alignment + + # 완료 현황 (초록색 계열) + ws.merge_cells(f'G{stats_row}:H{stats_row}') + ws[f'G{stats_row}'] = "완료 현황" + ws[f'G{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF") + ws[f'G{stats_row}'].fill = PatternFill(start_color="70AD47", end_color="70AD47", fill_type="solid") + ws[f'G{stats_row}'].alignment = center_alignment + + ws[f'I{stats_row}'] = stats.completed_count + ws[f'I{stats_row}'].font = Font(bold=True, size=14) + ws[f'I{stats_row}'].fill = PatternFill(start_color="C6E0B4", end_color="C6E0B4", fill_type="solid") + ws[f'I{stats_row}'].alignment = center_alignment + + # 지연 중 (빨간색 계열) + ws.merge_cells(f'J{stats_row}:K{stats_row}') + ws[f'J{stats_row}'] = "지연 중" + ws[f'J{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF") + ws[f'J{stats_row}'].fill = PatternFill(start_color="E74C3C", end_color="E74C3C", fill_type="solid") + ws[f'J{stats_row}'].alignment = center_alignment + + ws[f'L{stats_row}'] = stats.delayed_count + ws[f'L{stats_row}'].font = Font(bold=True, size=14) + ws[f'L{stats_row}'].fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") + ws[f'L{stats_row}'].alignment = center_alignment + + # 통계 박스에 테두리 적용 + thick_border = Border( + left=Side(style='medium'), + right=Side(style='medium'), + top=Side(style='medium'), + bottom=Side(style='medium') + ) + + for col in ['A', 'C', 'D', 'F', 'G', 'I', 'J', 'L']: + if col in ['A', 'D', 'G', 'J']: # 병합된 셀의 시작점 + for c in range(ord(col), ord(col) + 2): # 병합된 2개 셀 + ws.cell(row=stats_row, column=c - ord('A') + 1).border = thick_border + else: # 숫자 셀 + ws[f'{col}{stats_row}'].border = thick_border + + # 카드 형태로 데이터 입력 (완료됨 시트는 4행부터, 진행 중 시트는 7행부터) + current_row = 4 if sheet_title == "완료됨" else 7 + + for idx, issue in enumerate(sheet_issues, 1): + card_start_row = current_row + + # 상태별 헤더 색상 설정 + header_color = get_issue_status_header_color(issue) + card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid") + + # 카드 헤더 (No, 상태, 신고일) - L열까지 확장 + ws.merge_cells(f'A{current_row}:C{current_row}') + # 동적으로 할당된 프로젝트별 순번 사용 (웹과 동일) + issue_no = getattr(issue, '_display_no', issue.project_sequence_no or issue.id) + ws[f'A{current_row}'] = f"No. {issue_no}" + ws[f'A{current_row}'].font = card_header_font + ws[f'A{current_row}'].fill = card_header_fill + ws[f'A{current_row}'].alignment = center_alignment + + ws.merge_cells(f'D{current_row}:G{current_row}') + ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}" + ws[f'D{current_row}'].font = card_header_font + ws[f'D{current_row}'].fill = card_header_fill + ws[f'D{current_row}'].alignment = center_alignment + + ws.merge_cells(f'H{current_row}:L{current_row}') + ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}" + ws[f'H{current_row}'].font = card_header_font + ws[f'H{current_row}'].fill = card_header_fill + ws[f'H{current_row}'].alignment = center_alignment + current_row += 1 + + # 부적합명 + ws[f'A{current_row}'] = "부적합명" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + + # final_description이 있으면 사용, 없으면 description 사용 + issue_title = issue.final_description or issue.description or "내용 없음" + ws[f'B{current_row}'] = issue_title + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top') + current_row += 1 + + # 상세내용 (detail_notes가 실제 상세 설명) + if issue.detail_notes: + ws[f'A{current_row}'] = "상세내용" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + ws[f'B{current_row}'] = issue.detail_notes + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top') + ws.row_dimensions[current_row].height = 50 + current_row += 1 + + # 원인분류 + ws[f'A{current_row}'] = "원인분류" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:C{current_row}') + ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category) + ws[f'B{current_row}'].font = content_font + + ws[f'D{current_row}'] = "원인부서" + ws[f'D{current_row}'].font = label_font + ws[f'D{current_row}'].fill = label_fill + ws.merge_cells(f'E{current_row}:F{current_row}') + ws[f'E{current_row}'] = get_department_text(issue.cause_department) + ws[f'E{current_row}'].font = content_font + + ws[f'G{current_row}'] = "신고자" + ws[f'G{current_row}'].font = label_font + ws[f'G{current_row}'].fill = label_fill + ws.merge_cells(f'H{current_row}:L{current_row}') + ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-" + ws[f'H{current_row}'].font = content_font + current_row += 1 + + # 해결방안 (완료 반려 내용 및 댓글 제거) + ws[f'A{current_row}'] = "해결방안" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + + # management_comment에서 완료 반려 패턴과 댓글 제거 + clean_solution = clean_management_comment_for_export(issue.management_comment) + ws[f'B{current_row}'] = clean_solution + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬 + ws.row_dimensions[current_row].height = 30 + current_row += 1 + + # 담당정보 + ws[f'A{current_row}'] = "담당부서" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:C{current_row}') + ws[f'B{current_row}'] = get_department_text(issue.responsible_department) + ws[f'B{current_row}'].font = content_font + + ws[f'D{current_row}'] = "담당자" + ws[f'D{current_row}'].font = label_font + ws[f'D{current_row}'].fill = label_fill + ws.merge_cells(f'E{current_row}:G{current_row}') + ws[f'E{current_row}'] = issue.responsible_person or "" + ws[f'E{current_row}'].font = content_font + + ws[f'H{current_row}'] = "조치예상일" + ws[f'H{current_row}'].font = label_font + ws[f'H{current_row}'].fill = label_fill + ws.merge_cells(f'I{current_row}:L{current_row}') + ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else "" + ws[f'I{current_row}'].font = content_font + ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가 + current_row += 1 + + # === 신고 사진 영역 === + report_photos = [ + issue.photo_path, + issue.photo_path2, + issue.photo_path3, + issue.photo_path4, + issue.photo_path5 + ] + report_photos = [p for p in report_photos if p] # None 제거 + + if report_photos: + # 라벨 행 (A~L 전체 병합) + ws.merge_cells(f'A{current_row}:L{current_row}') + ws[f'A{current_row}'] = "신고 사진" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색 + ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center') + ws.row_dimensions[current_row].height = 18 + current_row += 1 + + # 사진들을 한 행에 일렬로 표시 (간격 좁게) + ws.merge_cells(f'A{current_row}:L{current_row}') + report_image_inserted = False + + # 사진 위치: A, C, E, G, I (2열 간격) + photo_columns = ['A', 'C', 'E', 'G', 'I'] + + for idx, photo in enumerate(report_photos): + if idx >= 5: # 최대 5장 + break + photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo + if os.path.exists(photo_path): + try: + img = XLImage(photo_path) + img.width = min(img.width, 200) # 크기 줄임 + img.height = min(img.height, 150) + ws.add_image(img, f'{photo_columns[idx]}{current_row}') + report_image_inserted = True + except Exception as e: + print(f"이미지 삽입 실패 ({photo_path}): {e}") + + if report_image_inserted: + ws.row_dimensions[current_row].height = 120 + else: + ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음" + ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999") + ws.row_dimensions[current_row].height = 20 + current_row += 1 + + # === 완료 관련 정보 (완료된 항목만) === + if issue.review_status == ReviewStatus.completed: + # 완료 코멘트 + if issue.completion_comment: + ws[f'A{current_row}'] = "완료 의견" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") + ws.merge_cells(f'B{current_row}:L{current_row}') + ws[f'B{current_row}'] = issue.completion_comment + ws[f'B{current_row}'].font = content_font + ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬 + ws.row_dimensions[current_row].height = 30 + current_row += 1 + + # 완료 사진 + completion_photos = [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ] + completion_photos = [p for p in completion_photos if p] # None 제거 + + if completion_photos: + # 라벨 행 (A~L 전체 병합) + ws.merge_cells(f'A{current_row}:L{current_row}') + ws[f'A{current_row}'] = "완료 사진" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색 + ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center') + ws.row_dimensions[current_row].height = 18 + current_row += 1 + + # 사진들을 한 행에 일렬로 표시 (간격 좁게) + ws.merge_cells(f'A{current_row}:L{current_row}') + completion_image_inserted = False + + # 사진 위치: A, C, E, G, I (2열 간격) + photo_columns = ['A', 'C', 'E', 'G', 'I'] + + for idx, photo in enumerate(completion_photos): + if idx >= 5: # 최대 5장 + break + photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo + if os.path.exists(photo_path): + try: + img = XLImage(photo_path) + img.width = min(img.width, 200) # 크기 줄임 + img.height = min(img.height, 150) + ws.add_image(img, f'{photo_columns[idx]}{current_row}') + completion_image_inserted = True + except Exception as e: + print(f"이미지 삽입 실패 ({photo_path}): {e}") + + if completion_image_inserted: + ws.row_dimensions[current_row].height = 120 + else: + ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음" + ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999") + ws.row_dimensions[current_row].height = 20 + current_row += 1 + + # 완료일 정보 + if issue.actual_completion_date: + ws[f'A{current_row}'] = "완료일" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") + ws.merge_cells(f'B{current_row}:C{current_row}') + ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d') + ws[f'B{current_row}'].font = content_font + current_row += 1 + + # 사진이 하나도 없을 경우 + # 진행중: 신고 사진만 체크 + # 완료됨: 신고 사진 + 완료 사진 체크 + has_completion_photos = False + if issue.review_status == ReviewStatus.completed: + comp_photos = [p for p in [ + issue.completion_photo_path, + issue.completion_photo_path2, + issue.completion_photo_path3, + issue.completion_photo_path4, + issue.completion_photo_path5 + ] if p] + has_completion_photos = bool(comp_photos) + has_any_photo = bool(report_photos) or has_completion_photos + + if not has_any_photo: + ws[f'A{current_row}'] = "첨부사진" + ws[f'A{current_row}'].font = label_font + ws[f'A{current_row}'].fill = label_fill + ws.merge_cells(f'B{current_row}:L{current_row}') + ws[f'B{current_row}'] = "첨부된 사진 없음" + ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999") + current_row += 1 + + # 카드 전체에 테두리 적용 (A-L 열) + card_end_row = current_row - 1 + for row in range(card_start_row, card_end_row + 1): + for col in range(1, 13): # A-L 열 (12열) + cell = ws.cell(row=row, column=col) + if not cell.border or cell.border.left.style != 'medium': + cell.border = border + + # 카드 외곽에 굵은 테두리 (A-L 열) - 상태별 색상 적용 + border_color = header_color # 헤더와 같은 색상 사용 + for col in range(1, 13): + ws.cell(row=card_start_row, column=col).border = Border( + left=Side(style='medium' if col == 1 else 'thin', color=border_color), + right=Side(style='medium' if col == 12 else 'thin', color=border_color), + top=Side(style='medium', color=border_color), + bottom=Side(style='thin', color=border_color) ) - - # 테두리 적용 - for col in range(1, len(headers) + 1): - ws.cell(row=current_row, column=col).border = border - ws.cell(row=current_row, column=col).alignment = Alignment(vertical='center') - - current_row += 1 + ws.cell(row=card_end_row, column=col).border = Border( + left=Side(style='medium' if col == 1 else 'thin', color=border_color), + right=Side(style='medium' if col == 12 else 'thin', color=border_color), + top=Side(style='thin', color=border_color), + bottom=Side(style='medium', color=border_color) + ) + + # 카드 좌우 테두리도 색상 적용 + for row in range(card_start_row + 1, card_end_row): + ws.cell(row=row, column=1).border = Border( + left=Side(style='medium', color=border_color), + right=ws.cell(row=row, column=1).border.right if ws.cell(row=row, column=1).border else Side(style='thin'), + top=ws.cell(row=row, column=1).border.top if ws.cell(row=row, column=1).border else Side(style='thin'), + bottom=ws.cell(row=row, column=1).border.bottom if ws.cell(row=row, column=1).border else Side(style='thin') + ) + ws.cell(row=row, column=12).border = Border( + left=ws.cell(row=row, column=12).border.left if ws.cell(row=row, column=12).border else Side(style='thin'), + right=Side(style='medium', color=border_color), + top=ws.cell(row=row, column=12).border.top if ws.cell(row=row, column=12).border else Side(style='thin'), + bottom=ws.cell(row=row, column=12).border.bottom if ws.cell(row=row, column=12).border else Side(style='thin') + ) + + # 카드 구분 (빈 행) + current_row += 1 - # 열 너비 자동 조정 - for col in range(1, len(headers) + 1): - column_letter = get_column_letter(col) - ws.column_dimensions[column_letter].width = 15 - - # 특정 열 너비 조정 - ws.column_dimensions['C'].width = 20 # 부적합명 - ws.column_dimensions['D'].width = 30 # 상세내용 - ws.column_dimensions['F'].width = 25 # 해결방안 + # 열 너비 조정 + ws.column_dimensions['A'].width = 12 # 레이블 열 + ws.column_dimensions['B'].width = 15 # 내용 열 + ws.column_dimensions['C'].width = 15 # 내용 열 + ws.column_dimensions['D'].width = 15 # 내용 열 + ws.column_dimensions['E'].width = 15 # 내용 열 + ws.column_dimensions['F'].width = 15 # 내용 열 + ws.column_dimensions['G'].width = 15 # 내용 열 + ws.column_dimensions['H'].width = 15 # 내용 열 + ws.column_dimensions['I'].width = 15 # 내용 열 + ws.column_dimensions['J'].width = 15 # 내용 열 + ws.column_dimensions['K'].width = 15 # 내용 열 + ws.column_dimensions['L'].width = 15 # 내용 열 # 엑셀 파일을 메모리에 저장 excel_buffer = io.BytesIO() wb.save(excel_buffer) excel_buffer.seek(0) - + + # 추출 이력 업데이트 + export_time = datetime.now() + for issue in issues: + issue.last_exported_at = export_time + issue.export_count = (issue.export_count or 0) + 1 + db.commit() + # 파일명 생성 today = date.today().strftime('%Y%m%d') filename = f"{project.project_name}_일일보고서_{today}.xlsx" - + # 한글 파일명을 위한 URL 인코딩 from urllib.parse import quote encoded_filename = quote(filename.encode('utf-8')) - + return StreamingResponse( io.BytesIO(excel_buffer.read()), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -297,22 +742,24 @@ async def export_daily_report( def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats: """프로젝트 통계 계산""" stats = schemas.DailyReportStats() - + today = date.today() - + for issue in issues: stats.total_count += 1 - + if issue.review_status == ReviewStatus.in_progress: stats.management_count += 1 - - # 지연 여부 확인 - if issue.expected_completion_date and issue.expected_completion_date < today: - stats.delayed_count += 1 - + + # 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우) + if issue.expected_completion_date: + expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date + if expected_date < today: + stats.delayed_count += 1 + elif issue.review_status == ReviewStatus.completed: stats.completed_count += 1 - + return stats def get_category_text(category: IssueCategory) -> str: @@ -350,6 +797,29 @@ def get_status_text(status: ReviewStatus) -> str: } return status_map.get(status, str(status)) +def clean_management_comment_for_export(text: str) -> str: + """엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)""" + if not text: + return "" + + # 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용) + text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text) + + # 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들) + lines = text.split('\n') + filtered_lines = [] + for line in lines: + # └ 또는 ↳로 시작하는 줄 제외 + if not re.match(r'^\s*[└↳]', line): + filtered_lines.append(line) + + # 3. 빈 줄 정리 + result = '\n'.join(filtered_lines).strip() + # 연속된 빈 줄을 하나로 + result = re.sub(r'\n{3,}', '\n\n', result) + + return result + def get_status_color(status: ReviewStatus) -> str: """상태별 색상 반환""" color_map = { @@ -358,3 +828,46 @@ def get_status_color(status: ReviewStatus) -> str: ReviewStatus.disposed: "F2F2F2" # 연한 회색 } return color_map.get(status, None) + + +def get_issue_priority(issue: Issue) -> int: + """이슈 우선순위 반환 (엑셀 정렬용) + 1: 지연 (빨강) + 2: 진행중 (노랑) + 3: 완료됨 (초록) + """ + if issue.review_status == ReviewStatus.completed: + return 3 + elif issue.review_status == ReviewStatus.in_progress: + # 조치 예상일이 지난 경우 지연 + if issue.expected_completion_date: + today = date.today() + expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date + if expected_date < today: + return 1 # 지연 + return 2 # 진행중 + return 2 + +def get_issue_status_text(issue: Issue) -> str: + """이슈 상태 텍스트 반환""" + if issue.review_status == ReviewStatus.completed: + return "완료됨" + elif issue.review_status == ReviewStatus.in_progress: + if issue.expected_completion_date: + today = date.today() + expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date + if expected_date < today: + return "지연중" + return "진행중" + return get_status_text(issue.review_status) + +def get_issue_status_header_color(issue: Issue) -> str: + """이슈 상태별 헤더 색상 반환""" + priority = get_issue_priority(issue) + if priority == 1: # 지연 + return "E74C3C" # 빨간색 + elif priority == 2: # 진행중 + return "FFD966" # 노랑색 (주황색 사이) + elif priority == 3: # 완료 + return "92D050" # 진한 초록색 + return "4472C4" # 기본 파란색 diff --git a/backend/services/file_service.py b/backend/services/file_service.py index 838e504..a10d134 100644 --- a/backend/services/file_service.py +++ b/backend/services/file_service.py @@ -54,31 +54,30 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str print(f"🔍 HEIC 파일 여부: {is_heic}") # 이미지 검증 및 형식 확인 + image = None try: - # HEIC 파일인 경우 바로 HEIF 처리 시도 + # HEIC 파일인 경우 pillow_heif를 직접 사용하여 처리 if is_heic and HEIF_SUPPORTED: - print("🔄 HEIC 파일 감지, HEIF 처리 시도...") - image = Image.open(io.BytesIO(image_data)) - print(f"✅ HEIF 이미지 로드 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}") + print("🔄 HEIC 파일 감지, pillow_heif로 직접 처리...") + try: + import pillow_heif + heif_file = pillow_heif.open_heif(io.BytesIO(image_data), convert_hdr_to_8bit=False) + image = heif_file.to_pillow() + print(f"✅ HEIC -> PIL 변환 성공: 모드: {image.mode}, 크기: {image.size}") + except Exception as heic_error: + print(f"⚠️ pillow_heif 직접 처리 실패: {heic_error}") + # PIL Image.open()으로 재시도 + image = Image.open(io.BytesIO(image_data)) + print(f"✅ PIL Image.open() 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}") else: # 일반 이미지 처리 image = Image.open(io.BytesIO(image_data)) print(f"🔍 이미지 형식: {image.format}, 모드: {image.mode}, 크기: {image.size}") except Exception as e: print(f"❌ 이미지 열기 실패: {e}") - - # HEIC 파일인 경우 원본 파일로 저장 - if is_heic: - print("🔄 HEIC 파일 - 원본 바이너리 파일로 저장 시도...") - filename = f"{prefix}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.heic" - filepath = os.path.join(UPLOAD_DIR, filename) - with open(filepath, 'wb') as f: - f.write(image_data) - print(f"✅ 원본 HEIC 파일 저장: {filepath}") - return f"/uploads/{filename}" - - # HEIC가 아닌 경우에만 HEIF 재시도 - elif HEIF_SUPPORTED: + + # HEIF 재시도 + if HEIF_SUPPORTED: print("🔄 HEIF 형식으로 재시도...") try: image = Image.open(io.BytesIO(image_data)) @@ -90,6 +89,10 @@ def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str else: print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가") raise e + + # 이미지가 성공적으로 로드되지 않은 경우 + if image is None: + raise Exception("이미지 로드 실패") # iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환 # RGB 모드로 변환 (RGBA, P 모드 등을 처리) diff --git a/frontend/index.html b/frontend/index.html index 8f4140c..fe07364 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -271,17 +271,17 @@