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:
Hyungi Ahn
2026-02-09 14:40:11 +09:00
commit 550633b89d
824 changed files with 1071683 additions and 0 deletions

View 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}"}

View 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)
}

View 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)}")

View 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)}")

View 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

View 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)
}

View 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": "프로젝트가 삭제되었습니다."}

View 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" # 기본 파란색

View 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" # 기본 파란색

View 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" # 기본 파란색