From 3cf485f3f2cbd9e29513ae1319288e05c8acf659 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 25 Oct 2025 12:08:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EC=8B=A0=ED=95=A8=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=99=84=EC=A0=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”ง Models & Schemas: - ์ƒˆ๋กœ์šด ENUM ํด๋ž˜์Šค ์ถ”๊ฐ€: * ReviewStatus: pending_review, in_progress, completed, disposed * DisposalReasonType: duplicate, invalid_report, not_applicable, spam, custom - Issue ๋ชจ๋ธ ํ™•์žฅ (8๊ฐœ ์ƒˆ ํ•„๋“œ): * review_status: ์ˆ˜์‹ ํ•จ ์›Œํฌํ”Œ๋กœ์šฐ ์ƒํƒœ (๊ธฐ๋ณธ๊ฐ’: pending_review) * disposal_reason: ํ๊ธฐ ์‚ฌ์œ  ENUM * custom_disposal_reason: ์‚ฌ์šฉ์ž ์ •์˜ ํ๊ธฐ ์‚ฌ์œ  * disposed_at: ํ๊ธฐ ์ฒ˜๋ฆฌ ์‹œ๊ฐ„ * reviewed_by_id: ๊ฒ€ํ† ์ž FK (users.id) * reviewed_at: ๊ฒ€ํ†  ์™„๋ฃŒ ์‹œ๊ฐ„ * original_data: ์›๋ณธ ๋ฐ์ดํ„ฐ ๋ณด์กด (JSONB) * modification_log: ์ˆ˜์ • ์ด๋ ฅ ์ถ”์  (JSONB) - User ๋ชจ๋ธ ๊ด€๊ณ„ ์ˆ˜์ •: * issues: ์‹ ๊ณ ํ•œ ๋ถ€์ ํ•ฉ (foreign_keys ๋ช…์‹œ) * reviewed_issues: ๊ฒ€ํ† ํ•œ ๋ถ€์ ํ•ฉ (์ƒˆ๋กœ ์ถ”๊ฐ€) ๐ŸŽฏ Pydantic Schemas: - ๊ธฐ์กด Issue ์Šคํ‚ค๋งˆ์— ์›Œํฌํ”Œ๋กœ์šฐ ํ•„๋“œ ์ถ”๊ฐ€ - ์ˆ˜์‹ ํ•จ ์ „์šฉ ์Šคํ‚ค๋งˆ๋“ค: * IssueDisposalRequest: ํ๊ธฐ ์š”์ฒญ * IssueReviewRequest: ๊ฒ€ํ† /์ˆ˜์ • ์š”์ฒญ * IssueStatusUpdateRequest: ์ƒํƒœ ๋ณ€๊ฒฝ ์š”์ฒญ * InboxIssue: ์ˆ˜์‹ ํ•จ์šฉ ๊ฐ„์†Œํ™” ๋ชจ๋ธ * ModificationLogEntry: ์ˆ˜์ • ์ด๋ ฅ ํ•ญ๋ชฉ ๐Ÿš€ API Endpoints (/api/inbox): - GET /: ์ˆ˜์‹ ํ•จ ๋ถ€์ ํ•ฉ ๋ชฉ๋ก (ํ”„๋กœ์ ํŠธ ํ•„ํ„ฐ๋ง, ํŽ˜์ด์ง•) - POST /{id}/dispose: ๋ถ€์ ํ•ฉ ํ๊ธฐ ์ฒ˜๋ฆฌ (๊ด€๋ฆฌ์ž ์ „์šฉ) - POST /{id}/review: ๋ถ€์ ํ•ฉ ๊ฒ€ํ† /์ˆ˜์ • (๊ด€๋ฆฌ์ž ์ „์šฉ) - POST /{id}/status: ์ตœ์ข… ์ƒํƒœ ๊ฒฐ์ • (๊ด€๋ฆฌ์ž ์ „์šฉ) - GET /{id}/history: ์ˆ˜์ • ์ด๋ ฅ ์กฐํšŒ - GET /statistics: ์ˆ˜์‹ ํ•จ ํ†ต๊ณ„ ๐Ÿ”’ Security & Validation: - ๊ด€๋ฆฌ์ž ์ „์šฉ ์•ก์…˜ (ํ๊ธฐ, ๊ฒ€ํ† , ์ƒํƒœ๋ณ€๊ฒฝ) - ์‚ฌ์šฉ์ž ์ •์˜ ํ๊ธฐ ์‚ฌ์œ  ๊ฒ€์ฆ - ํ”„๋กœ์ ํŠธ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ - ์ƒํƒœ ๋ณ€๊ฒฝ ๋กœ์ง ๊ฒ€์ฆ ๐Ÿ“Š Data Preservation: - ์›๋ณธ ๋ฐ์ดํ„ฐ ์ž๋™ ๋ณด์กด (์ตœ์ดˆ 1ํšŒ) - ๋ชจ๋“  ์ˆ˜์ •์‚ฌํ•ญ ์ด๋ ฅ ์ถ”์  - ๊ฒ€ํ† ์ž ๋ฐ ์‹œ๊ฐ„ ๊ธฐ๋ก - ํ๊ธฐ ์‚ฌ์œ  ๋ฐ ์‹œ๊ฐ„ ๊ธฐ๋ก ๐ŸŽฏ Workflow Logic: ์—…๋กœ๋“œ(pending_review) โ†’ ์ˆ˜์‹ ํ•จ ๊ฒ€ํ†  โ†’ [ํ๊ธฐโ†’ํ๊ธฐํ•จ] or [์Šน์ธโ†’๊ด€๋ฆฌํ•จ] - ํ๊ธฐ: disposed ์ƒํƒœ, ํ๊ธฐํ•จ์œผ๋กœ - ์Šน์ธ: in_progress/completed ์ƒํƒœ, ๊ด€๋ฆฌํ•จ์œผ๋กœ - ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ถ”์  ๋ฐ ๋ณด์กด Result: โœ… ์ˆ˜์‹ ํ•จ ์›Œํฌํ”Œ๋กœ์šฐ ๋ฐฑ์—”๋“œ 100% ์™„์„ฑ โœ… DB ์Šคํ‚ค๋งˆ์™€ ์™„๋ฒฝ ๋™๊ธฐํ™” โœ… ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ๋ฐ ์ถ”์ ์„ฑ ๋ณด์žฅ โœ… RESTful API ์„ค๊ณ„ ์ค€์ˆ˜ โœ… ๊ด€๋ฆฌ์ž ๊ถŒํ•œ ๊ธฐ๋ฐ˜ ๋ณด์•ˆ ์ ์šฉ --- backend/database/models.py | 30 +++- backend/database/schemas.py | 67 +++++++- backend/main.py | 3 +- backend/routers/inbox.py | 305 ++++++++++++++++++++++++++++++++++++ 4 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 backend/routers/inbox.py 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() + }