feat: 3-System 분리 프로젝트 초기 코드 작성
TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로 분리하기 위한 전체 코드 구조 작성. - SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원) - System 1: 공장관리 (TK-FB 기반, 신고 코드 제거) - System 2: 신고 (TK-FB에서 workIssue 코드 추출) - System 3: 부적합관리 (M-Project 기반) - Gateway 포털 (path-based 라우팅) - 통합 docker-compose.yml 및 배포 스크립트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
24
system3-nonconformance/api/Dockerfile
Normal file
24
system3-nonconformance/api/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 시스템 패키지 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 의존성 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 애플리케이션 파일 복사
|
||||
COPY . .
|
||||
|
||||
# uploads 디렉토리 생성
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 실행 명령
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
19
system3-nonconformance/api/database/database.py
Normal file
19
system3-nonconformance/api/database/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import os
|
||||
from typing import Generator
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
225
system3-nonconformance/api/database/models.py
Normal file
225
system3-nonconformance/api/database/models.py
Normal file
@@ -0,0 +1,225 @@
|
||||
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
|
||||
import enum
|
||||
|
||||
# 한국 시간대 설정
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
def get_kst_now():
|
||||
"""현재 한국 시간 반환"""
|
||||
return datetime.now(KST)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin" # 관리자
|
||||
user = "user" # 일반 사용자
|
||||
|
||||
class IssueStatus(str, enum.Enum):
|
||||
new = "new"
|
||||
progress = "progress"
|
||||
complete = "complete"
|
||||
|
||||
class IssueCategory(str, enum.Enum):
|
||||
material_missing = "material_missing"
|
||||
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
|
||||
incoming_defect = "incoming_defect"
|
||||
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 DepartmentType(str, enum.Enum):
|
||||
production = "production" # 생산
|
||||
quality = "quality" # 품질
|
||||
purchasing = "purchasing" # 구매
|
||||
design = "design" # 설계
|
||||
sales = "sales" # 영업
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
full_name = Column(String)
|
||||
role = Column(Enum(UserRole), default=UserRole.user)
|
||||
department = Column(Enum(DepartmentType)) # 부서 정보 추가
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
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")
|
||||
|
||||
class UserPagePermission(Base):
|
||||
__tablename__ = "user_page_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
page_name = Column(String(50), nullable=False)
|
||||
can_access = Column(Boolean, default=False)
|
||||
granted_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
granted_at = Column(DateTime, default=get_kst_now)
|
||||
notes = Column(Text)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="page_permissions", foreign_keys=[user_id])
|
||||
granted_by = relationship("User", foreign_keys=[granted_by_id], post_update=True)
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (
|
||||
Index('idx_user_page_permissions_user_id', 'user_id'),
|
||||
Index('idx_user_page_permissions_page_name', 'page_name'),
|
||||
)
|
||||
|
||||
class Issue(Base):
|
||||
__tablename__ = "issues"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
photo_path = 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)
|
||||
reporter_id = Column(Integer, ForeignKey("users.id"))
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id"))
|
||||
report_date = Column(DateTime, default=get_kst_now)
|
||||
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: []) # 수정 이력
|
||||
|
||||
# 중복 신고 추적 시스템
|
||||
duplicate_of_issue_id = Column(Integer, ForeignKey("issues.id")) # 중복 대상 이슈 ID
|
||||
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
|
||||
|
||||
# 관리함에서 사용할 추가 필드들
|
||||
solution = Column(Text) # 해결방안 (관리함에서 입력)
|
||||
responsible_department = Column(Enum(DepartmentType)) # 담당부서
|
||||
responsible_person = Column(String(100)) # 담당자
|
||||
expected_completion_date = Column(DateTime) # 조치 예상일
|
||||
actual_completion_date = Column(DateTime) # 완료 확인일
|
||||
cause_department = Column(Enum(DepartmentType)) # 원인부서
|
||||
management_comment = Column(Text) # ISSUE에 대한 의견
|
||||
project_sequence_no = Column(Integer) # 프로젝트별 순번 (No)
|
||||
final_description = Column(Text) # 최종 내용 (수정본 또는 원본)
|
||||
final_category = Column(Enum(IssueCategory)) # 최종 카테고리 (수정본 또는 원본)
|
||||
|
||||
# 추가 정보 필드들 (관리함에서 기록용)
|
||||
responsible_person_detail = Column(String(200)) # 해당자 상세 정보
|
||||
cause_detail = Column(Text) # 원인 상세 정보
|
||||
additional_info_updated_at = Column(DateTime) # 추가 정보 입력 시간
|
||||
additional_info_updated_by_id = Column(Integer, ForeignKey("users.id")) # 추가 정보 입력자
|
||||
|
||||
# 완료 신청 관련 필드들
|
||||
completion_requested_at = Column(DateTime) # 완료 신청 시간
|
||||
completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자
|
||||
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) # 완료 코멘트
|
||||
|
||||
# 완료 반려 관련 필드들
|
||||
completion_rejected_at = Column(DateTime) # 완료 반려 시간
|
||||
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")
|
||||
project = relationship("Project", back_populates="issues")
|
||||
duplicate_of = relationship("Issue", remote_side=[id], foreign_keys=[duplicate_of_issue_id])
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
job_no = Column(String, unique=True, nullable=False, index=True)
|
||||
project_name = Column(String, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="projects")
|
||||
issues = relationship("Issue", back_populates="project")
|
||||
|
||||
class DailyWork(Base):
|
||||
__tablename__ = "daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
worker_count = Column(Integer, nullable=False)
|
||||
regular_hours = Column(Float, nullable=False)
|
||||
overtime_workers = Column(Integer, default=0)
|
||||
overtime_hours = Column(Float, default=0)
|
||||
overtime_total = Column(Float, default=0)
|
||||
total_hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="daily_works")
|
||||
|
||||
class ProjectDailyWork(Base):
|
||||
__tablename__ = "project_daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id"), nullable=False)
|
||||
hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project")
|
||||
created_by = relationship("User")
|
||||
|
||||
class DeletionLog(Base):
|
||||
__tablename__ = "deletion_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_type = Column(String(50), nullable=False) # 'issue', 'project', 'daily_work' 등
|
||||
entity_id = Column(Integer, nullable=False) # 삭제된 엔티티의 ID
|
||||
entity_data = Column(JSONB, nullable=False) # 삭제된 데이터 전체 (JSON)
|
||||
deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
deleted_at = Column(DateTime, default=get_kst_now, nullable=False)
|
||||
reason = Column(Text) # 삭제 사유 (선택사항)
|
||||
|
||||
# Relationships
|
||||
deleted_by = relationship("User")
|
||||
369
system3-nonconformance/api/database/schemas.py
Normal file
369
system3-nonconformance/api/database/schemas.py
Normal file
@@ -0,0 +1,369 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
class UserRole(str, Enum):
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
class IssueStatus(str, Enum):
|
||||
new = "new"
|
||||
progress = "progress"
|
||||
complete = "complete"
|
||||
|
||||
class IssueCategory(str, Enum):
|
||||
material_missing = "material_missing"
|
||||
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
|
||||
incoming_defect = "incoming_defect"
|
||||
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" # 직접 입력
|
||||
|
||||
class DepartmentType(str, Enum):
|
||||
production = "production" # 생산
|
||||
quality = "quality" # 품질
|
||||
purchasing = "purchasing" # 구매
|
||||
design = "design" # 설계
|
||||
sales = "sales" # 영업
|
||||
|
||||
# User schemas
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
full_name: Optional[str] = None
|
||||
role: UserRole = UserRole.user
|
||||
department: Optional[DepartmentType] = None
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
department: Optional[DepartmentType] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
class User(UserBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Auth schemas
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: User
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
# Issue schemas
|
||||
class IssueBase(BaseModel):
|
||||
category: IssueCategory
|
||||
description: str
|
||||
project_id: int
|
||||
|
||||
class IssueCreate(IssueBase):
|
||||
photo: Optional[str] = None # 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
|
||||
description: Optional[str] = None
|
||||
project_id: Optional[int] = None
|
||||
work_hours: Optional[float] = None
|
||||
detail_notes: Optional[str] = None
|
||||
status: Optional[IssueStatus] = None
|
||||
photo: Optional[str] = None # 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_path3: Optional[str] = None
|
||||
photo_path4: Optional[str] = None
|
||||
photo_path5: Optional[str] = None
|
||||
status: IssueStatus
|
||||
reporter_id: int
|
||||
reporter: User
|
||||
project_id: Optional[int] = None
|
||||
# project: Optional['Project'] = None # 순환 참조 방지를 위해 제거
|
||||
report_date: datetime
|
||||
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
|
||||
|
||||
# 중복 신고 추적 시스템
|
||||
duplicate_of_issue_id: Optional[int] = None
|
||||
duplicate_reporters: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# 관리함에서 사용할 추가 필드들
|
||||
solution: Optional[str] = None # 해결방안
|
||||
responsible_department: Optional[DepartmentType] = None # 담당부서
|
||||
responsible_person: Optional[str] = None # 담당자
|
||||
expected_completion_date: Optional[datetime] = None # 조치 예상일
|
||||
actual_completion_date: Optional[datetime] = None # 완료 확인일
|
||||
cause_department: Optional[DepartmentType] = None # 원인부서
|
||||
management_comment: Optional[str] = None # ISSUE에 대한 의견
|
||||
project_sequence_no: Optional[int] = None # 프로젝트별 순번
|
||||
final_description: Optional[str] = None # 최종 내용
|
||||
final_category: Optional[IssueCategory] = None # 최종 카테고리
|
||||
|
||||
# 추가 정보 필드들 (관리함에서 기록용)
|
||||
responsible_person_detail: Optional[str] = None # 해당자 상세 정보
|
||||
cause_detail: Optional[str] = None # 원인 상세 정보
|
||||
additional_info_updated_at: Optional[datetime] = None # 추가 정보 입력 시간
|
||||
additional_info_updated_by_id: Optional[int] = None # 추가 정보 입력자
|
||||
|
||||
# 완료 신청 관련 필드들
|
||||
completion_requested_at: Optional[datetime] = None # 완료 신청 시간
|
||||
completion_requested_by_id: Optional[int] = 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 # 완료 코멘트
|
||||
|
||||
# 완료 반려 관련 필드들
|
||||
completion_rejected_at: Optional[datetime] = None # 완료 반려 시간
|
||||
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
|
||||
|
||||
# 수신함 워크플로우 전용 스키마들
|
||||
class IssueDisposalRequest(BaseModel):
|
||||
"""부적합 폐기 요청"""
|
||||
disposal_reason: DisposalReasonType = DisposalReasonType.duplicate
|
||||
custom_disposal_reason: Optional[str] = None
|
||||
duplicate_of_issue_id: Optional[int] = None # 중복 대상 이슈 ID
|
||||
|
||||
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
|
||||
completion_photo: Optional[str] = None # 완료 사진 (Base64)
|
||||
solution: Optional[str] = None # 해결방안
|
||||
responsible_department: Optional[DepartmentType] = None # 담당부서
|
||||
responsible_person: Optional[str] = None # 담당자
|
||||
|
||||
class ManagementUpdateRequest(BaseModel):
|
||||
"""관리함에서 사용할 필드 업데이트 요청"""
|
||||
solution: Optional[str] = None # 해결방안
|
||||
responsible_department: Optional[DepartmentType] = None # 담당부서
|
||||
responsible_person: Optional[str] = None # 담당자
|
||||
expected_completion_date: Optional[datetime] = None # 조치 예상일
|
||||
cause_department: Optional[DepartmentType] = None # 원인부서
|
||||
management_comment: Optional[str] = None # ISSUE에 대한 의견
|
||||
completion_photo: Optional[str] = None # 완료 사진 (Base64)
|
||||
final_description: Optional[str] = None # 최종 내용 (부적합명 + 상세 내용)
|
||||
|
||||
class AdditionalInfoUpdateRequest(BaseModel):
|
||||
"""추가 정보 업데이트 요청 (관리함 진행중에서 사용)"""
|
||||
cause_department: Optional[DepartmentType] = None # 원인부서
|
||||
responsible_person_detail: Optional[str] = None # 해당자 상세 정보
|
||||
cause_detail: Optional[str] = None # 원인 상세 정보
|
||||
|
||||
class CompletionRequestRequest(BaseModel):
|
||||
"""완료 신청 요청"""
|
||||
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):
|
||||
"""완료 신청 반려 요청"""
|
||||
rejection_reason: str # 반려 사유
|
||||
|
||||
class ManagementUpdateRequest(BaseModel):
|
||||
"""관리함에서 이슈 업데이트 요청"""
|
||||
final_description: Optional[str] = None
|
||||
final_category: Optional[IssueCategory] = None
|
||||
solution: Optional[str] = None
|
||||
responsible_department: Optional[DepartmentType] = None
|
||||
responsible_person: Optional[str] = None
|
||||
expected_completion_date: Optional[str] = None
|
||||
cause_department: Optional[DepartmentType] = None
|
||||
management_comment: Optional[str] = None
|
||||
completion_comment: Optional[str] = None
|
||||
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):
|
||||
"""수신함용 부적합 정보 (간소화된 버전)"""
|
||||
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)
|
||||
project_name: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
pass
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
project_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class Project(ProjectBase):
|
||||
id: int
|
||||
created_by_id: int
|
||||
created_by: User
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
# issues: Optional[List['Issue']] = None # 순환 참조 방지를 위해 제거
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Daily Work schemas
|
||||
class DailyWorkBase(BaseModel):
|
||||
date: datetime
|
||||
worker_count: int = Field(gt=0)
|
||||
overtime_workers: Optional[int] = 0
|
||||
overtime_hours: Optional[float] = 0
|
||||
|
||||
class DailyWorkCreate(DailyWorkBase):
|
||||
pass
|
||||
|
||||
class DailyWorkUpdate(BaseModel):
|
||||
worker_count: Optional[int] = Field(None, gt=0)
|
||||
overtime_workers: Optional[int] = None
|
||||
overtime_hours: Optional[float] = None
|
||||
|
||||
class DailyWork(DailyWorkBase):
|
||||
id: int
|
||||
regular_hours: float
|
||||
overtime_total: float
|
||||
total_hours: float
|
||||
created_by_id: int
|
||||
created_by: User
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Report schemas
|
||||
class ReportRequest(BaseModel):
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
|
||||
class CategoryStats(BaseModel):
|
||||
material_missing: int = 0
|
||||
dimension_defect: int = 0
|
||||
incoming_defect: int = 0
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
total_hours: float
|
||||
total_issues: int
|
||||
category_stats: CategoryStats
|
||||
completed_issues: int
|
||||
average_resolution_time: float
|
||||
|
||||
# Project Daily Work schemas
|
||||
class ProjectDailyWorkBase(BaseModel):
|
||||
date: datetime
|
||||
project_id: int
|
||||
hours: float
|
||||
|
||||
class ProjectDailyWorkCreate(ProjectDailyWorkBase):
|
||||
pass
|
||||
|
||||
class ProjectDailyWork(ProjectDailyWorkBase):
|
||||
id: int
|
||||
created_by_id: int
|
||||
created_at: datetime
|
||||
project: Project
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# 일일보고서 관련 스키마
|
||||
class DailyReportRequest(BaseModel):
|
||||
project_id: int
|
||||
|
||||
class DailyReportStats(BaseModel):
|
||||
total_count: int = 0
|
||||
management_count: int = 0 # 관리처리 현황 (진행 중)
|
||||
completed_count: int = 0
|
||||
delayed_count: int = 0
|
||||
69
system3-nonconformance/api/main.py
Normal file
69
system3-nonconformance/api/main.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
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, inbox, management
|
||||
from services.auth_service import create_admin_user
|
||||
|
||||
# 데이터베이스 테이블 생성
|
||||
# 메타데이터 캐시 클리어
|
||||
Base.metadata.clear()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
title="M-Project API",
|
||||
description="작업보고서 시스템 API",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS 설정 (완전 개방 - CORS 문제 해결)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False, # * origin과 credentials는 함께 사용 불가
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"]
|
||||
)
|
||||
|
||||
# 라우터 등록
|
||||
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)
|
||||
app.include_router(page_permissions.router)
|
||||
app.include_router(management.router) # 관리함 라우터 추가
|
||||
|
||||
# 시작 시 관리자 계정 생성
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
db = next(get_db())
|
||||
create_admin_user(db)
|
||||
db.close()
|
||||
|
||||
# 루트 엔드포인트
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "M-Project API", "version": "1.0.0"}
|
||||
|
||||
# 헬스체크
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
# 전역 예외 처리
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": f"Internal server error: {str(exc)}"}
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
41
system3-nonconformance/api/migrate_add_photo_fields.py
Normal file
41
system3-nonconformance/api/migrate_add_photo_fields.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
데이터베이스 마이그레이션: 사진 필드 추가
|
||||
- 신고 사진 3, 4, 5 추가
|
||||
- 완료 사진 2, 3, 4, 5 추가
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
import os
|
||||
|
||||
# 데이터베이스 URL
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/issue_tracker")
|
||||
|
||||
def run_migration():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
print("마이그레이션 시작...")
|
||||
|
||||
try:
|
||||
# 신고 사진 필드 추가
|
||||
print("신고 사진 필드 추가 중...")
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path3 VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path4 VARCHAR"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path5 VARCHAR"))
|
||||
|
||||
# 완료 사진 필드 추가
|
||||
print("완료 사진 필드 추가 중...")
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path2 VARCHAR(500)"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path3 VARCHAR(500)"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path4 VARCHAR(500)"))
|
||||
conn.execute(text("ALTER TABLE issues ADD COLUMN IF NOT EXISTS completion_photo_path5 VARCHAR(500)"))
|
||||
|
||||
conn.commit()
|
||||
print("✅ 마이그레이션 완료!")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"❌ 마이그레이션 실패: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migration()
|
||||
58
system3-nonconformance/api/migrations/001_init.sql
Normal file
58
system3-nonconformance/api/migrations/001_init.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- 초기 데이터베이스 설정
|
||||
|
||||
-- Enum 타입 생성 (4개 카테고리)
|
||||
CREATE TYPE userRole AS ENUM ('admin', 'user');
|
||||
CREATE TYPE issueStatus AS ENUM ('new', 'progress', 'complete');
|
||||
CREATE TYPE issueCategory AS ENUM (
|
||||
'material_missing', -- 자재누락
|
||||
'design_error', -- 설계미스
|
||||
'incoming_defect', -- 입고자재 불량
|
||||
'inspection_miss' -- 검사미스
|
||||
);
|
||||
|
||||
-- 사용자 테이블
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
hashed_password VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(100),
|
||||
role userRole DEFAULT 'user',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 이슈 테이블
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
photo_path VARCHAR(500),
|
||||
photo_path2 VARCHAR(500), -- 두 번째 사진
|
||||
category issueCategory NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status issueStatus DEFAULT 'new',
|
||||
reporter_id INTEGER REFERENCES users(id),
|
||||
report_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
work_hours FLOAT DEFAULT 0,
|
||||
detail_notes TEXT
|
||||
);
|
||||
|
||||
-- 일일 작업 테이블
|
||||
CREATE TABLE IF NOT EXISTS daily_works (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
worker_count INTEGER NOT NULL,
|
||||
regular_hours FLOAT NOT NULL,
|
||||
overtime_workers INTEGER DEFAULT 0,
|
||||
overtime_hours FLOAT DEFAULT 0,
|
||||
overtime_total FLOAT DEFAULT 0,
|
||||
total_hours FLOAT NOT NULL,
|
||||
created_by_id INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(date)
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_issues_reporter_id ON issues(reporter_id);
|
||||
CREATE INDEX idx_issues_status ON issues(status);
|
||||
CREATE INDEX idx_issues_category ON issues(category);
|
||||
CREATE INDEX idx_daily_works_date ON daily_works(date);
|
||||
CREATE INDEX idx_daily_works_created_by_id ON daily_works(created_by_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 두 번째 사진 경로 추가
|
||||
ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path2 VARCHAR(500);
|
||||
|
||||
-- 인덱스 추가 (선택사항)
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_photo_path2 ON issues(photo_path2);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 카테고리 업데이트 마이그레이션
|
||||
-- dimension_defect를 design_error로 변경
|
||||
UPDATE issues
|
||||
SET category = 'design_error'
|
||||
WHERE category = 'dimension_defect';
|
||||
|
||||
-- PostgreSQL enum 타입 업데이트 (필요한 경우)
|
||||
-- 기존 enum 타입 확인 후 필요시 재생성
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 카테고리 값 정규화 (대문자를 소문자로 변경)
|
||||
-- 기존 DIMENSION_DEFECT를 design_error로 변경
|
||||
UPDATE issues SET category = 'design_error' WHERE category IN ('DIMENSION_DEFECT', 'dimension_defect');
|
||||
UPDATE issues SET category = 'material_missing' WHERE category = 'MATERIAL_MISSING';
|
||||
UPDATE issues SET category = 'incoming_defect' WHERE category = 'INCOMING_DEFECT';
|
||||
UPDATE issues SET category = 'inspection_miss' WHERE category = 'INSPECTION_MISS';
|
||||
|
||||
-- 카테고리 값 확인
|
||||
SELECT category, COUNT(*) FROM issues GROUP BY category;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- PostgreSQL enum 타입 재생성
|
||||
-- 카테고리 컬럼을 임시로 텍스트로 변경
|
||||
ALTER TABLE issues ALTER COLUMN category TYPE VARCHAR(50);
|
||||
|
||||
-- 기존 enum 타입 삭제
|
||||
DROP TYPE IF EXISTS issuecategory CASCADE;
|
||||
|
||||
-- 새로운 enum 타입 생성 (4개 카테고리)
|
||||
CREATE TYPE issuecategory AS ENUM (
|
||||
'material_missing', -- 자재누락
|
||||
'design_error', -- 설계미스 (기존 dimension_defect 대체)
|
||||
'incoming_defect', -- 입고자재 불량
|
||||
'inspection_miss' -- 검사미스 (신규)
|
||||
);
|
||||
|
||||
-- 카테고리 컬럼을 새 enum 타입으로 변경
|
||||
ALTER TABLE issues ALTER COLUMN category TYPE issuecategory USING category::issuecategory;
|
||||
|
||||
-- 확인
|
||||
SELECT enumlabel FROM pg_enum WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'issuecategory');
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 프로젝트 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
job_no VARCHAR(50) UNIQUE NOT NULL,
|
||||
project_name VARCHAR(200) NOT NULL,
|
||||
created_by_id INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_job_no ON projects(job_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_created_by_id ON projects(created_by_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_is_active ON projects(is_active);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 부적합 사항 테이블에 프로젝트 ID 컬럼 추가
|
||||
ALTER TABLE issues ADD COLUMN project_id INTEGER;
|
||||
|
||||
-- 외래키 제약조건 추가
|
||||
ALTER TABLE issues ADD CONSTRAINT fk_issues_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_project_id ON issues(project_id);
|
||||
|
||||
-- 기존 부적합 사항들을 첫 번째 프로젝트로 할당 (있는 경우)
|
||||
UPDATE issues
|
||||
SET project_id = (SELECT id FROM projects ORDER BY created_at LIMIT 1)
|
||||
WHERE project_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM projects LIMIT 1);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- project_id 컬럼을 BIGINT로 변경
|
||||
ALTER TABLE issues ALTER COLUMN project_id TYPE BIGINT;
|
||||
|
||||
-- projects 테이블의 id도 BIGINT로 변경 (일관성을 위해)
|
||||
ALTER TABLE projects ALTER COLUMN id TYPE BIGINT;
|
||||
|
||||
-- 외래키 제약조건 재생성 (타입 변경으로 인해 필요)
|
||||
ALTER TABLE issues DROP CONSTRAINT IF EXISTS fk_issues_project_id;
|
||||
ALTER TABLE issues ADD CONSTRAINT fk_issues_project_id
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
|
||||
-- 다른 테이블들도 확인하여 project_id 참조하는 곳이 있으면 수정
|
||||
-- (현재는 issues 테이블만 project_id를 가지고 있음)
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 프로젝트별 일일공수 테이블 생성
|
||||
CREATE TABLE project_daily_works (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
hours FLOAT NOT NULL,
|
||||
created_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_project_daily_works_date ON project_daily_works(date);
|
||||
CREATE INDEX idx_project_daily_works_project_id ON project_daily_works(project_id);
|
||||
CREATE INDEX idx_project_daily_works_date_project ON project_daily_works(date, project_id);
|
||||
|
||||
-- 기존 일일공수 데이터를 프로젝트별로 마이그레이션 (M Project로)
|
||||
INSERT INTO project_daily_works (date, project_id, hours, created_by_id, created_at)
|
||||
SELECT
|
||||
date::date,
|
||||
1, -- M Project ID
|
||||
total_hours,
|
||||
created_by_id,
|
||||
created_at
|
||||
FROM daily_works
|
||||
WHERE total_hours > 0;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 부적합 카테고리에 'etc' (기타) 값 추가
|
||||
-- 백엔드 코드와 데이터베이스 enum 타입 불일치 해결
|
||||
|
||||
-- issuecategory enum 타입에 'etc' 값 추가
|
||||
ALTER TYPE issuecategory ADD VALUE 'etc';
|
||||
|
||||
-- 확인 쿼리 (주석)
|
||||
-- SELECT enumlabel FROM pg_enum WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'issuecategory') ORDER BY enumsortorder;
|
||||
|
||||
-- 이제 사용 가능한 카테고리:
|
||||
-- 1. material_missing (자재누락)
|
||||
-- 2. design_error (설계미스)
|
||||
-- 3. incoming_defect (입고자재 불량)
|
||||
-- 4. inspection_miss (검사미스)
|
||||
-- 5. etc (기타) ✅ 새로 추가됨
|
||||
@@ -0,0 +1,137 @@
|
||||
-- 권한 시스템 개선 마이그레이션
|
||||
-- 새로운 사용자 역할 추가 및 개별 권한 테이블 생성
|
||||
|
||||
-- 1. 새로운 사용자 역할 추가
|
||||
ALTER TYPE userrole ADD VALUE 'super_admin';
|
||||
ALTER TYPE userrole ADD VALUE 'manager';
|
||||
|
||||
-- 2. 사용자별 개별 권한 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission VARCHAR(50) NOT NULL,
|
||||
granted BOOLEAN DEFAULT TRUE,
|
||||
granted_by_id INTEGER REFERENCES users(id),
|
||||
granted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
notes TEXT,
|
||||
UNIQUE(user_id, permission)
|
||||
);
|
||||
|
||||
-- 3. 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_user_permissions_user_id ON user_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_permissions_permission ON user_permissions(permission);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_permissions_granted ON user_permissions(granted);
|
||||
|
||||
-- 4. 기본 권한 설정 (기존 관리자에게 super_admin 권한 부여)
|
||||
UPDATE users SET role = 'super_admin' WHERE username = 'hyungi';
|
||||
|
||||
-- 5. 권한 확인 함수 생성
|
||||
CREATE OR REPLACE FUNCTION check_user_permission(p_user_id INTEGER, p_permission VARCHAR)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
user_role userrole;
|
||||
has_permission BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- 사용자 역할 가져오기
|
||||
SELECT role INTO user_role FROM users WHERE id = p_user_id AND is_active = TRUE;
|
||||
|
||||
IF user_role IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- super_admin은 모든 권한 보유
|
||||
IF user_role = 'super_admin' THEN
|
||||
RETURN TRUE;
|
||||
END IF;
|
||||
|
||||
-- 개별 권한 확인
|
||||
SELECT granted INTO has_permission
|
||||
FROM user_permissions
|
||||
WHERE user_id = p_user_id
|
||||
AND permission = p_permission
|
||||
AND granted = TRUE
|
||||
AND revoked_at IS NULL;
|
||||
|
||||
-- 개별 권한이 없으면 역할 기반 기본 권한 확인
|
||||
IF has_permission IS NULL THEN
|
||||
-- 기본 권한 매트릭스
|
||||
CASE
|
||||
WHEN p_permission IN ('issues.create', 'issues.view') THEN
|
||||
has_permission := TRUE; -- 모든 사용자
|
||||
WHEN p_permission IN ('issues.edit', 'issues.review', 'daily_work.create', 'daily_work.view', 'daily_work.edit') THEN
|
||||
has_permission := user_role IN ('admin', 'manager'); -- 관리자, 매니저
|
||||
WHEN p_permission IN ('projects.create', 'projects.edit', 'issues.delete', 'daily_work.delete') THEN
|
||||
has_permission := user_role = 'admin'; -- 관리자만
|
||||
WHEN p_permission IN ('projects.delete', 'users.create', 'users.edit', 'users.delete', 'users.change_role') THEN
|
||||
has_permission := user_role = 'super_admin'; -- 최고 관리자만
|
||||
ELSE
|
||||
has_permission := FALSE;
|
||||
END CASE;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(has_permission, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 6. 권한 부여 함수 생성
|
||||
CREATE OR REPLACE FUNCTION grant_user_permission(
|
||||
p_user_id INTEGER,
|
||||
p_permission VARCHAR,
|
||||
p_granted_by_id INTEGER,
|
||||
p_notes TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
INSERT INTO user_permissions (user_id, permission, granted, granted_by_id, notes)
|
||||
VALUES (p_user_id, p_permission, TRUE, p_granted_by_id, p_notes)
|
||||
ON CONFLICT (user_id, permission)
|
||||
DO UPDATE SET
|
||||
granted = TRUE,
|
||||
granted_by_id = p_granted_by_id,
|
||||
granted_at = NOW(),
|
||||
revoked_at = NULL,
|
||||
notes = p_notes;
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 7. 권한 취소 함수 생성
|
||||
CREATE OR REPLACE FUNCTION revoke_user_permission(
|
||||
p_user_id INTEGER,
|
||||
p_permission VARCHAR,
|
||||
p_revoked_by_id INTEGER,
|
||||
p_notes TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
UPDATE user_permissions
|
||||
SET granted = FALSE,
|
||||
revoked_at = NOW(),
|
||||
notes = p_notes
|
||||
WHERE user_id = p_user_id
|
||||
AND permission = p_permission;
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 8. 사용자 권한 목록 조회 뷰 생성
|
||||
CREATE OR REPLACE VIEW user_permissions_view AS
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
u.full_name,
|
||||
u.role,
|
||||
up.permission,
|
||||
up.granted,
|
||||
up.granted_at,
|
||||
up.revoked_at,
|
||||
granted_by.username as granted_by_username,
|
||||
up.notes
|
||||
FROM users u
|
||||
LEFT JOIN user_permissions up ON u.id = up.user_id
|
||||
LEFT JOIN users granted_by ON up.granted_by_id = granted_by.id
|
||||
WHERE u.is_active = TRUE
|
||||
ORDER BY u.username, up.permission;
|
||||
@@ -0,0 +1,111 @@
|
||||
-- 권한 시스템 단순화
|
||||
-- admin/user 구조로 변경하고 페이지별 접근 권한으로 변경
|
||||
|
||||
-- 1. 기존 복잡한 권한 테이블 삭제하고 단순한 페이지 권한 테이블로 변경
|
||||
DROP TABLE IF EXISTS user_permissions CASCADE;
|
||||
|
||||
-- 2. 페이지별 접근 권한 테이블 생성
|
||||
CREATE TABLE user_page_permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
page_name VARCHAR(50) NOT NULL,
|
||||
can_access BOOLEAN DEFAULT FALSE,
|
||||
granted_by_id INTEGER REFERENCES users(id),
|
||||
granted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
notes TEXT,
|
||||
UNIQUE(user_id, page_name)
|
||||
);
|
||||
|
||||
-- 3. 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_name ON user_page_permissions(page_name);
|
||||
|
||||
-- 4. 기존 복잡한 함수들 삭제
|
||||
DROP FUNCTION IF EXISTS check_user_permission(INTEGER, VARCHAR);
|
||||
DROP FUNCTION IF EXISTS grant_user_permission(INTEGER, VARCHAR, INTEGER, TEXT);
|
||||
DROP FUNCTION IF EXISTS revoke_user_permission(INTEGER, VARCHAR, INTEGER, TEXT);
|
||||
|
||||
-- 5. 단순한 페이지 접근 권한 체크 함수
|
||||
CREATE OR REPLACE FUNCTION check_page_access(p_user_id INTEGER, p_page_name VARCHAR)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
user_role userrole;
|
||||
has_access BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
-- 사용자 역할 가져오기
|
||||
SELECT role INTO user_role FROM users WHERE id = p_user_id AND is_active = TRUE;
|
||||
|
||||
IF user_role IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- admin은 모든 페이지 접근 가능
|
||||
IF user_role = 'admin' THEN
|
||||
RETURN TRUE;
|
||||
END IF;
|
||||
|
||||
-- 일반 사용자는 개별 페이지 권한 확인
|
||||
SELECT can_access INTO has_access
|
||||
FROM user_page_permissions
|
||||
WHERE user_id = p_user_id
|
||||
AND page_name = p_page_name;
|
||||
|
||||
-- 권한이 설정되지 않은 경우 기본값 (부적합 등록/조회만 허용)
|
||||
IF has_access IS NULL THEN
|
||||
CASE p_page_name
|
||||
WHEN 'issues_create' THEN has_access := TRUE;
|
||||
WHEN 'issues_view' THEN has_access := TRUE;
|
||||
ELSE has_access := FALSE;
|
||||
END CASE;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(has_access, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 6. 페이지 권한 부여 함수
|
||||
CREATE OR REPLACE FUNCTION grant_page_access(
|
||||
p_user_id INTEGER,
|
||||
p_page_name VARCHAR,
|
||||
p_can_access BOOLEAN,
|
||||
p_granted_by_id INTEGER,
|
||||
p_notes TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id, notes)
|
||||
VALUES (p_user_id, p_page_name, p_can_access, p_granted_by_id, p_notes)
|
||||
ON CONFLICT (user_id, page_name)
|
||||
DO UPDATE SET
|
||||
can_access = p_can_access,
|
||||
granted_by_id = p_granted_by_id,
|
||||
granted_at = NOW(),
|
||||
notes = p_notes;
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 7. 사용자 페이지 권한 조회 뷰
|
||||
CREATE OR REPLACE VIEW user_page_access_view AS
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
u.full_name,
|
||||
u.role,
|
||||
upp.page_name,
|
||||
upp.can_access,
|
||||
upp.granted_at,
|
||||
granted_by.username as granted_by_username,
|
||||
upp.notes
|
||||
FROM users u
|
||||
LEFT JOIN user_page_permissions upp ON u.id = upp.user_id
|
||||
LEFT JOIN users granted_by ON upp.granted_by_id = granted_by.id
|
||||
WHERE u.is_active = TRUE
|
||||
ORDER BY u.username, upp.page_name;
|
||||
|
||||
-- 8. 기존 super_admin, manager 역할을 admin으로 변경
|
||||
UPDATE users SET role = 'admin' WHERE role IN ('super_admin', 'manager');
|
||||
|
||||
-- 9. 기존 뷰 삭제
|
||||
DROP VIEW IF EXISTS user_permissions_view;
|
||||
@@ -0,0 +1,316 @@
|
||||
-- ================================================
|
||||
-- 마이그레이션: 013_add_inbox_workflow_system.sql
|
||||
-- 목적: 수신함 워크플로우를 위한 DB 스키마 추가
|
||||
-- 작성일: 2025-10-25
|
||||
-- 주의: 배포 시 반드시 이 파일이 실행되는지 확인 필요!
|
||||
-- ================================================
|
||||
|
||||
-- 트랜잭션 시작 (실패 시 롤백)
|
||||
BEGIN;
|
||||
|
||||
-- 1. 새로운 ENUM 타입 생성
|
||||
-- 검토 상태 (수신함 워크플로우용)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'review_status') THEN
|
||||
CREATE TYPE review_status AS ENUM (
|
||||
'pending_review', -- 수신함 (검토 대기)
|
||||
'in_progress', -- 관리함 (진행 중)
|
||||
'completed', -- 관리함 (완료됨)
|
||||
'disposed' -- 폐기함 (폐기됨)
|
||||
);
|
||||
RAISE NOTICE '✅ review_status ENUM 타입이 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ review_status ENUM 타입이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 폐기 사유 타입
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'disposal_reason_type') THEN
|
||||
CREATE TYPE disposal_reason_type AS ENUM (
|
||||
'duplicate', -- 중복 (기본값)
|
||||
'invalid_report', -- 잘못된 신고
|
||||
'not_applicable', -- 해당 없음
|
||||
'spam', -- 스팸/오류
|
||||
'custom' -- 직접 입력
|
||||
);
|
||||
RAISE NOTICE '✅ disposal_reason_type ENUM 타입이 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ disposal_reason_type ENUM 타입이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2. issues 테이블에 새로운 컬럼 추가 (안전하게)
|
||||
-- 검토 상태 컬럼
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'review_status'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN review_status review_status DEFAULT 'pending_review';
|
||||
RAISE NOTICE '✅ issues.review_status 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.review_status 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 폐기 사유 컬럼
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'disposal_reason'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN disposal_reason disposal_reason_type;
|
||||
RAISE NOTICE '✅ issues.disposal_reason 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.disposal_reason 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 사용자 정의 폐기 사유 컬럼
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'custom_disposal_reason'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN custom_disposal_reason TEXT;
|
||||
RAISE NOTICE '✅ issues.custom_disposal_reason 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.custom_disposal_reason 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 폐기 날짜 컬럼
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'disposed_at'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN disposed_at TIMESTAMP WITH TIME ZONE;
|
||||
RAISE NOTICE '✅ issues.disposed_at 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.disposed_at 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 검토자 ID 컬럼
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'reviewed_by_id'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN reviewed_by_id INTEGER REFERENCES users(id);
|
||||
RAISE NOTICE '✅ issues.reviewed_by_id 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.reviewed_by_id 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 검토 날짜 컬럼
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'reviewed_at'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN reviewed_at TIMESTAMP WITH TIME ZONE;
|
||||
RAISE NOTICE '✅ issues.reviewed_at 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.reviewed_at 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 원본 데이터 보존 컬럼 (JSONB)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'original_data'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN original_data JSONB;
|
||||
RAISE NOTICE '✅ issues.original_data 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.original_data 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 수정 이력 컬럼 (JSONB)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'modification_log'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN modification_log JSONB DEFAULT '[]'::jsonb;
|
||||
RAISE NOTICE '✅ issues.modification_log 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ issues.modification_log 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 3. 인덱스 추가 (성능 최적화)
|
||||
-- review_status 인덱스
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'issues' AND indexname = 'idx_issues_review_status'
|
||||
) THEN
|
||||
CREATE INDEX idx_issues_review_status ON issues(review_status);
|
||||
RAISE NOTICE '✅ idx_issues_review_status 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ idx_issues_review_status 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- reviewed_by_id 인덱스
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'issues' AND indexname = 'idx_issues_reviewed_by_id'
|
||||
) THEN
|
||||
CREATE INDEX idx_issues_reviewed_by_id ON issues(reviewed_by_id);
|
||||
RAISE NOTICE '✅ idx_issues_reviewed_by_id 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ idx_issues_reviewed_by_id 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- disposed_at 인덱스
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'issues' AND indexname = 'idx_issues_disposed_at'
|
||||
) THEN
|
||||
CREATE INDEX idx_issues_disposed_at ON issues(disposed_at) WHERE disposed_at IS NOT NULL;
|
||||
RAISE NOTICE '✅ idx_issues_disposed_at 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ idx_issues_disposed_at 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 4. 기존 데이터 마이그레이션 (안전하게)
|
||||
-- 기존 issues의 review_status를 기존 status에 따라 설정
|
||||
UPDATE issues
|
||||
SET review_status = CASE
|
||||
WHEN status = 'new' THEN 'pending_review'::review_status
|
||||
WHEN status = 'progress' THEN 'in_progress'::review_status
|
||||
WHEN status = 'complete' THEN 'completed'::review_status
|
||||
ELSE 'pending_review'::review_status
|
||||
END
|
||||
WHERE review_status = 'pending_review'; -- 기본값인 경우만 업데이트
|
||||
|
||||
-- 5. 제약 조건 추가
|
||||
-- 폐기된 경우 폐기 사유 필수
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'chk_disposal_reason_required'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD CONSTRAINT chk_disposal_reason_required
|
||||
CHECK (
|
||||
(review_status = 'disposed' AND disposal_reason IS NOT NULL) OR
|
||||
(review_status != 'disposed')
|
||||
);
|
||||
RAISE NOTICE '✅ chk_disposal_reason_required 제약 조건이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ chk_disposal_reason_required 제약 조건이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 사용자 정의 사유는 disposal_reason이 'custom'일 때만
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'chk_custom_reason_logic'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD CONSTRAINT chk_custom_reason_logic
|
||||
CHECK (
|
||||
(disposal_reason = 'custom' AND custom_disposal_reason IS NOT NULL AND LENGTH(TRIM(custom_disposal_reason)) > 0) OR
|
||||
(disposal_reason != 'custom' OR disposal_reason IS NULL)
|
||||
);
|
||||
RAISE NOTICE '✅ chk_custom_reason_logic 제약 조건이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ chk_custom_reason_logic 제약 조건이 이미 존재합니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 6. 마이그레이션 완료 로그
|
||||
INSERT INTO migration_log (migration_file, executed_at, status, notes)
|
||||
VALUES (
|
||||
'013_add_inbox_workflow_system.sql',
|
||||
NOW(),
|
||||
'SUCCESS',
|
||||
'수신함 워크플로우 시스템 추가: review_status, disposal_reason, 원본데이터 보존, 수정이력 등'
|
||||
) ON CONFLICT (migration_file) DO UPDATE SET
|
||||
executed_at = NOW(),
|
||||
status = 'SUCCESS',
|
||||
notes = EXCLUDED.notes;
|
||||
|
||||
-- 트랜잭션 커밋
|
||||
COMMIT;
|
||||
|
||||
-- 7. 마이그레이션 검증
|
||||
DO $$
|
||||
DECLARE
|
||||
column_count INTEGER;
|
||||
enum_count INTEGER;
|
||||
index_count INTEGER;
|
||||
BEGIN
|
||||
-- 컬럼 개수 확인
|
||||
SELECT COUNT(*) INTO column_count
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'issues'
|
||||
AND column_name IN (
|
||||
'review_status', 'disposal_reason', 'custom_disposal_reason',
|
||||
'disposed_at', 'reviewed_by_id', 'reviewed_at',
|
||||
'original_data', 'modification_log'
|
||||
);
|
||||
|
||||
-- ENUM 타입 확인
|
||||
SELECT COUNT(*) INTO enum_count
|
||||
FROM pg_type
|
||||
WHERE typname IN ('review_status', 'disposal_reason_type');
|
||||
|
||||
-- 인덱스 확인
|
||||
SELECT COUNT(*) INTO index_count
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'issues'
|
||||
AND indexname IN (
|
||||
'idx_issues_review_status',
|
||||
'idx_issues_reviewed_by_id',
|
||||
'idx_issues_disposed_at'
|
||||
);
|
||||
|
||||
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
|
||||
RAISE NOTICE '추가된 컬럼: %/8개', column_count;
|
||||
RAISE NOTICE '생성된 ENUM: %/2개', enum_count;
|
||||
RAISE NOTICE '생성된 인덱스: %/3개', index_count;
|
||||
|
||||
IF column_count = 8 AND enum_count = 2 AND index_count = 3 THEN
|
||||
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
|
||||
ELSE
|
||||
RAISE EXCEPTION '❌ 마이그레이션 검증 실패! 일부 구조가 누락되었습니다.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 8. 최종 테이블 구조 출력
|
||||
\echo '=== 최종 issues 테이블 구조 ==='
|
||||
\d issues;
|
||||
|
||||
\echo '=== 새로운 ENUM 타입들 ==='
|
||||
\dT+ review_status;
|
||||
\dT+ disposal_reason_type;
|
||||
|
||||
\echo '=== 마이그레이션 013 완료 ==='
|
||||
@@ -0,0 +1,92 @@
|
||||
-- 014_add_user_department.sql
|
||||
-- 사용자 부서 정보 추가
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- migration_log 테이블 생성 (멱등성)
|
||||
CREATE TABLE IF NOT EXISTS migration_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_file VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
status VARCHAR(50),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 마이그레이션 파일 이름
|
||||
DO $$
|
||||
DECLARE
|
||||
migration_name VARCHAR(255) := '014_add_user_department.sql';
|
||||
migration_notes TEXT := '사용자 부서 정보 추가: department ENUM 타입 및 users 테이블에 department 컬럼 추가';
|
||||
current_status VARCHAR(50);
|
||||
BEGIN
|
||||
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
|
||||
|
||||
IF current_status IS NULL THEN
|
||||
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
|
||||
|
||||
-- department ENUM 타입 생성
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'department_type') THEN
|
||||
CREATE TYPE department_type AS ENUM (
|
||||
'production', -- 생산
|
||||
'quality', -- 품질
|
||||
'purchasing', -- 구매
|
||||
'design', -- 설계
|
||||
'sales' -- 영업
|
||||
);
|
||||
RAISE NOTICE '✅ department_type ENUM 타입이 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ department_type ENUM 타입이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- users 테이블에 department 컬럼 추가
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'department') THEN
|
||||
ALTER TABLE users ADD COLUMN department department_type;
|
||||
RAISE NOTICE '✅ users.department 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ users.department 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 인덱스 추가 (부서별 조회 성능 향상)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'users' AND indexname = 'idx_users_department') THEN
|
||||
CREATE INDEX idx_users_department ON users (department) WHERE department IS NOT NULL;
|
||||
RAISE NOTICE '✅ idx_users_department 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_users_department 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 마이그레이션 검증
|
||||
DECLARE
|
||||
col_count INTEGER;
|
||||
enum_count INTEGER;
|
||||
idx_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO col_count FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'department';
|
||||
SELECT COUNT(*) INTO enum_count FROM pg_type WHERE typname = 'department_type';
|
||||
SELECT COUNT(*) INTO idx_count FROM pg_indexes WHERE tablename = 'users' AND indexname = 'idx_users_department';
|
||||
|
||||
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
|
||||
RAISE NOTICE '추가된 컬럼: %/1개', col_count;
|
||||
RAISE NOTICE '생성된 ENUM: %/1개', enum_count;
|
||||
RAISE NOTICE '생성된 인덱스: %/1개', idx_count;
|
||||
|
||||
IF col_count = 1 AND enum_count = 1 AND idx_count = 1 THEN
|
||||
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
|
||||
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
|
||||
ELSE
|
||||
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
|
||||
END IF;
|
||||
END;
|
||||
|
||||
-- 부서 ENUM 값 확인
|
||||
RAISE NOTICE '=== 부서 ENUM 값 ===';
|
||||
PERFORM dblink_exec('dbname=' || current_database(), 'SELECT enumlabel FROM pg_enum WHERE enumtypid = ''department_type''::regtype ORDER BY enumsortorder');
|
||||
|
||||
ELSIF current_status = 'SUCCESS' THEN
|
||||
RAISE NOTICE 'ℹ️ 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,100 @@
|
||||
-- 015_add_duplicate_tracking.sql
|
||||
-- 중복 신고 추적 시스템 추가
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- migration_log 테이블 생성 (멱등성)
|
||||
CREATE TABLE IF NOT EXISTS migration_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_file VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
status VARCHAR(50),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 마이그레이션 파일 이름
|
||||
DO $$
|
||||
DECLARE
|
||||
migration_name VARCHAR(255) := '015_add_duplicate_tracking.sql';
|
||||
migration_notes TEXT := '중복 신고 추적 시스템: duplicate_of_issue_id, duplicate_reporters 컬럼 추가';
|
||||
current_status VARCHAR(50);
|
||||
BEGIN
|
||||
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
|
||||
|
||||
IF current_status IS NULL THEN
|
||||
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
|
||||
|
||||
-- issues 테이블에 중복 추적 컬럼 추가
|
||||
-- 중복 대상 이슈 ID
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'duplicate_of_issue_id') THEN
|
||||
ALTER TABLE issues ADD COLUMN duplicate_of_issue_id INTEGER;
|
||||
RAISE NOTICE '✅ issues.duplicate_of_issue_id 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.duplicate_of_issue_id 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 중복 신고자 목록 (JSONB 배열)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'duplicate_reporters') THEN
|
||||
ALTER TABLE issues ADD COLUMN duplicate_reporters JSONB DEFAULT '[]'::jsonb;
|
||||
RAISE NOTICE '✅ issues.duplicate_reporters 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.duplicate_reporters 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 외래 키 제약 조건 추가 (duplicate_of_issue_id)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_duplicate_of_issue_id_fkey') THEN
|
||||
ALTER TABLE issues ADD CONSTRAINT issues_duplicate_of_issue_id_fkey
|
||||
FOREIGN KEY (duplicate_of_issue_id) REFERENCES issues(id);
|
||||
RAISE NOTICE '✅ issues_duplicate_of_issue_id_fkey 외래 키 제약 조건이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues_duplicate_of_issue_id_fkey 외래 키 제약 조건이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 인덱스 추가
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_duplicate_of') THEN
|
||||
CREATE INDEX idx_issues_duplicate_of ON issues (duplicate_of_issue_id) WHERE duplicate_of_issue_id IS NOT NULL;
|
||||
RAISE NOTICE '✅ idx_issues_duplicate_of 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_issues_duplicate_of 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- JSONB 인덱스 추가 (중복 신고자 검색 성능 향상)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_duplicate_reporters_gin') THEN
|
||||
CREATE INDEX idx_issues_duplicate_reporters_gin ON issues USING GIN (duplicate_reporters);
|
||||
RAISE NOTICE '✅ idx_issues_duplicate_reporters_gin 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_issues_duplicate_reporters_gin 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 마이그레이션 검증
|
||||
DECLARE
|
||||
col_count INTEGER;
|
||||
idx_count INTEGER;
|
||||
fk_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO col_count FROM information_schema.columns WHERE table_name = 'issues' AND column_name IN ('duplicate_of_issue_id', 'duplicate_reporters');
|
||||
SELECT COUNT(*) INTO idx_count FROM pg_indexes WHERE tablename = 'issues' AND indexname IN ('idx_issues_duplicate_of', 'idx_issues_duplicate_reporters_gin');
|
||||
SELECT COUNT(*) INTO fk_count FROM pg_constraint WHERE conname = 'issues_duplicate_of_issue_id_fkey';
|
||||
|
||||
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
|
||||
RAISE NOTICE '추가된 컬럼: %/2개', col_count;
|
||||
RAISE NOTICE '생성된 인덱스: %/2개', idx_count;
|
||||
RAISE NOTICE '생성된 FK: %/1개', fk_count;
|
||||
|
||||
IF col_count = 2 AND idx_count = 2 AND fk_count = 1 THEN
|
||||
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
|
||||
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
|
||||
ELSE
|
||||
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
|
||||
END IF;
|
||||
END;
|
||||
|
||||
ELSIF current_status = 'SUCCESS' THEN
|
||||
RAISE NOTICE 'ℹ️ 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,223 @@
|
||||
-- 016_add_management_fields.sql
|
||||
-- 관리함에서 사용할 추가 필드들과 완료 사진 업로드 기능 추가
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
migration_name VARCHAR(255) := '016_add_management_fields.sql';
|
||||
migration_notes TEXT := '관리함 필드 추가: 원인/해결방안, 담당부서/담당자, 조치예상일, 완료확인일, 원인부서, 의견, 완료사진, 프로젝트별 No 등';
|
||||
current_status VARCHAR(50);
|
||||
BEGIN
|
||||
-- migration_log 테이블이 없으면 생성 (멱등성)
|
||||
CREATE TABLE IF NOT EXISTS migration_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_file VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
status VARCHAR(50),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
SELECT status INTO current_status FROM migration_log WHERE migration_file = migration_name;
|
||||
|
||||
IF current_status IS NULL THEN
|
||||
RAISE NOTICE '--- 마이그레이션 % 시작 ---', migration_name;
|
||||
|
||||
-- issues 테이블에 관리함 관련 컬럼들 추가
|
||||
|
||||
-- 완료 사진 경로
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path') THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(255);
|
||||
RAISE NOTICE '✅ issues.completion_photo_path 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.completion_photo_path 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 해결방안 (관리함에서 입력)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'solution') THEN
|
||||
ALTER TABLE issues ADD COLUMN solution TEXT;
|
||||
RAISE NOTICE '✅ issues.solution 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.solution 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 담당부서 (관리함에서 선택)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_department') THEN
|
||||
ALTER TABLE issues ADD COLUMN responsible_department department_type;
|
||||
RAISE NOTICE '✅ issues.responsible_department 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.responsible_department 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 담당자 (관리함에서 입력)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_person') THEN
|
||||
ALTER TABLE issues ADD COLUMN responsible_person VARCHAR(100);
|
||||
RAISE NOTICE '✅ issues.responsible_person 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.responsible_person 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 조치 예상일 (관리함에서 입력)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'expected_completion_date') THEN
|
||||
ALTER TABLE issues ADD COLUMN expected_completion_date DATE;
|
||||
RAISE NOTICE '✅ issues.expected_completion_date 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.expected_completion_date 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 완료 확인일 (완료 상태로 변경 시 자동 입력)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'actual_completion_date') THEN
|
||||
ALTER TABLE issues ADD COLUMN actual_completion_date DATE;
|
||||
RAISE NOTICE '✅ issues.actual_completion_date 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.actual_completion_date 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 원인부서 (관리함에서 입력)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'cause_department') THEN
|
||||
ALTER TABLE issues ADD COLUMN cause_department department_type;
|
||||
RAISE NOTICE '✅ issues.cause_department 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.cause_department 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- ISSUE에 대한 의견 (관리함에서 입력)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'management_comment') THEN
|
||||
ALTER TABLE issues ADD COLUMN management_comment TEXT;
|
||||
RAISE NOTICE '✅ issues.management_comment 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.management_comment 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 프로젝트별 순번 (No) - 프로젝트 내에서 1, 2, 3... 순으로 증가
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'project_sequence_no') THEN
|
||||
ALTER TABLE issues ADD COLUMN project_sequence_no INTEGER;
|
||||
RAISE NOTICE '✅ issues.project_sequence_no 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.project_sequence_no 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 최종 내용 (수정된 내용이 있으면 수정본, 없으면 원본)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'final_description') THEN
|
||||
ALTER TABLE issues ADD COLUMN final_description TEXT;
|
||||
RAISE NOTICE '✅ issues.final_description 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.final_description 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 최종 카테고리 (수정된 카테고리가 있으면 수정본, 없으면 원본)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'final_category') THEN
|
||||
ALTER TABLE issues ADD COLUMN final_category issuecategory;
|
||||
RAISE NOTICE '✅ issues.final_category 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.final_category 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 인덱스 추가
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_project_sequence') THEN
|
||||
CREATE INDEX idx_issues_project_sequence ON issues (project_id, project_sequence_no);
|
||||
RAISE NOTICE '✅ idx_issues_project_sequence 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_issues_project_sequence 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_responsible_department') THEN
|
||||
CREATE INDEX idx_issues_responsible_department ON issues (responsible_department) WHERE responsible_department IS NOT NULL;
|
||||
RAISE NOTICE '✅ idx_issues_responsible_department 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_issues_responsible_department 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_expected_completion') THEN
|
||||
CREATE INDEX idx_issues_expected_completion ON issues (expected_completion_date) WHERE expected_completion_date IS NOT NULL;
|
||||
RAISE NOTICE '✅ idx_issues_expected_completion 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_issues_expected_completion 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 프로젝트별 순번 자동 생성 함수
|
||||
CREATE OR REPLACE FUNCTION generate_project_sequence_no(p_project_id BIGINT) RETURNS INTEGER AS $func$
|
||||
DECLARE
|
||||
next_no INTEGER;
|
||||
BEGIN
|
||||
-- 해당 프로젝트의 최대 순번 + 1
|
||||
SELECT COALESCE(MAX(project_sequence_no), 0) + 1
|
||||
INTO next_no
|
||||
FROM issues
|
||||
WHERE project_id = p_project_id;
|
||||
|
||||
RETURN next_no;
|
||||
END;
|
||||
$func$ LANGUAGE plpgsql;
|
||||
RAISE NOTICE '✅ generate_project_sequence_no 함수가 생성되었습니다.';
|
||||
|
||||
-- 기존 이슈들에 대해 프로젝트별 순번 설정
|
||||
DO $update_sequence$
|
||||
DECLARE
|
||||
issue_record RECORD;
|
||||
seq_no INTEGER;
|
||||
BEGIN
|
||||
FOR issue_record IN
|
||||
SELECT id, project_id
|
||||
FROM issues
|
||||
WHERE project_sequence_no IS NULL
|
||||
ORDER BY project_id, report_date
|
||||
LOOP
|
||||
SELECT generate_project_sequence_no(issue_record.project_id) INTO seq_no;
|
||||
UPDATE issues
|
||||
SET project_sequence_no = seq_no
|
||||
WHERE id = issue_record.id;
|
||||
END LOOP;
|
||||
END $update_sequence$;
|
||||
RAISE NOTICE '✅ 기존 이슈들의 프로젝트별 순번이 설정되었습니다.';
|
||||
|
||||
-- 기존 이슈들의 final_description과 final_category 초기화
|
||||
UPDATE issues
|
||||
SET
|
||||
final_description = description,
|
||||
final_category = category
|
||||
WHERE final_description IS NULL OR final_category IS NULL;
|
||||
RAISE NOTICE '✅ 기존 이슈들의 final_description과 final_category가 초기화되었습니다.';
|
||||
|
||||
-- 마이그레이션 검증
|
||||
DECLARE
|
||||
col_count INTEGER;
|
||||
idx_count INTEGER;
|
||||
func_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO col_count FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name IN (
|
||||
'completion_photo_path', 'solution', 'responsible_department', 'responsible_person',
|
||||
'expected_completion_date', 'actual_completion_date', 'cause_department',
|
||||
'management_comment', 'project_sequence_no', 'final_description', 'final_category'
|
||||
);
|
||||
|
||||
SELECT COUNT(*) INTO idx_count FROM pg_indexes
|
||||
WHERE tablename = 'issues' AND indexname IN (
|
||||
'idx_issues_project_sequence', 'idx_issues_responsible_department', 'idx_issues_expected_completion'
|
||||
);
|
||||
|
||||
SELECT COUNT(*) INTO func_count FROM pg_proc WHERE proname = 'generate_project_sequence_no';
|
||||
|
||||
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
|
||||
RAISE NOTICE '추가된 컬럼: %/11개', col_count;
|
||||
RAISE NOTICE '생성된 인덱스: %/3개', idx_count;
|
||||
RAISE NOTICE '생성된 함수: %/1개', func_count;
|
||||
|
||||
IF col_count = 11 AND idx_count = 3 AND func_count = 1 THEN
|
||||
RAISE NOTICE '✅ 마이그레이션이 성공적으로 완료되었습니다!';
|
||||
INSERT INTO migration_log (migration_file, status, notes) VALUES (migration_name, 'SUCCESS', migration_notes);
|
||||
ELSE
|
||||
RAISE EXCEPTION '❌ 마이그레이션 검증 실패!';
|
||||
END IF;
|
||||
END;
|
||||
|
||||
ELSIF current_status = 'SUCCESS' THEN
|
||||
RAISE NOTICE 'ℹ️ 마이그레이션 %는 이미 성공적으로 실행되었습니다. 스킵합니다.', migration_name;
|
||||
ELSE
|
||||
RAISE NOTICE '⚠️ 마이그레이션 %는 이전에 실패했습니다. 수동 확인이 필요합니다.', migration_name;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,87 @@
|
||||
-- 프로젝트별 순번(project_sequence_no) 자동 할당 개선
|
||||
-- 수신함에서 진행 중/완료로 상태 변경 시 프로젝트별 순번이 자동 할당되도록 개선
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
issue_record RECORD;
|
||||
seq_no INTEGER;
|
||||
updated_count INTEGER := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '=== 프로젝트별 순번 자동 할당 개선 마이그레이션 시작 ===';
|
||||
|
||||
-- 1. generate_project_sequence_no 함수가 존재하는지 확인
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'generate_project_sequence_no') THEN
|
||||
RAISE EXCEPTION '❌ generate_project_sequence_no 함수가 존재하지 않습니다. 016_add_management_fields.sql을 먼저 실행하세요.';
|
||||
END IF;
|
||||
|
||||
-- 2. 진행 중 또는 완료 상태인데 project_sequence_no가 NULL인 이슈들 찾기
|
||||
RAISE NOTICE '🔍 project_sequence_no가 누락된 이슈들을 찾는 중...';
|
||||
|
||||
FOR issue_record IN
|
||||
SELECT id, project_id, review_status
|
||||
FROM issues
|
||||
WHERE review_status IN ('in_progress', 'completed')
|
||||
AND project_sequence_no IS NULL
|
||||
ORDER BY project_id, reviewed_at NULLS LAST, report_date
|
||||
LOOP
|
||||
-- 프로젝트별 순번 생성
|
||||
SELECT generate_project_sequence_no(issue_record.project_id) INTO seq_no;
|
||||
|
||||
-- 순번 할당
|
||||
UPDATE issues
|
||||
SET project_sequence_no = seq_no
|
||||
WHERE id = issue_record.id;
|
||||
|
||||
updated_count := updated_count + 1;
|
||||
|
||||
RAISE NOTICE '✅ 이슈 ID: %, 프로젝트 ID: %, 할당된 순번: %',
|
||||
issue_record.id, issue_record.project_id, seq_no;
|
||||
END LOOP;
|
||||
|
||||
-- 3. 결과 요약
|
||||
RAISE NOTICE '=== 마이그레이션 완료 ===';
|
||||
RAISE NOTICE '📊 총 %개의 이슈에 프로젝트별 순번이 할당되었습니다.', updated_count;
|
||||
|
||||
-- 4. 검증
|
||||
DECLARE
|
||||
missing_count INTEGER;
|
||||
total_managed_count INTEGER;
|
||||
BEGIN
|
||||
-- 관리 중인 이슈 중 순번이 없는 것들 확인
|
||||
SELECT COUNT(*) INTO missing_count
|
||||
FROM issues
|
||||
WHERE review_status IN ('in_progress', 'completed')
|
||||
AND project_sequence_no IS NULL;
|
||||
|
||||
-- 전체 관리 중인 이슈 수
|
||||
SELECT COUNT(*) INTO total_managed_count
|
||||
FROM issues
|
||||
WHERE review_status IN ('in_progress', 'completed');
|
||||
|
||||
RAISE NOTICE '=== 검증 결과 ===';
|
||||
RAISE NOTICE '전체 관리 중인 이슈: %개', total_managed_count;
|
||||
RAISE NOTICE '순번이 누락된 이슈: %개', missing_count;
|
||||
|
||||
IF missing_count > 0 THEN
|
||||
RAISE WARNING '⚠️ 여전히 %개의 이슈에 순번이 누락되어 있습니다.', missing_count;
|
||||
ELSE
|
||||
RAISE NOTICE '✅ 모든 관리 중인 이슈에 순번이 정상적으로 할당되었습니다.';
|
||||
END IF;
|
||||
END;
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE EXCEPTION '❌ 마이그레이션 실행 중 오류 발생: %', SQLERRM;
|
||||
END $migration$;
|
||||
|
||||
-- 마이그레이션 로그 기록
|
||||
INSERT INTO migration_log (migration_file, executed_at, status, notes)
|
||||
VALUES (
|
||||
'017_fix_project_sequence_no.sql',
|
||||
NOW(),
|
||||
'SUCCESS',
|
||||
'프로젝트별 순번 자동 할당 개선: 수신함에서 진행중/완료로 상태 변경시 project_sequence_no 자동 할당되도록 백엔드 로직 개선 및 기존 데이터 보정'
|
||||
) ON CONFLICT (migration_file) DO UPDATE SET
|
||||
executed_at = NOW(),
|
||||
status = EXCLUDED.status,
|
||||
notes = EXCLUDED.notes;
|
||||
@@ -0,0 +1,85 @@
|
||||
-- 수신함 추가 정보 필드 추가
|
||||
-- 원인부서, 해당자, 원인 상세 정보를 기록하기 위한 필드들
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
col_count INTEGER := 0;
|
||||
idx_count INTEGER := 0;
|
||||
BEGIN
|
||||
RAISE NOTICE '=== 수신함 추가 정보 필드 추가 마이그레이션 시작 ===';
|
||||
|
||||
-- 1. 해당자 상세 정보 (responsible_person과 별도)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'responsible_person_detail') THEN
|
||||
ALTER TABLE issues ADD COLUMN responsible_person_detail VARCHAR(200);
|
||||
RAISE NOTICE '✅ issues.responsible_person_detail 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.responsible_person_detail 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 2. 원인 상세 정보 (기록용)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'cause_detail') THEN
|
||||
ALTER TABLE issues ADD COLUMN cause_detail TEXT;
|
||||
RAISE NOTICE '✅ issues.cause_detail 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.cause_detail 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 3. 추가 정보 입력 시간 (언제 입력되었는지 기록)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'additional_info_updated_at') THEN
|
||||
ALTER TABLE issues ADD COLUMN additional_info_updated_at TIMESTAMP WITH TIME ZONE;
|
||||
RAISE NOTICE '✅ issues.additional_info_updated_at 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.additional_info_updated_at 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 4. 추가 정보 입력자 ID (누가 입력했는지 기록)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'additional_info_updated_by_id') THEN
|
||||
ALTER TABLE issues ADD COLUMN additional_info_updated_by_id INTEGER REFERENCES users(id);
|
||||
RAISE NOTICE '✅ issues.additional_info_updated_by_id 컬럼이 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ issues.additional_info_updated_by_id 컬럼이 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 인덱스 추가 (검색 성능 향상)
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'issues' AND indexname = 'idx_issues_additional_info_updated_at') THEN
|
||||
CREATE INDEX idx_issues_additional_info_updated_at ON issues (additional_info_updated_at);
|
||||
RAISE NOTICE '✅ idx_issues_additional_info_updated_at 인덱스가 생성되었습니다.';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ idx_issues_additional_info_updated_at 인덱스가 이미 존재합니다.';
|
||||
END IF;
|
||||
|
||||
-- 검증
|
||||
SELECT COUNT(*) INTO col_count FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name IN (
|
||||
'responsible_person_detail', 'cause_detail', 'additional_info_updated_at', 'additional_info_updated_by_id'
|
||||
);
|
||||
|
||||
SELECT COUNT(*) INTO idx_count FROM pg_indexes
|
||||
WHERE tablename = 'issues' AND indexname = 'idx_issues_additional_info_updated_at';
|
||||
|
||||
RAISE NOTICE '=== 마이그레이션 검증 결과 ===';
|
||||
RAISE NOTICE '추가된 컬럼: %/4개', col_count;
|
||||
RAISE NOTICE '생성된 인덱스: %/1개', idx_count;
|
||||
|
||||
IF col_count = 4 AND idx_count = 1 THEN
|
||||
RAISE NOTICE '✅ 모든 추가 정보 필드가 성공적으로 추가되었습니다.';
|
||||
ELSE
|
||||
RAISE WARNING '⚠️ 일부 필드나 인덱스가 누락되었습니다.';
|
||||
END IF;
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE EXCEPTION '❌ 마이그레이션 실행 중 오류 발생: %', SQLERRM;
|
||||
END $migration$;
|
||||
|
||||
-- 마이그레이션 로그 기록
|
||||
INSERT INTO migration_log (migration_file, executed_at, status, notes)
|
||||
VALUES (
|
||||
'018_add_additional_info_fields.sql',
|
||||
NOW(),
|
||||
'SUCCESS',
|
||||
'수신함 추가 정보 필드 추가: 해당자 상세(responsible_person_detail), 원인 상세(cause_detail), 입력 시간/입력자 추적 필드'
|
||||
) ON CONFLICT (migration_file) DO UPDATE SET
|
||||
executed_at = NOW(),
|
||||
status = EXCLUDED.status,
|
||||
notes = EXCLUDED.notes;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 삭제 로그 테이블 추가
|
||||
-- 생성일: 2025-11-08
|
||||
-- 설명: 부적합 등 엔티티 삭제 시 로그를 보관하기 위한 테이블
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deletion_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER NOT NULL,
|
||||
entity_data JSONB NOT NULL,
|
||||
deleted_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'Asia/Seoul'),
|
||||
reason TEXT
|
||||
);
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX IF NOT EXISTS idx_deletion_logs_entity_type ON deletion_logs(entity_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_deletion_logs_entity_id ON deletion_logs(entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deletion_logs_deleted_by ON deletion_logs(deleted_by_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deletion_logs_deleted_at ON deletion_logs(deleted_at);
|
||||
|
||||
-- 테이블 코멘트
|
||||
COMMENT ON TABLE deletion_logs IS '엔티티 삭제 로그 - 삭제된 데이터의 백업 및 추적';
|
||||
COMMENT ON COLUMN deletion_logs.entity_type IS '삭제된 엔티티 타입 (issue, project, daily_work 등)';
|
||||
COMMENT ON COLUMN deletion_logs.entity_id IS '삭제된 엔티티의 ID';
|
||||
COMMENT ON COLUMN deletion_logs.entity_data IS '삭제 시점의 엔티티 전체 데이터 (JSON)';
|
||||
COMMENT ON COLUMN deletion_logs.deleted_by_id IS '삭제 실행자 ID';
|
||||
COMMENT ON COLUMN deletion_logs.deleted_at IS '삭제 시각 (KST)';
|
||||
COMMENT ON COLUMN deletion_logs.reason IS '삭제 사유 (선택사항)';
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 완료 신청 관련 필드 추가
|
||||
-- 마이그레이션: 019_add_completion_request_fields.sql
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 완료 신청 관련 필드들 추가
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_requested_at') THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_requested_at TIMESTAMP WITH TIME ZONE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_requested_by_id') THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_requested_by_id INTEGER REFERENCES users(id);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_photo_path') THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(500);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'issues' AND column_name = 'completion_comment') THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_comment TEXT;
|
||||
END IF;
|
||||
|
||||
-- 마이그레이션 로그 기록
|
||||
INSERT INTO migration_log (migration_file, executed_at, status, notes)
|
||||
VALUES ('019_add_completion_request_fields.sql', NOW(), 'SUCCESS', 'Added completion request fields: completion_requested_at, completion_requested_by_id, completion_photo_path, completion_comment');
|
||||
|
||||
RAISE NOTICE '✅ 완료 신청 관련 필드 추가 완료';
|
||||
RAISE NOTICE '📝 완료 신청 필드 마이그레이션 완료 - 019_add_completion_request_fields.sql';
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- 오류 발생 시 로그 기록
|
||||
INSERT INTO migration_log (migration_file, executed_at, status, notes)
|
||||
VALUES ('019_add_completion_request_fields.sql', NOW(), 'FAILED', 'Error: ' || SQLERRM);
|
||||
|
||||
RAISE EXCEPTION '❌ 마이그레이션 실패: %', SQLERRM;
|
||||
END $$;
|
||||
@@ -0,0 +1,91 @@
|
||||
-- 020_add_management_completion_fields.sql
|
||||
-- 관리함 완료 신청 정보 필드 추가
|
||||
-- 작성일: 2025-10-26
|
||||
-- 목적: 완료 사진 및 코멘트 수정 기능 지원
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 마이그레이션 로그 확인
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 이미 실행된 마이그레이션인지 확인
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM migration_log
|
||||
WHERE migration_file = '020_add_management_completion_fields.sql'
|
||||
AND status = 'completed'
|
||||
) THEN
|
||||
RAISE NOTICE '마이그레이션이 이미 실행되었습니다: 020_add_management_completion_fields.sql';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 마이그레이션 시작 로그
|
||||
INSERT INTO migration_log (migration_file, status, started_at, notes)
|
||||
VALUES ('020_add_management_completion_fields.sql', 'running', NOW(),
|
||||
'관리함 완료 신청 정보 필드 추가 - completion_photo, completion_comment 수정 기능');
|
||||
|
||||
-- completion_photo_path 컬럼이 없으면 추가 (이미 있을 수 있음)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'completion_photo_path'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_photo_path VARCHAR(500);
|
||||
RAISE NOTICE '✅ completion_photo_path 컬럼 추가됨';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ completion_photo_path 컬럼이 이미 존재함';
|
||||
END IF;
|
||||
|
||||
-- completion_comment 컬럼이 없으면 추가 (이미 있을 수 있음)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'completion_comment'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_comment TEXT;
|
||||
RAISE NOTICE '✅ completion_comment 컬럼 추가됨';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ completion_comment 컬럼이 이미 존재함';
|
||||
END IF;
|
||||
|
||||
-- completion_requested_at 컬럼이 없으면 추가 (이미 있을 수 있음)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'completion_requested_at'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_requested_at TIMESTAMP WITH TIME ZONE;
|
||||
RAISE NOTICE '✅ completion_requested_at 컬럼 추가됨';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ completion_requested_at 컬럼이 이미 존재함';
|
||||
END IF;
|
||||
|
||||
-- completion_requested_by_id 컬럼이 없으면 추가 (이미 있을 수 있음)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'issues' AND column_name = 'completion_requested_by_id'
|
||||
) THEN
|
||||
ALTER TABLE issues ADD COLUMN completion_requested_by_id INTEGER REFERENCES users(id);
|
||||
RAISE NOTICE '✅ completion_requested_by_id 컬럼 추가됨';
|
||||
ELSE
|
||||
RAISE NOTICE 'ℹ️ completion_requested_by_id 컬럼이 이미 존재함';
|
||||
END IF;
|
||||
|
||||
-- 마이그레이션 완료 로그
|
||||
UPDATE migration_log
|
||||
SET status = 'completed', completed_at = NOW(),
|
||||
notes = notes || ' - 완료: 모든 필요한 컬럼이 추가되었습니다.'
|
||||
WHERE migration_file = '020_add_management_completion_fields.sql'
|
||||
AND status = 'running';
|
||||
|
||||
RAISE NOTICE '🎉 마이그레이션 완료: 020_add_management_completion_fields.sql';
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
-- 에러 발생 시 로그 업데이트
|
||||
UPDATE migration_log
|
||||
SET status = 'failed', completed_at = NOW(),
|
||||
notes = notes || ' - 실패: ' || SQLERRM
|
||||
WHERE migration_file = '020_add_management_completion_fields.sql'
|
||||
AND status = 'running';
|
||||
|
||||
RAISE EXCEPTION '❌ 마이그레이션 실패: %', SQLERRM;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
15
system3-nonconformance/api/requirements.txt
Normal file
15
system3-nonconformance/api/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.12.1
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
pillow==10.1.0
|
||||
pillow-heif==0.13.0
|
||||
reportlab==4.0.7
|
||||
openpyxl==3.1.2
|
||||
186
system3-nonconformance/api/routers/auth.py
Normal file
186
system3-nonconformance/api/routers/auth.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import User, UserRole
|
||||
from database import schemas
|
||||
from services.auth_service import (
|
||||
authenticate_user, create_access_token, verify_token,
|
||||
get_password_hash, verify_password
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
token_data = verify_token(token, credentials_exception)
|
||||
user = db.query(User).filter(User.username == token_data.username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
@router.options("/login")
|
||||
async def login_options():
|
||||
"""OPTIONS preflight 요청 처리"""
|
||||
return {"message": "OK"}
|
||||
|
||||
@router.post("/login", response_model=schemas.Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token = create_access_token(data={"sub": user.username})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=schemas.User)
|
||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
@router.get("/users", response_model=List[schemas.User])
|
||||
async def get_all_users(
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""모든 사용자 목록 조회 (관리자 전용)"""
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
return users
|
||||
|
||||
@router.post("/users", response_model=schemas.User)
|
||||
async def create_user(
|
||||
user: schemas.UserCreate,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 중복 확인
|
||||
db_user = db.query(User).filter(User.username == user.username).first()
|
||||
if db_user:
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
|
||||
# 사용자 생성
|
||||
db_user = User(
|
||||
username=user.username,
|
||||
hashed_password=get_password_hash(user.password),
|
||||
full_name=user.full_name,
|
||||
role=user.role
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.get("/users", response_model=List[schemas.User])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
@router.put("/users/{user_id}", response_model=schemas.User)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_update: schemas.UserUpdate,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 업데이트
|
||||
update_data = user_update.dict(exclude_unset=True)
|
||||
if "password" in update_data:
|
||||
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(db_user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
async def delete_user(
|
||||
username: str,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_user = db.query(User).filter(User.username == username).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# hyungi 계정은 삭제 불가
|
||||
if db_user.username == "hyungi":
|
||||
raise HTTPException(status_code=400, detail="Cannot delete primary admin user")
|
||||
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
return {"detail": "User deleted successfully"}
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
password_change: schemas.PasswordChange,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 현재 비밀번호 확인
|
||||
if not verify_password(password_change.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password"
|
||||
)
|
||||
|
||||
# 새 비밀번호 설정
|
||||
current_user.hashed_password = get_password_hash(password_change.new_password)
|
||||
db.commit()
|
||||
|
||||
return {"detail": "Password changed successfully"}
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
new_password: str
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
password_reset: PasswordReset,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 비밀번호 초기화 (관리자 전용)"""
|
||||
db_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 새 비밀번호로 업데이트
|
||||
db_user.hashed_password = get_password_hash(password_reset.new_password)
|
||||
db.commit()
|
||||
|
||||
return {"detail": f"Password reset successfully for user {db_user.username}"}
|
||||
163
system3-nonconformance/api/routers/daily_work.py
Normal file
163
system3-nonconformance/api/routers/daily_work.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import DailyWork, User, UserRole, KST
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/daily-work", tags=["daily-work"])
|
||||
|
||||
@router.post("/", response_model=schemas.DailyWork)
|
||||
async def create_daily_work(
|
||||
work: schemas.DailyWorkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 중복 확인 (같은 날짜)
|
||||
existing = db.query(DailyWork).filter(
|
||||
DailyWork.date == work.date.date()
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Daily work for this date already exists")
|
||||
|
||||
# 계산
|
||||
regular_hours = work.worker_count * 8 # 정규 근무 8시간
|
||||
overtime_total = work.overtime_workers * work.overtime_hours
|
||||
total_hours = regular_hours + overtime_total
|
||||
|
||||
# 생성
|
||||
db_work = DailyWork(
|
||||
date=work.date,
|
||||
worker_count=work.worker_count,
|
||||
regular_hours=regular_hours,
|
||||
overtime_workers=work.overtime_workers,
|
||||
overtime_hours=work.overtime_hours,
|
||||
overtime_total=overtime_total,
|
||||
total_hours=total_hours,
|
||||
created_by_id=current_user.id
|
||||
)
|
||||
db.add(db_work)
|
||||
db.commit()
|
||||
db.refresh(db_work)
|
||||
return db_work
|
||||
|
||||
@router.get("/", response_model=List[schemas.DailyWork])
|
||||
async def read_daily_works(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(DailyWork)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(DailyWork.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(DailyWork.date <= end_date)
|
||||
|
||||
works = query.order_by(DailyWork.date.desc()).offset(skip).limit(limit).all()
|
||||
return works
|
||||
|
||||
@router.get("/{work_id}", response_model=schemas.DailyWork)
|
||||
async def read_daily_work(
|
||||
work_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
return work
|
||||
|
||||
@router.put("/{work_id}", response_model=schemas.DailyWork)
|
||||
async def update_daily_work(
|
||||
work_id: int,
|
||||
work_update: schemas.DailyWorkUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
|
||||
# 업데이트
|
||||
update_data = work_update.dict(exclude_unset=True)
|
||||
|
||||
# 재계산 필요한 경우
|
||||
if any(key in update_data for key in ["worker_count", "overtime_workers", "overtime_hours"]):
|
||||
worker_count = update_data.get("worker_count", work.worker_count)
|
||||
overtime_workers = update_data.get("overtime_workers", work.overtime_workers)
|
||||
overtime_hours = update_data.get("overtime_hours", work.overtime_hours)
|
||||
|
||||
regular_hours = worker_count * 8
|
||||
overtime_total = overtime_workers * overtime_hours
|
||||
total_hours = regular_hours + overtime_total
|
||||
|
||||
update_data["regular_hours"] = regular_hours
|
||||
update_data["overtime_total"] = overtime_total
|
||||
update_data["total_hours"] = total_hours
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(work, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(work)
|
||||
return work
|
||||
|
||||
@router.delete("/{work_id}")
|
||||
async def delete_daily_work(
|
||||
work_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
|
||||
# 권한 확인 (관리자만 삭제 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
|
||||
|
||||
db.delete(work)
|
||||
db.commit()
|
||||
return {"detail": "Daily work deleted successfully"}
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_daily_work_stats(
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일 공수 통계"""
|
||||
query = db.query(DailyWork)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(DailyWork.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(DailyWork.date <= end_date)
|
||||
|
||||
works = query.all()
|
||||
|
||||
if not works:
|
||||
return {
|
||||
"total_days": 0,
|
||||
"total_hours": 0,
|
||||
"total_overtime": 0,
|
||||
"average_daily_hours": 0
|
||||
}
|
||||
|
||||
total_hours = sum(w.total_hours for w in works)
|
||||
total_overtime = sum(w.overtime_total for w in works)
|
||||
|
||||
return {
|
||||
"total_days": len(works),
|
||||
"total_hours": total_hours,
|
||||
"total_overtime": total_overtime,
|
||||
"average_daily_hours": total_hours / len(works)
|
||||
}
|
||||
404
system3-nonconformance/api/routers/inbox.py
Normal file
404
system3-nonconformance/api/routers/inbox.py
Normal file
@@ -0,0 +1,404 @@
|
||||
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, ManagementUpdateRequest
|
||||
)
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/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_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
부적합 폐기 처리
|
||||
"""
|
||||
# 수신함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_inbox', 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="부적합을 찾을 수 없습니다.")
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
# 중복 처리 로직
|
||||
if disposal_request.disposal_reason == DisposalReasonType.duplicate and disposal_request.duplicate_of_issue_id:
|
||||
# 중복 대상 이슈 확인
|
||||
target_issue = db.query(Issue).filter(Issue.id == disposal_request.duplicate_of_issue_id).first()
|
||||
if not target_issue:
|
||||
raise HTTPException(status_code=404, detail="중복 대상 이슈를 찾을 수 없습니다.")
|
||||
|
||||
# 중복 신고자를 대상 이슈에 추가
|
||||
current_reporters = target_issue.duplicate_reporters or []
|
||||
new_reporter = {
|
||||
"user_id": issue.reporter_id,
|
||||
"username": issue.reporter.username,
|
||||
"full_name": issue.reporter.full_name,
|
||||
"report_date": issue.report_date.isoformat() if issue.report_date else None,
|
||||
"added_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 중복 체크 (이미 추가된 신고자인지 확인)
|
||||
existing_reporter = next((r for r in current_reporters if r.get("user_id") == issue.reporter_id), None)
|
||||
if not existing_reporter:
|
||||
current_reporters.append(new_reporter)
|
||||
target_issue.duplicate_reporters = current_reporters
|
||||
|
||||
# 현재 이슈에 중복 대상 설정
|
||||
issue.duplicate_of_issue_id = disposal_request.duplicate_of_issue_id
|
||||
|
||||
# 폐기 처리
|
||||
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_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
부적합 검토 및 수정
|
||||
"""
|
||||
# 수신함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_inbox', 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="부적합을 찾을 수 없습니다.")
|
||||
|
||||
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_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
부적합 최종 상태 결정 (진행 중 / 완료)
|
||||
"""
|
||||
# 수신함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_inbox', 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="부적합을 찾을 수 없습니다.")
|
||||
|
||||
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()
|
||||
|
||||
# 진행 중 또는 완료 상태로 변경 시 프로젝트별 순번 자동 할당
|
||||
if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed]:
|
||||
if not issue.project_sequence_no:
|
||||
from sqlalchemy import text
|
||||
result = db.execute(
|
||||
text("SELECT generate_project_sequence_no(:project_id)"),
|
||||
{"project_id": issue.project_id}
|
||||
)
|
||||
issue.project_sequence_no = result.scalar()
|
||||
|
||||
# 완료 사진 업로드 처리
|
||||
if status_request.completion_photo and status_request.review_status == ReviewStatus.completed:
|
||||
try:
|
||||
completion_photo_path = save_base64_image(status_request.completion_photo, "completion")
|
||||
issue.completion_photo_path = completion_photo_path
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
|
||||
|
||||
# 완료 상태로 변경 시 추가 정보 처리
|
||||
if status_request.review_status == ReviewStatus.completed:
|
||||
issue.actual_completion_date = datetime.now().date()
|
||||
|
||||
# 해결방안 저장
|
||||
if status_request.solution:
|
||||
issue.solution = status_request.solution
|
||||
|
||||
# 담당부서 저장
|
||||
if status_request.responsible_department:
|
||||
issue.responsible_department = status_request.responsible_department
|
||||
|
||||
# 담당자 저장
|
||||
if status_request.responsible_person:
|
||||
issue.responsible_person = status_request.responsible_person
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@router.get("/management-issues")
|
||||
async def get_management_issues(
|
||||
project_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""관리함 이슈 목록 조회 (중복 선택용)"""
|
||||
try:
|
||||
query = db.query(Issue).filter(
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
)
|
||||
|
||||
# 프로젝트 필터 적용
|
||||
if project_id:
|
||||
query = query.filter(Issue.project_id == project_id)
|
||||
|
||||
issues = query.order_by(Issue.reviewed_at.desc()).limit(50).all()
|
||||
|
||||
# 간단한 형태로 반환 (제목과 ID만)
|
||||
result = []
|
||||
for issue in issues:
|
||||
result.append({
|
||||
"id": issue.id,
|
||||
"description": issue.description[:100] + "..." if len(issue.description) > 100 else issue.description,
|
||||
"category": issue.category.value,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"reviewed_at": issue.reviewed_at.isoformat() if issue.reviewed_at else None,
|
||||
"duplicate_count": len(issue.duplicate_reporters) if issue.duplicate_reporters else 0
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"관리함 이슈 조회 중 오류가 발생했습니다: {str(e)}")
|
||||
488
system3-nonconformance/api/routers/issues.py
Normal file
488
system3-nonconformance/api/routers/issues.py
Normal file
@@ -0,0 +1,488 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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, 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)
|
||||
):
|
||||
print(f"DEBUG: 받은 issue 데이터: {issue}")
|
||||
print(f"DEBUG: project_id: {issue.project_id}")
|
||||
# 이미지 저장 (최대 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_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)
|
||||
|
||||
# 사진 업데이트 처리 (최대 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:
|
||||
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)
|
||||
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:
|
||||
print(f"DEBUG: {len(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.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)
|
||||
):
|
||||
"""
|
||||
관리함에서 이슈의 관리 관련 필드들을 업데이트합니다.
|
||||
"""
|
||||
print(f"DEBUG: Received management update for issue {issue_id}")
|
||||
print(f"DEBUG: Update data: {management_update}")
|
||||
print(f"DEBUG: Current user: {current_user.username}")
|
||||
|
||||
# 관리함 페이지 권한 확인
|
||||
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="부적합을 찾을 수 없습니다.")
|
||||
|
||||
print(f"DEBUG: Found issue: {issue.id}")
|
||||
|
||||
# 관리함에서만 수정 가능한 필드들만 업데이트
|
||||
update_data = management_update.dict(exclude_unset=True)
|
||||
print(f"DEBUG: Update data dict: {update_data}")
|
||||
|
||||
# 완료 사진 처리 (최대 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)
|
||||
print(f"DEBUG: Saved {photo_field}: {new_path}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Photo save error for {photo_field}: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
|
||||
|
||||
# 나머지 필드 처리 (완료 사진 제외)
|
||||
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}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Field set error for {field}: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"필드 {field} 설정 실패: {str(e)}")
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
print(f"DEBUG: Successfully updated issue {issue.id}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Database commit error: {str(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:
|
||||
print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}")
|
||||
|
||||
# 완료 사진 저장 (최대 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_comment = request.completion_comment
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
print(f"DEBUG: DB 업데이트 완료")
|
||||
|
||||
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:
|
||||
print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}")
|
||||
|
||||
# 완료 사진 파일 삭제 (최대 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
|
||||
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)
|
||||
print(f"DEBUG: 완료 반려 처리 완료")
|
||||
|
||||
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)}")
|
||||
212
system3-nonconformance/api/routers/management.py
Normal file
212
system3-nonconformance/api/routers/management.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, User, ReviewStatus
|
||||
from database.schemas import (
|
||||
ManagementUpdateRequest, AdditionalInfoUpdateRequest, Issue as IssueSchema, DailyReportStats
|
||||
)
|
||||
from routers.auth import get_current_user
|
||||
from routers.page_permissions import check_page_access
|
||||
|
||||
router = APIRouter(prefix="/api/management", tags=["management"])
|
||||
|
||||
@router.get("/", response_model=List[IssueSchema])
|
||||
async def get_management_issues(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
관리함 - 진행 중 및 완료된 부적합 목록 조회
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 진행 중 또는 완료된 이슈들 조회
|
||||
issues = db.query(Issue).filter(
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
).order_by(Issue.reviewed_at.desc()).all()
|
||||
|
||||
return issues
|
||||
|
||||
@router.put("/{issue_id}")
|
||||
async def update_issue(
|
||||
issue_id: int,
|
||||
update_request: ManagementUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
관리함에서 이슈 정보 업데이트
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not 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="부적합을 찾을 수 없습니다.")
|
||||
|
||||
# 진행 중 또는 완료 대기 상태인지 확인
|
||||
if issue.review_status not in [ReviewStatus.in_progress]:
|
||||
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 수정할 수 있습니다.")
|
||||
|
||||
# 업데이트할 데이터 처리
|
||||
update_data = update_request.dict(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field == 'completion_photo' and value:
|
||||
# 완료 사진 Base64 처리
|
||||
from services.file_service import save_base64_image
|
||||
try:
|
||||
print(f"🔍 완료 사진 처리 시작 - 데이터 길이: {len(value)}")
|
||||
print(f"🔍 Base64 데이터 시작 부분: {value[:100]}...")
|
||||
photo_path = save_base64_image(value, "completion_")
|
||||
if photo_path:
|
||||
issue.completion_photo_path = photo_path
|
||||
print(f"✅ 완료 사진 저장 성공: {photo_path}")
|
||||
else:
|
||||
print("❌ 완료 사진 저장 실패: photo_path가 None")
|
||||
except Exception as e:
|
||||
print(f"❌ 완료 사진 저장 실패: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
elif field == 'final_description' and value:
|
||||
# final_description 업데이트 시 description도 함께 업데이트
|
||||
issue.final_description = value
|
||||
issue.description = value
|
||||
print(f"✅ final_description 및 description 업데이트: {value[:50]}...")
|
||||
continue
|
||||
elif field == 'final_category' and value:
|
||||
# final_category 업데이트 시 category도 함께 업데이트
|
||||
issue.final_category = value
|
||||
issue.category = value
|
||||
print(f"✅ final_category 및 category 업데이트: {value}")
|
||||
continue
|
||||
elif field == 'expected_completion_date' and value:
|
||||
# 날짜 필드 처리
|
||||
if not value.endswith('T00:00:00'):
|
||||
value = value + 'T00:00:00'
|
||||
setattr(issue, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return {
|
||||
"message": "이슈가 성공적으로 업데이트되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"updated_at": datetime.now()
|
||||
}
|
||||
|
||||
@router.put("/{issue_id}/additional-info")
|
||||
async def update_additional_info(
|
||||
issue_id: int,
|
||||
additional_info: AdditionalInfoUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
추가 정보 업데이트 (원인부서, 해당자 상세, 원인 상세)
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not 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="부적합을 찾을 수 없습니다.")
|
||||
|
||||
# 진행 중 상태인지 확인
|
||||
if issue.review_status != ReviewStatus.in_progress:
|
||||
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 추가 정보를 입력할 수 있습니다.")
|
||||
|
||||
# 추가 정보 업데이트
|
||||
update_data = additional_info.dict(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(issue, field, value)
|
||||
|
||||
# 추가 정보 입력 시간 및 입력자 기록
|
||||
issue.additional_info_updated_at = datetime.now()
|
||||
issue.additional_info_updated_by_id = current_user.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return {
|
||||
"message": "추가 정보가 성공적으로 업데이트되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"updated_at": issue.additional_info_updated_at,
|
||||
"updated_by": current_user.username
|
||||
}
|
||||
|
||||
@router.get("/{issue_id}/additional-info")
|
||||
async def get_additional_info(
|
||||
issue_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
추가 정보 조회
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not 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="부적합을 찾을 수 없습니다.")
|
||||
|
||||
return {
|
||||
"issue_id": issue.id,
|
||||
"cause_department": issue.cause_department.value if issue.cause_department else None,
|
||||
"responsible_person_detail": issue.responsible_person_detail,
|
||||
"cause_detail": issue.cause_detail,
|
||||
"additional_info_updated_at": issue.additional_info_updated_at,
|
||||
"additional_info_updated_by_id": issue.additional_info_updated_by_id
|
||||
}
|
||||
|
||||
@router.get("/stats", response_model=DailyReportStats)
|
||||
async def get_management_stats(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
프로젝트별 관리함 통계 조회 (보고서용)
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 해당 프로젝트의 관리함 이슈들 조회
|
||||
issues = db.query(Issue).filter(
|
||||
Issue.project_id == project_id,
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
).all()
|
||||
|
||||
# 통계 계산
|
||||
stats = DailyReportStats()
|
||||
today = datetime.now().date()
|
||||
|
||||
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
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
330
system3-nonconformance/api/routers/page_permissions.py
Normal file
330
system3-nonconformance/api/routers/page_permissions.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
페이지 권한 관리 API 라우터
|
||||
사용자별 페이지 접근 권한을 관리하는 엔드포인트들
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import User, UserPagePermission, UserRole
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["page-permissions"])
|
||||
|
||||
# Pydantic 모델들
|
||||
class PagePermissionRequest(BaseModel):
|
||||
user_id: int
|
||||
page_name: str
|
||||
can_access: bool
|
||||
notes: Optional[str] = None
|
||||
|
||||
class PagePermissionResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
page_name: str
|
||||
can_access: bool
|
||||
granted_by_id: Optional[int]
|
||||
granted_at: Optional[datetime]
|
||||
notes: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserPagePermissionSummary(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
full_name: Optional[str]
|
||||
role: str
|
||||
permissions: List[PagePermissionResponse]
|
||||
|
||||
# 기본 페이지 목록
|
||||
DEFAULT_PAGES = {
|
||||
'issues_create': {'title': '부적합 등록', 'default_access': True},
|
||||
'issues_view': {'title': '부적합 조회', 'default_access': True},
|
||||
'issues_manage': {'title': '부적합 관리', 'default_access': True},
|
||||
'issues_inbox': {'title': '수신함', 'default_access': True},
|
||||
'issues_management': {'title': '관리함', 'default_access': False},
|
||||
'issues_archive': {'title': '폐기함', 'default_access': False},
|
||||
'issues_dashboard': {'title': '현황판', 'default_access': True},
|
||||
'projects_manage': {'title': '프로젝트 관리', 'default_access': False},
|
||||
'daily_work': {'title': '일일 공수', 'default_access': False},
|
||||
'reports': {'title': '보고서', 'default_access': False},
|
||||
'users_manage': {'title': '사용자 관리', 'default_access': False}
|
||||
}
|
||||
|
||||
@router.post("/page-permissions/grant")
|
||||
async def grant_page_permission(
|
||||
request: PagePermissionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""페이지 권한 부여/취소"""
|
||||
|
||||
# 관리자만 권한 설정 가능
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 권한을 설정할 수 있습니다."
|
||||
)
|
||||
|
||||
# 대상 사용자 확인
|
||||
target_user = db.query(User).filter(User.id == request.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 유효한 페이지명 확인
|
||||
if request.page_name not in DEFAULT_PAGES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="유효하지 않은 페이지명입니다."
|
||||
)
|
||||
|
||||
# 기존 권한 확인
|
||||
existing_permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == request.user_id,
|
||||
UserPagePermission.page_name == request.page_name
|
||||
).first()
|
||||
|
||||
if existing_permission:
|
||||
# 기존 권한 업데이트
|
||||
existing_permission.can_access = request.can_access
|
||||
existing_permission.granted_by_id = current_user.id
|
||||
existing_permission.notes = request.notes
|
||||
db.commit()
|
||||
db.refresh(existing_permission)
|
||||
return {"message": "권한이 업데이트되었습니다.", "permission_id": existing_permission.id}
|
||||
else:
|
||||
# 새 권한 생성
|
||||
new_permission = UserPagePermission(
|
||||
user_id=request.user_id,
|
||||
page_name=request.page_name,
|
||||
can_access=request.can_access,
|
||||
granted_by_id=current_user.id,
|
||||
notes=request.notes
|
||||
)
|
||||
db.add(new_permission)
|
||||
db.commit()
|
||||
db.refresh(new_permission)
|
||||
return {"message": "권한이 설정되었습니다.", "permission_id": new_permission.id}
|
||||
|
||||
@router.get("/users/{user_id}/page-permissions", response_model=List[PagePermissionResponse])
|
||||
async def get_user_page_permissions(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 사용자의 페이지 권한 목록 조회"""
|
||||
|
||||
# 관리자이거나 본인의 권한만 조회 가능
|
||||
if current_user.role != UserRole.admin and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="권한이 없습니다."
|
||||
)
|
||||
|
||||
# 사용자 존재 확인
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 사용자의 페이지 권한 조회
|
||||
permissions = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == user_id
|
||||
).all()
|
||||
|
||||
return permissions
|
||||
|
||||
@router.get("/page-permissions/check/{user_id}/{page_name}")
|
||||
async def check_page_access(
|
||||
user_id: int,
|
||||
page_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 사용자의 특정 페이지 접근 권한 확인"""
|
||||
|
||||
# 사용자 존재 확인
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# admin은 모든 페이지 접근 가능
|
||||
if user.role == UserRole.admin:
|
||||
return {"can_access": True, "reason": "admin_role"}
|
||||
|
||||
# 유효한 페이지명 확인
|
||||
if page_name not in DEFAULT_PAGES:
|
||||
return {"can_access": False, "reason": "invalid_page"}
|
||||
|
||||
# 개별 권한 확인
|
||||
permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == user_id,
|
||||
UserPagePermission.page_name == page_name
|
||||
).first()
|
||||
|
||||
if permission:
|
||||
return {
|
||||
"can_access": permission.can_access,
|
||||
"reason": "explicit_permission",
|
||||
"granted_at": permission.granted_at.isoformat() if permission.granted_at else None
|
||||
}
|
||||
|
||||
# 기본 권한 확인
|
||||
default_access = DEFAULT_PAGES[page_name]['default_access']
|
||||
return {
|
||||
"can_access": default_access,
|
||||
"reason": "default_permission"
|
||||
}
|
||||
|
||||
@router.get("/page-permissions/all-users", response_model=List[UserPagePermissionSummary])
|
||||
async def get_all_users_permissions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""모든 사용자의 페이지 권한 요약 조회 (관리자용)"""
|
||||
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 접근할 수 있습니다."
|
||||
)
|
||||
|
||||
# 모든 사용자 조회
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
|
||||
result = []
|
||||
for user in users:
|
||||
# 각 사용자의 권한 조회
|
||||
permissions = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == user.id
|
||||
).all()
|
||||
|
||||
result.append(UserPagePermissionSummary(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
role=user.role.value,
|
||||
permissions=permissions
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/page-permissions/available-pages")
|
||||
async def get_available_pages(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""사용 가능한 페이지 목록 조회"""
|
||||
|
||||
return {
|
||||
"pages": DEFAULT_PAGES,
|
||||
"total_count": len(DEFAULT_PAGES)
|
||||
}
|
||||
|
||||
@router.delete("/page-permissions/{permission_id}")
|
||||
async def delete_page_permission(
|
||||
permission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""페이지 권한 삭제 (기본값으로 되돌림)"""
|
||||
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 권한을 삭제할 수 있습니다."
|
||||
)
|
||||
|
||||
# 권한 조회
|
||||
permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.id == permission_id
|
||||
).first()
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="권한을 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 권한 삭제
|
||||
db.delete(permission)
|
||||
db.commit()
|
||||
|
||||
return {"message": "권한이 삭제되었습니다. 기본값이 적용됩니다."}
|
||||
|
||||
class BulkPermissionRequest(BaseModel):
|
||||
user_id: int
|
||||
permissions: List[dict] # [{"page_name": "issues_manage", "can_access": true}, ...]
|
||||
|
||||
@router.post("/page-permissions/bulk-grant")
|
||||
async def bulk_grant_permissions(
|
||||
request: BulkPermissionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""사용자의 여러 페이지 권한을 일괄 설정"""
|
||||
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 권한을 설정할 수 있습니다."
|
||||
)
|
||||
|
||||
# 대상 사용자 확인
|
||||
target_user = db.query(User).filter(User.id == request.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
updated_permissions = []
|
||||
|
||||
for perm_data in request.permissions:
|
||||
page_name = perm_data.get('page_name')
|
||||
can_access = perm_data.get('can_access', False)
|
||||
|
||||
# 유효한 페이지명 확인
|
||||
if page_name not in DEFAULT_PAGES:
|
||||
continue
|
||||
|
||||
# 기존 권한 확인
|
||||
existing_permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == request.user_id,
|
||||
UserPagePermission.page_name == page_name
|
||||
).first()
|
||||
|
||||
if existing_permission:
|
||||
# 기존 권한 업데이트
|
||||
existing_permission.can_access = can_access
|
||||
existing_permission.granted_by_id = current_user.id
|
||||
updated_permissions.append(existing_permission)
|
||||
else:
|
||||
# 새 권한 생성
|
||||
new_permission = UserPagePermission(
|
||||
user_id=request.user_id,
|
||||
page_name=page_name,
|
||||
can_access=can_access,
|
||||
granted_by_id=current_user.id
|
||||
)
|
||||
db.add(new_permission)
|
||||
updated_permissions.append(new_permission)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"{len(updated_permissions)}개의 권한이 설정되었습니다.",
|
||||
"updated_count": len(updated_permissions)
|
||||
}
|
||||
129
system3-nonconformance/api/routers/projects.py
Normal file
129
system3-nonconformance/api/routers/projects.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from database.database import get_db
|
||||
from database.models import Project, User, UserRole
|
||||
from database.schemas import ProjectCreate, ProjectUpdate, Project as ProjectSchema
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/projects",
|
||||
tags=["projects"]
|
||||
)
|
||||
|
||||
def check_admin_permission(current_user: User = Depends(get_current_user)):
|
||||
"""관리자 권한 확인"""
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자 권한이 필요합니다."
|
||||
)
|
||||
return current_user
|
||||
|
||||
@router.options("/")
|
||||
async def projects_options():
|
||||
"""OPTIONS preflight 요청 처리"""
|
||||
return {"message": "OK"}
|
||||
|
||||
@router.post("/", response_model=ProjectSchema)
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 생성 (관리자만)"""
|
||||
# Job No. 중복 확인
|
||||
existing_project = db.query(Project).filter(Project.job_no == project.job_no).first()
|
||||
if existing_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 Job No.입니다."
|
||||
)
|
||||
|
||||
# 프로젝트 생성
|
||||
db_project = Project(
|
||||
job_no=project.job_no,
|
||||
project_name=project.project_name,
|
||||
created_by_id=current_user.id
|
||||
)
|
||||
|
||||
db.add(db_project)
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
|
||||
return db_project
|
||||
|
||||
@router.get("/", response_model=List[ProjectSchema])
|
||||
async def get_projects(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""프로젝트 목록 조회"""
|
||||
query = db.query(Project)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Project.is_active == True)
|
||||
|
||||
projects = query.offset(skip).limit(limit).all()
|
||||
return projects
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectSchema)
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""특정 프로젝트 조회"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
return project
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectSchema)
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
project_update: ProjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 수정 (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 업데이트할 필드만 수정
|
||||
update_data = project_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
return project
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 삭제 (비활성화) (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 실제 삭제 대신 비활성화
|
||||
project.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {"message": "프로젝트가 삭제되었습니다."}
|
||||
873
system3-nonconformance/api/routers/reports.py
Normal file
873
system3-nonconformance/api/routers/reports.py
Normal file
@@ -0,0 +1,873 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
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
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"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,
|
||||
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 == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_only = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
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()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
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)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
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, "완료됨")
|
||||
]
|
||||
|
||||
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)
|
||||
)
|
||||
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
|
||||
|
||||
# 열 너비 조정
|
||||
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",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 지연 여부 확인 (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:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
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 = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
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" # 기본 파란색
|
||||
734
system3-nonconformance/api/routers/reports.py.bak
Normal file
734
system3-nonconformance/api/routers/reports.py.bak
Normal file
@@ -0,0 +1,734 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
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
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"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,
|
||||
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 == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (한번도 추출 안된 항목만)
|
||||
completed_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
).all()
|
||||
|
||||
# 진행중 항목 정렬: 지연 -> 진행중 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 완료 항목 정렬: 등록번호(project_sequence_no 또는 id) 순
|
||||
completed_issues = sorted(completed_issues, key=lambda x: x.project_sequence_no or x.id)
|
||||
|
||||
# 전체 이슈 (통계 계산용)
|
||||
issues = in_progress_issues + completed_issues
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 엑셀 파일 생성
|
||||
wb = Workbook()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
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)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
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, "완료됨")
|
||||
]
|
||||
|
||||
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행부터)
|
||||
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행부터)
|
||||
current_row = 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}')
|
||||
ws[f'A{current_row}'] = f"No. {issue.project_sequence_no or issue.id}"
|
||||
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
|
||||
|
||||
# === 신고 사진 영역 ===
|
||||
has_report_photo = issue.photo_path or issue.photo_path2
|
||||
if has_report_photo:
|
||||
# 라벨 행 (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
|
||||
|
||||
# 신고 사진 1
|
||||
if issue.photo_path:
|
||||
photo_path = issue.photo_path.replace('/uploads/', '/app/uploads/') if issue.photo_path.startswith('/uploads/') else issue.photo_path
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 첫 번째 사진
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
# 신고 사진 2
|
||||
if issue.photo_path2:
|
||||
photo_path2 = issue.photo_path2.replace('/uploads/', '/app/uploads/') if issue.photo_path2.startswith('/uploads/') else issue.photo_path2
|
||||
if os.path.exists(photo_path2):
|
||||
try:
|
||||
img = XLImage(photo_path2)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'G{current_row}') # G열에 두 번째 사진 (간격 확보)
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path2}): {e}")
|
||||
|
||||
if report_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
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
|
||||
|
||||
# 완료 사진
|
||||
if issue.completion_photo_path:
|
||||
# 라벨 행 (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_path = issue.completion_photo_path.replace('/uploads/', '/app/uploads/') if issue.completion_photo_path.startswith('/uploads/') else issue.completion_photo_path
|
||||
completion_image_inserted = False
|
||||
|
||||
if os.path.exists(completion_path):
|
||||
try:
|
||||
img = XLImage(completion_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 사진
|
||||
completion_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({completion_path}): {e}")
|
||||
|
||||
if completion_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
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_any_photo = has_report_photo or (issue.review_status == ReviewStatus.completed and issue.completion_photo_path)
|
||||
|
||||
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 열)
|
||||
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'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
ws.cell(row=card_end_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 카드 구분 (빈 행)
|
||||
current_row += 1
|
||||
|
||||
# 열 너비 조정
|
||||
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",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 지연 여부 확인 (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:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
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 = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
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 "F39C12" # 주황/노란색
|
||||
elif priority == 3: # 완료
|
||||
return "27AE60" # 초록색
|
||||
return "4472C4" # 기본 파란색
|
||||
734
system3-nonconformance/api/routers/reports.py.bak2
Normal file
734
system3-nonconformance/api/routers/reports.py.bak2
Normal file
@@ -0,0 +1,734 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
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
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"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,
|
||||
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 == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (한번도 추출 안된 항목만)
|
||||
completed_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
).all()
|
||||
|
||||
# 진행중 항목 정렬: 지연 -> 진행중 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 완료 항목 정렬: 등록번호(project_sequence_no 또는 id) 순
|
||||
completed_issues = sorted(completed_issues, key=lambda x: x.project_sequence_no or x.id)
|
||||
|
||||
# 전체 이슈 (통계 계산용)
|
||||
issues = in_progress_issues + completed_issues
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 엑셀 파일 생성
|
||||
wb = Workbook()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
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)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
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, "완료됨")
|
||||
]
|
||||
|
||||
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행부터)
|
||||
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행부터)
|
||||
current_row = 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}')
|
||||
ws[f'A{current_row}'] = f"No. {issue.project_sequence_no or issue.id}"
|
||||
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
|
||||
|
||||
# === 신고 사진 영역 ===
|
||||
has_report_photo = issue.photo_path or issue.photo_path2
|
||||
if has_report_photo:
|
||||
# 라벨 행 (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
|
||||
|
||||
# 신고 사진 1
|
||||
if issue.photo_path:
|
||||
photo_path = issue.photo_path.replace('/uploads/', '/app/uploads/') if issue.photo_path.startswith('/uploads/') else issue.photo_path
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 첫 번째 사진
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
# 신고 사진 2
|
||||
if issue.photo_path2:
|
||||
photo_path2 = issue.photo_path2.replace('/uploads/', '/app/uploads/') if issue.photo_path2.startswith('/uploads/') else issue.photo_path2
|
||||
if os.path.exists(photo_path2):
|
||||
try:
|
||||
img = XLImage(photo_path2)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'G{current_row}') # G열에 두 번째 사진 (간격 확보)
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path2}): {e}")
|
||||
|
||||
if report_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
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
|
||||
|
||||
# 완료 사진
|
||||
if issue.completion_photo_path:
|
||||
# 라벨 행 (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_path = issue.completion_photo_path.replace('/uploads/', '/app/uploads/') if issue.completion_photo_path.startswith('/uploads/') else issue.completion_photo_path
|
||||
completion_image_inserted = False
|
||||
|
||||
if os.path.exists(completion_path):
|
||||
try:
|
||||
img = XLImage(completion_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 사진
|
||||
completion_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({completion_path}): {e}")
|
||||
|
||||
if completion_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
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_any_photo = has_report_photo or (issue.review_status == ReviewStatus.completed and issue.completion_photo_path)
|
||||
|
||||
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 열)
|
||||
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'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
ws.cell(row=card_end_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 카드 구분 (빈 행)
|
||||
current_row += 1
|
||||
|
||||
# 열 너비 조정
|
||||
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",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 지연 여부 확인 (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:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
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 = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
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 "F39C12" # 주황/노란색
|
||||
elif priority == 3: # 완료
|
||||
return "27AE60" # 초록색
|
||||
return "4472C4" # 기본 파란색
|
||||
98
system3-nonconformance/api/services/auth_service.py
Normal file
98
system3-nonconformance/api/services/auth_service.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
import bcrypt as bcrypt_lib
|
||||
|
||||
from database.models import User, UserRole
|
||||
from database.schemas import TokenData
|
||||
|
||||
# 환경 변수 - SSO 공유 시크릿 사용 (docker-compose에서 SECRET_KEY=SSO_JWT_SECRET)
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
|
||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7 days
|
||||
|
||||
# 비밀번호 암호화 (pbkdf2_sha256 - 로컬 인증용)
|
||||
pwd_context = CryptContext(
|
||||
schemes=["pbkdf2_sha256"],
|
||||
deprecated="auto"
|
||||
)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""비밀번호 검증 - bcrypt + pbkdf2_sha256 둘 다 지원 (SSO 호환)"""
|
||||
if not plain_password or not hashed_password:
|
||||
return False
|
||||
|
||||
# bcrypt 형식 ($2b$ 또는 $2a$)
|
||||
if hashed_password.startswith(("$2b$", "$2a$")):
|
||||
try:
|
||||
return bcrypt_lib.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# pbkdf2_sha256 형식 (passlib)
|
||||
try:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""비밀번호 해시 생성 (pbkdf2_sha256)"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(token: str, credentials_exception):
|
||||
"""JWT 토큰 검증 - SSO 토큰 페이로드 구조 지원"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
# SSO 토큰: "sub" 필드에 username
|
||||
# 기존 M-Project 토큰: "sub" 필드에 username
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
return token_data
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str):
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return False
|
||||
return user
|
||||
|
||||
def create_admin_user(db: Session):
|
||||
"""초기 관리자 계정 생성"""
|
||||
admin_username = os.getenv("ADMIN_USERNAME", "hyungi")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "djg3-jj34-X3Q3")
|
||||
|
||||
existing_admin = db.query(User).filter(User.username == admin_username).first()
|
||||
if not existing_admin:
|
||||
admin_user = User(
|
||||
username=admin_username,
|
||||
hashed_password=get_password_hash(admin_password),
|
||||
full_name="관리자",
|
||||
role=UserRole.admin,
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
print(f"관리자 계정 생성됨: {admin_username}")
|
||||
else:
|
||||
print(f"관리자 계정이 이미 존재함: {admin_username}")
|
||||
136
system3-nonconformance/api/services/file_service.py
Normal file
136
system3-nonconformance/api/services/file_service.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# HEIF/HEIC 지원을 위한 라이브러리
|
||||
try:
|
||||
from pillow_heif import register_heif_opener
|
||||
register_heif_opener()
|
||||
HEIF_SUPPORTED = True
|
||||
except ImportError:
|
||||
HEIF_SUPPORTED = False
|
||||
|
||||
UPLOAD_DIR = "/app/uploads"
|
||||
|
||||
def ensure_upload_dir():
|
||||
"""업로드 디렉토리 생성"""
|
||||
if not os.path.exists(UPLOAD_DIR):
|
||||
os.makedirs(UPLOAD_DIR)
|
||||
|
||||
def save_base64_image(base64_string: str, prefix: str = "image") -> Optional[str]:
|
||||
"""Base64 이미지를 파일로 저장하고 경로 반환"""
|
||||
try:
|
||||
ensure_upload_dir()
|
||||
|
||||
# Base64 헤더 제거 및 정리
|
||||
if "," in base64_string:
|
||||
base64_string = base64_string.split(",")[1]
|
||||
|
||||
# Base64 문자열 정리 (공백, 개행 제거)
|
||||
base64_string = base64_string.strip().replace('\n', '').replace('\r', '').replace(' ', '')
|
||||
|
||||
print(f"🔍 정리된 Base64 길이: {len(base64_string)}")
|
||||
print(f"🔍 Base64 시작 20자: {base64_string[:20]}")
|
||||
|
||||
# 디코딩
|
||||
try:
|
||||
image_data = base64.b64decode(base64_string)
|
||||
print(f"🔍 디코딩된 데이터 길이: {len(image_data)}")
|
||||
print(f"🔍 바이너리 시작 20바이트: {image_data[:20]}")
|
||||
except Exception as decode_error:
|
||||
print(f"❌ Base64 디코딩 실패: {decode_error}")
|
||||
raise decode_error
|
||||
|
||||
# 파일 시그니처 확인
|
||||
file_signature = image_data[:20]
|
||||
print(f"🔍 파일 시그니처 (hex): {file_signature.hex()}")
|
||||
|
||||
# HEIC 파일 시그니처 확인
|
||||
is_heic = b'ftyp' in image_data[:20] and (b'heic' in image_data[:50] or b'mif1' in image_data[:50])
|
||||
print(f"🔍 HEIC 파일 여부: {is_heic}")
|
||||
|
||||
# 이미지 검증 및 형식 확인
|
||||
image = None
|
||||
try:
|
||||
# HEIC 파일인 경우 pillow_heif를 직접 사용하여 처리
|
||||
if is_heic and HEIF_SUPPORTED:
|
||||
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}")
|
||||
|
||||
# HEIF 재시도
|
||||
if HEIF_SUPPORTED:
|
||||
print("🔄 HEIF 형식으로 재시도...")
|
||||
try:
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
print(f"✅ HEIF 재시도 성공: {image.format}, 모드: {image.mode}, 크기: {image.size}")
|
||||
except Exception as heif_e:
|
||||
print(f"❌ HEIF 처리도 실패: {heif_e}")
|
||||
print("❌ 지원되지 않는 이미지 형식")
|
||||
raise e
|
||||
else:
|
||||
print("❌ HEIF 지원 라이브러리가 설치되지 않거나 처리 불가")
|
||||
raise e
|
||||
|
||||
# 이미지가 성공적으로 로드되지 않은 경우
|
||||
if image is None:
|
||||
raise Exception("이미지 로드 실패")
|
||||
|
||||
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
|
||||
# RGB 모드로 변환 (RGBA, P 모드 등을 처리)
|
||||
if image.mode in ('RGBA', 'LA', 'P'):
|
||||
# 투명도가 있는 이미지는 흰 배경과 합성
|
||||
background = Image.new('RGB', image.size, (255, 255, 255))
|
||||
if image.mode == 'P':
|
||||
image = image.convert('RGBA')
|
||||
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
|
||||
image = background
|
||||
elif image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# 파일명 생성 (prefix 포함)
|
||||
filename = f"{prefix}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.jpg"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
|
||||
# 이미지 저장 (최대 크기 제한)
|
||||
max_size = (1920, 1920)
|
||||
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
|
||||
# 항상 JPEG로 저장
|
||||
image.save(filepath, 'JPEG', quality=85, optimize=True)
|
||||
|
||||
# 웹 경로 반환
|
||||
return f"/uploads/{filename}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"이미지 저장 실패: {e}")
|
||||
return None
|
||||
|
||||
def delete_file(filepath: str):
|
||||
"""파일 삭제"""
|
||||
try:
|
||||
if filepath and filepath.startswith("/uploads/"):
|
||||
filename = filepath.replace("/uploads/", "")
|
||||
full_path = os.path.join(UPLOAD_DIR, filename)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
except Exception as e:
|
||||
print(f"파일 삭제 실패: {e}")
|
||||
Reference in New Issue
Block a user