diff --git a/backend/database/models.py b/backend/database/models.py index 1127d6b..33578ad 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -1,4 +1,5 @@ from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship from datetime import datetime, timezone, timedelta @@ -29,6 +30,19 @@ class IssueCategory(str, enum.Enum): inspection_miss = "inspection_miss" # 검사미스 (신규 추가) etc = "etc" # 기타 +class ReviewStatus(str, enum.Enum): + pending_review = "pending_review" # 수신함 (검토 대기) + in_progress = "in_progress" # 관리함 (진행 중) + completed = "completed" # 관리함 (완료됨) + disposed = "disposed" # 폐기함 (폐기됨) + +class DisposalReasonType(str, enum.Enum): + duplicate = "duplicate" # 중복 (기본값) + invalid_report = "invalid_report" # 잘못된 신고 + not_applicable = "not_applicable" # 해당 없음 + spam = "spam" # 스팸/오류 + custom = "custom" # 직접 입력 + class User(Base): __tablename__ = "users" @@ -41,7 +55,8 @@ class User(Base): created_at = Column(DateTime, default=get_kst_now) # Relationships - issues = relationship("Issue", back_populates="reporter") + issues = relationship("Issue", back_populates="reporter", foreign_keys="Issue.reporter_id") + reviewed_issues = relationship("Issue", foreign_keys="Issue.reviewed_by_id") daily_works = relationship("DailyWork", back_populates="created_by") projects = relationship("Project", back_populates="created_by") page_permissions = relationship("UserPagePermission", back_populates="user", foreign_keys="UserPagePermission.user_id") @@ -82,8 +97,19 @@ class Issue(Base): work_hours = Column(Float, default=0) detail_notes = Column(Text) + # 수신함 워크플로우 관련 컬럼들 + review_status = Column(Enum(ReviewStatus), default=ReviewStatus.pending_review) + disposal_reason = Column(Enum(DisposalReasonType)) + custom_disposal_reason = Column(Text) + disposed_at = Column(DateTime) + reviewed_by_id = Column(Integer, ForeignKey("users.id")) + reviewed_at = Column(DateTime) + original_data = Column(JSONB) # 원본 데이터 보존 + modification_log = Column(JSONB, default=lambda: []) # 수정 이력 + # Relationships - reporter = relationship("User", back_populates="issues") + reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id]) + reviewer = relationship("User", foreign_keys=[reviewed_by_id]) project = relationship("Project", back_populates="issues") class Project(Base): diff --git a/backend/database/schemas.py b/backend/database/schemas.py index 4e7b467..17fd630 100644 --- a/backend/database/schemas.py +++ b/backend/database/schemas.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field from datetime import datetime -from typing import Optional, List +from typing import Optional, List, Dict, Any from enum import Enum class UserRole(str, Enum): @@ -19,6 +19,19 @@ class IssueCategory(str, Enum): inspection_miss = "inspection_miss" # 검사미스 (신규 추가) etc = "etc" # 기타 +class ReviewStatus(str, Enum): + pending_review = "pending_review" # 수신함 (검토 대기) + in_progress = "in_progress" # 관리함 (진행 중) + completed = "completed" # 관리함 (완료됨) + disposed = "disposed" # 폐기함 (폐기됨) + +class DisposalReasonType(str, Enum): + duplicate = "duplicate" # 중복 (기본값) + invalid_report = "invalid_report" # 잘못된 신고 + not_applicable = "not_applicable" # 해당 없음 + spam = "spam" # 스팸/오류 + custom = "custom" # 직접 입력 + # User schemas class UserBase(BaseModel): username: str @@ -92,9 +105,61 @@ class Issue(IssueBase): work_hours: float detail_notes: Optional[str] = None + # 수신함 워크플로우 관련 필드들 + review_status: ReviewStatus + disposal_reason: Optional[DisposalReasonType] = None + custom_disposal_reason: Optional[str] = None + disposed_at: Optional[datetime] = None + reviewed_by_id: Optional[int] = None + reviewed_at: Optional[datetime] = None + original_data: Optional[Dict[str, Any]] = None + modification_log: Optional[List[Dict[str, Any]]] = None + class Config: from_attributes = True +# 수신함 워크플로우 전용 스키마들 +class IssueDisposalRequest(BaseModel): + """부적합 폐기 요청""" + disposal_reason: DisposalReasonType = DisposalReasonType.duplicate + custom_disposal_reason: Optional[str] = None + +class IssueReviewRequest(BaseModel): + """부적합 검토 및 수정 요청""" + project_id: Optional[int] = None + category: Optional[IssueCategory] = None + description: Optional[str] = None + modifications: Optional[Dict[str, Any]] = None + +class IssueStatusUpdateRequest(BaseModel): + """부적합 상태 변경 요청""" + review_status: ReviewStatus + notes: Optional[str] = None + +class InboxIssue(BaseModel): + """수신함용 부적합 정보 (간소화된 버전)""" + id: int + category: IssueCategory + description: str + photo_path: Optional[str] = None + photo_path2: Optional[str] = None + project_id: Optional[int] = None + reporter_id: int + reporter: User + report_date: datetime + review_status: ReviewStatus + + class Config: + from_attributes = True + +class ModificationLogEntry(BaseModel): + """수정 이력 항목""" + field: str + old_value: Any + new_value: Any + modified_at: datetime + modified_by: int + # Project schemas class ProjectBase(BaseModel): job_no: str = Field(..., min_length=1, max_length=50) diff --git a/backend/main.py b/backend/main.py index d85fc1e..ea89be8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,7 +5,7 @@ import uvicorn from database.database import engine, get_db from database.models import Base -from routers import auth, issues, daily_work, reports, projects, page_permissions +from routers import auth, issues, daily_work, reports, projects, page_permissions, inbox from services.auth_service import create_admin_user # 데이터베이스 테이블 생성 @@ -33,6 +33,7 @@ app.add_middleware( # 라우터 등록 app.include_router(auth.router) app.include_router(issues.router) +app.include_router(inbox.router) # 수신함 라우터 추가 app.include_router(daily_work.router) app.include_router(reports.router) app.include_router(projects.router) diff --git a/backend/routers/inbox.py b/backend/routers/inbox.py new file mode 100644 index 0000000..e491bfe --- /dev/null +++ b/backend/routers/inbox.py @@ -0,0 +1,305 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime + +from database.database import get_db +from database.models import Issue, User, Project, ReviewStatus, DisposalReasonType +from database.schemas import ( + InboxIssue, IssueDisposalRequest, IssueReviewRequest, + IssueStatusUpdateRequest, ModificationLogEntry +) +from routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/inbox", tags=["inbox"]) + +@router.get("/", response_model=List[InboxIssue]) +async def get_inbox_issues( + project_id: Optional[int] = Query(None, description="프로젝트 ID로 필터링"), + skip: int = Query(0, ge=0, description="건너뛸 항목 수"), + limit: int = Query(100, ge=1, le=1000, description="가져올 항목 수"), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 수신함 - 검토 대기 중인 부적합 목록 조회 + """ + query = db.query(Issue).filter(Issue.review_status == ReviewStatus.pending_review) + + # 프로젝트 필터링 + if project_id: + query = query.filter(Issue.project_id == project_id) + + # 최신순 정렬 + query = query.order_by(Issue.report_date.desc()) + + issues = query.offset(skip).limit(limit).all() + return issues + +@router.post("/{issue_id}/dispose") +async def dispose_issue( + issue_id: int, + disposal_request: IssueDisposalRequest, + current_user: User = Depends(get_current_admin), # 관리자만 폐기 가능 + db: Session = Depends(get_db) +): + """ + 부적합 폐기 처리 + """ + # 부적합 조회 + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.") + + if issue.review_status != ReviewStatus.pending_review: + raise HTTPException(status_code=400, detail="검토 대기 중인 부적합만 폐기할 수 있습니다.") + + # 사용자 정의 사유 검증 + if disposal_request.disposal_reason == DisposalReasonType.custom: + if not disposal_request.custom_disposal_reason or not disposal_request.custom_disposal_reason.strip(): + raise HTTPException(status_code=400, detail="사용자 정의 폐기 사유를 입력해주세요.") + + # 원본 데이터 보존 + if not issue.original_data: + issue.original_data = { + "category": issue.category.value, + "description": issue.description, + "project_id": issue.project_id, + "photo_path": issue.photo_path, + "photo_path2": issue.photo_path2, + "preserved_at": datetime.now().isoformat() + } + + # 폐기 처리 + issue.review_status = ReviewStatus.disposed + issue.disposal_reason = disposal_request.disposal_reason + issue.custom_disposal_reason = disposal_request.custom_disposal_reason + issue.disposed_at = datetime.now() + issue.reviewed_by_id = current_user.id + issue.reviewed_at = datetime.now() + + db.commit() + db.refresh(issue) + + return { + "message": "부적합이 성공적으로 폐기되었습니다.", + "issue_id": issue.id, + "disposal_reason": issue.disposal_reason.value, + "disposed_at": issue.disposed_at + } + +@router.post("/{issue_id}/review") +async def review_issue( + issue_id: int, + review_request: IssueReviewRequest, + current_user: User = Depends(get_current_admin), # 관리자만 검토 가능 + db: Session = Depends(get_db) +): + """ + 부적합 검토 및 수정 + """ + # 부적합 조회 + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.") + + if issue.review_status != ReviewStatus.pending_review: + raise HTTPException(status_code=400, detail="검토 대기 중인 부적합만 수정할 수 있습니다.") + + # 원본 데이터 보존 (최초 1회만) + if not issue.original_data: + issue.original_data = { + "category": issue.category.value, + "description": issue.description, + "project_id": issue.project_id, + "photo_path": issue.photo_path, + "photo_path2": issue.photo_path2, + "preserved_at": datetime.now().isoformat() + } + + # 수정 이력 준비 + modifications = [] + current_time = datetime.now() + + # 프로젝트 변경 + if review_request.project_id is not None and review_request.project_id != issue.project_id: + # 프로젝트 존재 확인 + if review_request.project_id != 0: # 0은 프로젝트 없음을 의미 + project = db.query(Project).filter(Project.id == review_request.project_id).first() + if not project: + raise HTTPException(status_code=400, detail="존재하지 않는 프로젝트입니다.") + + modifications.append({ + "field": "project_id", + "old_value": issue.project_id, + "new_value": review_request.project_id, + "modified_at": current_time.isoformat(), + "modified_by": current_user.id + }) + issue.project_id = review_request.project_id if review_request.project_id != 0 else None + + # 카테고리 변경 + if review_request.category is not None and review_request.category != issue.category: + modifications.append({ + "field": "category", + "old_value": issue.category.value, + "new_value": review_request.category.value, + "modified_at": current_time.isoformat(), + "modified_by": current_user.id + }) + issue.category = review_request.category + + # 설명 변경 + if review_request.description is not None and review_request.description != issue.description: + modifications.append({ + "field": "description", + "old_value": issue.description, + "new_value": review_request.description, + "modified_at": current_time.isoformat(), + "modified_by": current_user.id + }) + issue.description = review_request.description + + # 수정 이력 업데이트 + if modifications: + if issue.modification_log is None: + issue.modification_log = [] + issue.modification_log.extend(modifications) + + # SQLAlchemy에 변경사항 알림 + from sqlalchemy.orm.attributes import flag_modified + flag_modified(issue, "modification_log") + + # 검토자 정보 업데이트 + issue.reviewed_by_id = current_user.id + issue.reviewed_at = current_time + + db.commit() + db.refresh(issue) + + return { + "message": "부적합 검토가 완료되었습니다.", + "issue_id": issue.id, + "modifications_count": len(modifications), + "modifications": modifications, + "reviewed_at": issue.reviewed_at + } + +@router.post("/{issue_id}/status") +async def update_issue_status( + issue_id: int, + status_request: IssueStatusUpdateRequest, + current_user: User = Depends(get_current_admin), # 관리자만 상태 변경 가능 + db: Session = Depends(get_db) +): + """ + 부적합 최종 상태 결정 (진행 중 / 완료) + """ + # 부적합 조회 + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.") + + if issue.review_status not in [ReviewStatus.pending_review, ReviewStatus.in_progress]: + raise HTTPException(status_code=400, detail="이미 처리가 완료된 부적합입니다.") + + # 상태 변경 검증 + if status_request.review_status not in [ReviewStatus.in_progress, ReviewStatus.completed]: + raise HTTPException(status_code=400, detail="진행 중 또는 완료 상태만 설정할 수 있습니다.") + + # 원본 데이터 보존 (최초 1회만) + if not issue.original_data: + issue.original_data = { + "category": issue.category.value, + "description": issue.description, + "project_id": issue.project_id, + "photo_path": issue.photo_path, + "photo_path2": issue.photo_path2, + "preserved_at": datetime.now().isoformat() + } + + # 상태 변경 + old_status = issue.review_status + issue.review_status = status_request.review_status + issue.reviewed_by_id = current_user.id + issue.reviewed_at = datetime.now() + + # 노트가 있으면 detail_notes에 추가 + if status_request.notes: + current_notes = issue.detail_notes or "" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") + new_note = f"[{timestamp}] {current_user.username}: {status_request.notes}" + issue.detail_notes = f"{current_notes}\n{new_note}".strip() + + db.commit() + db.refresh(issue) + + return { + "message": f"부적합 상태가 '{status_request.review_status.value}'로 변경되었습니다.", + "issue_id": issue.id, + "old_status": old_status.value, + "new_status": issue.review_status.value, + "reviewed_at": issue.reviewed_at, + "destination": "관리함" if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed] else "수신함" + } + +@router.get("/{issue_id}/history") +async def get_issue_history( + issue_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 부적합 수정 이력 조회 + """ + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.") + + return { + "issue_id": issue.id, + "original_data": issue.original_data, + "modification_log": issue.modification_log or [], + "review_status": issue.review_status.value, + "reviewed_by_id": issue.reviewed_by_id, + "reviewed_at": issue.reviewed_at, + "disposal_info": { + "disposal_reason": issue.disposal_reason.value if issue.disposal_reason else None, + "custom_disposal_reason": issue.custom_disposal_reason, + "disposed_at": issue.disposed_at + } if issue.review_status == ReviewStatus.disposed else None + } + +@router.get("/statistics") +async def get_inbox_statistics( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 수신함 통계 정보 + """ + # 검토 대기 중 + pending_count = db.query(Issue).filter(Issue.review_status == ReviewStatus.pending_review).count() + + # 오늘 처리된 건수 + today = datetime.now().date() + today_processed = db.query(Issue).filter( + Issue.reviewed_at >= today, + Issue.review_status != ReviewStatus.pending_review + ).count() + + # 폐기된 건수 + disposed_count = db.query(Issue).filter(Issue.review_status == ReviewStatus.disposed).count() + + # 관리함으로 이동한 건수 + management_count = db.query(Issue).filter( + Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed]) + ).count() + + return { + "pending_review": pending_count, + "today_processed": today_processed, + "total_disposed": disposed_count, + "total_in_management": management_count, + "total_issues": db.query(Issue).count() + }