Files
tk-factory-services/system3-nonconformance/api/routers/issues.py
Hyungi Ahn ac2a2e7eed feat(tkqc): 관리함 이슈 프로젝트 변경 + cause_person 필드명 버그 수정
- 모바일/데스크톱 관리함에서 이슈 소속 프로젝트 변경 가능
- 프로젝트 변경 시 sequence_no 자동 재계산 (DB 함수 사용)
- in_progress 상태에서만 변경 허용 (프론트+백엔드 이중 제한)
- cause_person → responsible_person_detail 필드명 불일치 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:56:20 +09:00

516 lines
19 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import text, func
from typing import List, Optional
from datetime import datetime
from database.database import get_db
from database.models import Issue, IssueStatus, User, UserRole, ReviewStatus
from database import schemas
from routers.auth import get_current_user, get_current_admin
from routers.page_permissions import check_page_access
from services.file_service import save_base64_image, delete_file
router = APIRouter(prefix="/api/issues", tags=["issues"])
@router.post("/", response_model=schemas.Issue)
async def create_issue(
issue: schemas.IssueCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# 이미지 저장 (최대 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,
location_info=issue.location_info,
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
)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.get("/", response_model=List[schemas.Issue])
async def read_issues(
skip: int = 0,
limit: int = 100,
status: Optional[IssueStatus] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
query = db.query(Issue)
# 권한별 조회 제한
if current_user.role == UserRole.admin:
# 관리자는 모든 이슈 조회 가능
pass
else:
# 일반 사용자는 본인이 등록한 이슈만 조회 가능
query = query.filter(Issue.reporter_id == current_user.id)
if status:
query = query.filter(Issue.status == status)
# 최신순 정렬 (report_date 기준)
issues = query.order_by(Issue.report_date.desc()).offset(skip).limit(limit).all()
return issues
@router.get("/admin/all", response_model=List[schemas.Issue])
async def read_all_issues_admin(
skip: int = 0,
limit: int = 100,
status: Optional[IssueStatus] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""이슈 관리 권한이 있는 사용자: 모든 부적합 조회"""
# 이슈 관리 페이지 권한 확인 (관리함, 폐기함 등에서 사용)
from routers.page_permissions import check_page_access
# 관리자이거나 이슈 관리 권한이 있는 사용자만 접근 가능
if (current_user.role != 'admin' and
not check_page_access(current_user.id, 'issues_manage', db) and
not check_page_access(current_user.id, 'issues_inbox', db) and
not check_page_access(current_user.id, 'issues_management', db) and
not check_page_access(current_user.id, 'issues_archive', db)):
raise HTTPException(status_code=403, detail="이슈 관리 권한이 없습니다.")
query = db.query(Issue)
if status:
query = query.filter(Issue.status == status)
# 최신순 정렬 (report_date 기준)
issues = query.order_by(Issue.report_date.desc()).offset(skip).limit(limit).all()
return issues
@router.get("/{issue_id}", response_model=schemas.Issue)
async def read_issue(
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="Issue not found")
# 권한별 조회 제한
if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
raise HTTPException(
status_code=403,
detail="본인이 등록한 부적합만 조회할 수 있습니다."
)
return issue
@router.put("/{issue_id}", response_model=schemas.Issue)
async def update_issue(
issue_id: int,
issue_update: schemas.IssueUpdate,
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="Issue not found")
# 권한 확인
if current_user.role == UserRole.user and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this issue")
# 업데이트
update_data = issue_update.dict(exclude_unset=True)
# 프로젝트 변경 시 sequence_no 재계산
if "project_id" in update_data and update_data["project_id"] != issue.project_id:
if issue.review_status and issue.review_status != ReviewStatus.in_progress:
raise HTTPException(status_code=400, detail="진행 중 상태에서만 프로젝트를 변경할 수 있습니다")
try:
new_seq = db.execute(text("SELECT generate_project_sequence_no(:pid)"),
{"pid": update_data["project_id"]}).scalar()
update_data["project_sequence_no"] = new_seq
except Exception:
max_seq = db.query(func.coalesce(func.max(Issue.project_sequence_no), 0)).filter(
Issue.project_id == update_data["project_id"]
).scalar()
update_data["project_sequence_no"] = max_seq + 1
# 사진 업데이트 처리 (최대 5장) - 새 사진 저장 후 기존 사진 삭제 (안전)
old_photos_to_delete = []
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 update_data[photo_field]:
new_path = save_base64_image(update_data[photo_field])
update_data[path_field] = new_path
# 새 사진 저장 성공 시에만 기존 사진 삭제 예약
if new_path and existing_path:
old_photos_to_delete.append(existing_path)
else:
update_data[path_field] = None
if existing_path:
old_photos_to_delete.append(existing_path)
# photo 필드는 제거 (DB에는 photo_path만 저장)
del update_data[photo_field]
# work_hours가 입력되면 자동으로 상태를 complete로 변경
if "work_hours" in update_data and update_data["work_hours"] > 0:
if issue.status == IssueStatus.new:
update_data["status"] = IssueStatus.complete
for field, value in update_data.items():
setattr(issue, field, value)
db.commit()
db.refresh(issue)
# DB 커밋 성공 후 기존 사진 파일 삭제
for old_path in old_photos_to_delete:
try:
delete_file(old_path)
except Exception:
pass
return issue
@router.delete("/{issue_id}")
async def delete_issue(
issue_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
from database.models import DeletionLog
import json
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인 (관리자 또는 본인이 등록한 경우 삭제 가능)
if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="본인이 등록한 부적합만 삭제할 수 있습니다.")
# 이 이슈를 중복 대상으로 참조하는 다른 이슈들의 참조 제거
referencing_issues = db.query(Issue).filter(Issue.duplicate_of_issue_id == issue_id).all()
if referencing_issues:
for ref_issue in referencing_issues:
ref_issue.duplicate_of_issue_id = None
db.flush() # 참조 제거를 먼저 커밋
# 삭제 로그 생성 (삭제 전 데이터 저장)
issue_data = {
"id": issue.id,
"category": issue.category.value if issue.category else None,
"description": issue.description,
"status": issue.status.value if issue.status else None,
"reporter_id": issue.reporter_id,
"project_id": issue.project_id,
"report_date": issue.report_date.isoformat() if issue.report_date else None,
"work_hours": issue.work_hours,
"detail_notes": issue.detail_notes,
"photo_path": issue.photo_path,
"photo_path2": issue.photo_path2,
"review_status": issue.review_status.value if issue.review_status else None,
"solution": issue.solution,
"responsible_department": issue.responsible_department.value if issue.responsible_department else None,
"responsible_person": issue.responsible_person,
"expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None,
"actual_completion_date": issue.actual_completion_date.isoformat() if issue.actual_completion_date else None,
}
deletion_log = DeletionLog(
entity_type="issue",
entity_id=issue.id,
entity_data=issue_data,
deleted_by_id=current_user.id,
reason=f"사용자 {current_user.username}에 의해 삭제됨"
)
db.add(deletion_log)
# 이미지 파일 삭제 (신고 사진 최대 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()
return {"detail": "Issue deleted successfully", "logged": True}
@router.post("/{issue_id}/opinion")
async def add_opinion(
issue_id: int,
opinion_request: schemas.OpinionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
의견 제시 — 로그인한 모든 사용자가 사용 가능 (권한 체크 없음)
solution 필드에 의견을 추가합니다.
"""
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 새 의견 형식: [작성자] (날짜시간)\n내용
now = datetime.now()
date_str = now.strftime("%Y. %m. %d. %H:%M")
author = current_user.full_name or current_user.username
new_opinion = f"[{author}] ({date_str})\n{opinion_request.opinion}"
# 기존 solution에 추가 (최신이 위로)
separator = "" * 50
if issue.solution:
issue.solution = f"{new_opinion}\n{separator}\n{issue.solution}"
else:
issue.solution = new_opinion
db.commit()
db.refresh(issue)
return {
"message": "의견이 추가되었습니다.",
"issue_id": issue.id
}
@router.get("/stats/summary")
async def get_issue_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""이슈 통계 조회"""
query = db.query(Issue)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.user:
query = query.filter(Issue.reporter_id == current_user.id)
total = query.count()
new = query.filter(Issue.status == IssueStatus.new).count()
progress = query.filter(Issue.status == IssueStatus.progress).count()
complete = query.filter(Issue.status == IssueStatus.complete).count()
return {
"total": total,
"new": new,
"progress": progress,
"complete": complete
}
@router.put("/{issue_id}/management")
async def update_issue_management(
issue_id: int,
management_update: schemas.ManagementUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
관리함에서 이슈의 관리 관련 필드들을 업데이트합니다.
"""
# 관리함 페이지 권한 확인
if not (current_user.role == UserRole.admin or check_page_access(current_user.id, 'issues_management', db)):
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
# 이슈 조회
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
# 관리함에서만 수정 가능한 필드들만 업데이트
update_data = management_update.dict(exclude_unset=True)
# 완료 사진 처리 (최대 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:
# 기존 사진 삭제
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)
except Exception as e:
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
# 나머지 필드 처리 (완료 사진 제외)
for field, value in update_data.items():
if field not in completion_photo_fields:
try:
setattr(issue, field, value)
except Exception as e:
raise HTTPException(status_code=400, detail=f"필드 {field} 설정 실패: {str(e)}")
try:
db.commit()
db.refresh(issue)
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"데이터베이스 저장 실패: {str(e)}")
return {
"message": "관리 정보가 업데이트되었습니다.",
"issue_id": issue.id,
"updated_fields": list(update_data.keys())
}
@router.post("/{issue_id}/completion-request")
async def request_completion(
issue_id: int,
request: schemas.CompletionRequestRequest,
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="부적합을 찾을 수 없습니다.")
# 진행 중 상태인지 확인
if issue.review_status != ReviewStatus.in_progress:
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 완료 신청할 수 있습니다.")
# 이미 완료 신청된 경우 확인
if issue.completion_requested_at:
raise HTTPException(status_code=400, detail="이미 완료 신청된 부적합입니다.")
try:
# 완료 사진 저장 (최대 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:
saved_path = save_base64_image(photo_data, "completion")
if saved_path:
setattr(issue, path_field, saved_path)
saved_paths.append(saved_path)
else:
raise Exception(f"{photo_field} 저장에 실패했습니다.")
# 완료 신청 정보 업데이트
issue.completion_requested_at = datetime.now()
issue.completion_requested_by_id = current_user.id
issue.completion_comment = request.completion_comment
db.commit()
db.refresh(issue)
return {
"message": "완료 신청이 성공적으로 제출되었습니다.",
"issue_id": issue.id,
"completion_requested_at": issue.completion_requested_at,
"completion_photo_paths": saved_paths
}
except Exception as e:
print(f"ERROR: 완료 신청 처리 오류 - {str(e)}")
db.rollback()
# 업로드된 파일이 있다면 삭제
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")
async def reject_completion_request(
issue_id: int,
request: schemas.CompletionRejectionRequest,
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="부적합을 찾을 수 없습니다.")
# 완료 신청이 있는지 확인
if not issue.completion_requested_at:
raise HTTPException(status_code=400, detail="완료 신청이 없는 부적합입니다.")
# 권한 확인 (관리자 또는 관리함 접근 권한이 있는 사용자)
if current_user.role != UserRole.admin and not check_page_access(current_user.id, 'issues_management', db):
raise HTTPException(status_code=403, detail="완료 반려 권한이 없습니다.")
try:
# 완료 사진은 파일 삭제하지 않음 (재신청 시 참고용으로 보존)
# DB 참조만 초기화
# 완료 신청 정보 초기화
issue.completion_requested_at = None
issue.completion_requested_by_id = 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
# 완료 반려 정보 기록 (전용 필드 사용)
issue.completion_rejected_at = datetime.now()
issue.completion_rejected_by_id = current_user.id
issue.completion_rejection_reason = request.rejection_reason
# 상태는 in_progress로 유지
issue.review_status = ReviewStatus.in_progress
db.commit()
db.refresh(issue)
return {
"message": "완료 신청이 반려되었습니다.",
"issue_id": issue.id,
"rejection_reason": request.rejection_reason
}
except Exception as e:
print(f"ERROR: 완료 반려 처리 오류 - {str(e)}")
db.rollback()
raise HTTPException(status_code=500, detail=f"완료 반려 처리 중 오류가 발생했습니다: {str(e)}")