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:
186
system3-nonconformance/api/routers/auth.py
Normal file
186
system3-nonconformance/api/routers/auth.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import User, UserRole
|
||||
from database import schemas
|
||||
from services.auth_service import (
|
||||
authenticate_user, create_access_token, verify_token,
|
||||
get_password_hash, verify_password
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
token_data = verify_token(token, credentials_exception)
|
||||
user = db.query(User).filter(User.username == token_data.username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
@router.options("/login")
|
||||
async def login_options():
|
||||
"""OPTIONS preflight 요청 처리"""
|
||||
return {"message": "OK"}
|
||||
|
||||
@router.post("/login", response_model=schemas.Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token = create_access_token(data={"sub": user.username})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=schemas.User)
|
||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
@router.get("/users", response_model=List[schemas.User])
|
||||
async def get_all_users(
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""모든 사용자 목록 조회 (관리자 전용)"""
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
return users
|
||||
|
||||
@router.post("/users", response_model=schemas.User)
|
||||
async def create_user(
|
||||
user: schemas.UserCreate,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 중복 확인
|
||||
db_user = db.query(User).filter(User.username == user.username).first()
|
||||
if db_user:
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
|
||||
# 사용자 생성
|
||||
db_user = User(
|
||||
username=user.username,
|
||||
hashed_password=get_password_hash(user.password),
|
||||
full_name=user.full_name,
|
||||
role=user.role
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.get("/users", response_model=List[schemas.User])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
@router.put("/users/{user_id}", response_model=schemas.User)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_update: schemas.UserUpdate,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 업데이트
|
||||
update_data = user_update.dict(exclude_unset=True)
|
||||
if "password" in update_data:
|
||||
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(db_user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
async def delete_user(
|
||||
username: str,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_user = db.query(User).filter(User.username == username).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# hyungi 계정은 삭제 불가
|
||||
if db_user.username == "hyungi":
|
||||
raise HTTPException(status_code=400, detail="Cannot delete primary admin user")
|
||||
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
return {"detail": "User deleted successfully"}
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
password_change: schemas.PasswordChange,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 현재 비밀번호 확인
|
||||
if not verify_password(password_change.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password"
|
||||
)
|
||||
|
||||
# 새 비밀번호 설정
|
||||
current_user.hashed_password = get_password_hash(password_change.new_password)
|
||||
db.commit()
|
||||
|
||||
return {"detail": "Password changed successfully"}
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
new_password: str
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
password_reset: PasswordReset,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 비밀번호 초기화 (관리자 전용)"""
|
||||
db_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 새 비밀번호로 업데이트
|
||||
db_user.hashed_password = get_password_hash(password_reset.new_password)
|
||||
db.commit()
|
||||
|
||||
return {"detail": f"Password reset successfully for user {db_user.username}"}
|
||||
163
system3-nonconformance/api/routers/daily_work.py
Normal file
163
system3-nonconformance/api/routers/daily_work.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import DailyWork, User, UserRole, KST
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/daily-work", tags=["daily-work"])
|
||||
|
||||
@router.post("/", response_model=schemas.DailyWork)
|
||||
async def create_daily_work(
|
||||
work: schemas.DailyWorkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 중복 확인 (같은 날짜)
|
||||
existing = db.query(DailyWork).filter(
|
||||
DailyWork.date == work.date.date()
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Daily work for this date already exists")
|
||||
|
||||
# 계산
|
||||
regular_hours = work.worker_count * 8 # 정규 근무 8시간
|
||||
overtime_total = work.overtime_workers * work.overtime_hours
|
||||
total_hours = regular_hours + overtime_total
|
||||
|
||||
# 생성
|
||||
db_work = DailyWork(
|
||||
date=work.date,
|
||||
worker_count=work.worker_count,
|
||||
regular_hours=regular_hours,
|
||||
overtime_workers=work.overtime_workers,
|
||||
overtime_hours=work.overtime_hours,
|
||||
overtime_total=overtime_total,
|
||||
total_hours=total_hours,
|
||||
created_by_id=current_user.id
|
||||
)
|
||||
db.add(db_work)
|
||||
db.commit()
|
||||
db.refresh(db_work)
|
||||
return db_work
|
||||
|
||||
@router.get("/", response_model=List[schemas.DailyWork])
|
||||
async def read_daily_works(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(DailyWork)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(DailyWork.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(DailyWork.date <= end_date)
|
||||
|
||||
works = query.order_by(DailyWork.date.desc()).offset(skip).limit(limit).all()
|
||||
return works
|
||||
|
||||
@router.get("/{work_id}", response_model=schemas.DailyWork)
|
||||
async def read_daily_work(
|
||||
work_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
return work
|
||||
|
||||
@router.put("/{work_id}", response_model=schemas.DailyWork)
|
||||
async def update_daily_work(
|
||||
work_id: int,
|
||||
work_update: schemas.DailyWorkUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
|
||||
# 업데이트
|
||||
update_data = work_update.dict(exclude_unset=True)
|
||||
|
||||
# 재계산 필요한 경우
|
||||
if any(key in update_data for key in ["worker_count", "overtime_workers", "overtime_hours"]):
|
||||
worker_count = update_data.get("worker_count", work.worker_count)
|
||||
overtime_workers = update_data.get("overtime_workers", work.overtime_workers)
|
||||
overtime_hours = update_data.get("overtime_hours", work.overtime_hours)
|
||||
|
||||
regular_hours = worker_count * 8
|
||||
overtime_total = overtime_workers * overtime_hours
|
||||
total_hours = regular_hours + overtime_total
|
||||
|
||||
update_data["regular_hours"] = regular_hours
|
||||
update_data["overtime_total"] = overtime_total
|
||||
update_data["total_hours"] = total_hours
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(work, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(work)
|
||||
return work
|
||||
|
||||
@router.delete("/{work_id}")
|
||||
async def delete_daily_work(
|
||||
work_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
|
||||
# 권한 확인 (관리자만 삭제 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
|
||||
|
||||
db.delete(work)
|
||||
db.commit()
|
||||
return {"detail": "Daily work deleted successfully"}
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_daily_work_stats(
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일 공수 통계"""
|
||||
query = db.query(DailyWork)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(DailyWork.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(DailyWork.date <= end_date)
|
||||
|
||||
works = query.all()
|
||||
|
||||
if not works:
|
||||
return {
|
||||
"total_days": 0,
|
||||
"total_hours": 0,
|
||||
"total_overtime": 0,
|
||||
"average_daily_hours": 0
|
||||
}
|
||||
|
||||
total_hours = sum(w.total_hours for w in works)
|
||||
total_overtime = sum(w.overtime_total for w in works)
|
||||
|
||||
return {
|
||||
"total_days": len(works),
|
||||
"total_hours": total_hours,
|
||||
"total_overtime": total_overtime,
|
||||
"average_daily_hours": total_hours / len(works)
|
||||
}
|
||||
404
system3-nonconformance/api/routers/inbox.py
Normal file
404
system3-nonconformance/api/routers/inbox.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, User, Project, ReviewStatus, DisposalReasonType
|
||||
from database.schemas import (
|
||||
InboxIssue, IssueDisposalRequest, IssueReviewRequest,
|
||||
IssueStatusUpdateRequest, ModificationLogEntry, ManagementUpdateRequest
|
||||
)
|
||||
from routers.auth import get_current_user, get_current_admin
|
||||
from routers.page_permissions import check_page_access
|
||||
from services.file_service import save_base64_image
|
||||
|
||||
router = APIRouter(prefix="/api/inbox", tags=["inbox"])
|
||||
|
||||
@router.get("/", response_model=List[InboxIssue])
|
||||
async def get_inbox_issues(
|
||||
project_id: Optional[int] = Query(None, description="프로젝트 ID로 필터링"),
|
||||
skip: int = Query(0, ge=0, description="건너뛸 항목 수"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="가져올 항목 수"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
수신함 - 검토 대기 중인 부적합 목록 조회
|
||||
"""
|
||||
query = db.query(Issue).filter(Issue.review_status == ReviewStatus.pending_review)
|
||||
|
||||
# 프로젝트 필터링
|
||||
if project_id:
|
||||
query = query.filter(Issue.project_id == project_id)
|
||||
|
||||
# 최신순 정렬
|
||||
query = query.order_by(Issue.report_date.desc())
|
||||
|
||||
issues = query.offset(skip).limit(limit).all()
|
||||
return issues
|
||||
|
||||
@router.post("/{issue_id}/dispose")
|
||||
async def dispose_issue(
|
||||
issue_id: int,
|
||||
disposal_request: IssueDisposalRequest,
|
||||
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
부적합 폐기 처리
|
||||
"""
|
||||
# 수신함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_inbox', db):
|
||||
raise HTTPException(status_code=403, detail="수신함 접근 권한이 없습니다.")
|
||||
# 부적합 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
if issue.review_status != ReviewStatus.pending_review:
|
||||
raise HTTPException(status_code=400, detail="검토 대기 중인 부적합만 폐기할 수 있습니다.")
|
||||
|
||||
# 사용자 정의 사유 검증
|
||||
if disposal_request.disposal_reason == DisposalReasonType.custom:
|
||||
if not disposal_request.custom_disposal_reason or not disposal_request.custom_disposal_reason.strip():
|
||||
raise HTTPException(status_code=400, detail="사용자 정의 폐기 사유를 입력해주세요.")
|
||||
|
||||
# 원본 데이터 보존
|
||||
if not issue.original_data:
|
||||
issue.original_data = {
|
||||
"category": issue.category.value,
|
||||
"description": issue.description,
|
||||
"project_id": issue.project_id,
|
||||
"photo_path": issue.photo_path,
|
||||
"photo_path2": issue.photo_path2,
|
||||
"preserved_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 중복 처리 로직
|
||||
if disposal_request.disposal_reason == DisposalReasonType.duplicate and disposal_request.duplicate_of_issue_id:
|
||||
# 중복 대상 이슈 확인
|
||||
target_issue = db.query(Issue).filter(Issue.id == disposal_request.duplicate_of_issue_id).first()
|
||||
if not target_issue:
|
||||
raise HTTPException(status_code=404, detail="중복 대상 이슈를 찾을 수 없습니다.")
|
||||
|
||||
# 중복 신고자를 대상 이슈에 추가
|
||||
current_reporters = target_issue.duplicate_reporters or []
|
||||
new_reporter = {
|
||||
"user_id": issue.reporter_id,
|
||||
"username": issue.reporter.username,
|
||||
"full_name": issue.reporter.full_name,
|
||||
"report_date": issue.report_date.isoformat() if issue.report_date else None,
|
||||
"added_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 중복 체크 (이미 추가된 신고자인지 확인)
|
||||
existing_reporter = next((r for r in current_reporters if r.get("user_id") == issue.reporter_id), None)
|
||||
if not existing_reporter:
|
||||
current_reporters.append(new_reporter)
|
||||
target_issue.duplicate_reporters = current_reporters
|
||||
|
||||
# 현재 이슈에 중복 대상 설정
|
||||
issue.duplicate_of_issue_id = disposal_request.duplicate_of_issue_id
|
||||
|
||||
# 폐기 처리
|
||||
issue.review_status = ReviewStatus.disposed
|
||||
issue.disposal_reason = disposal_request.disposal_reason
|
||||
issue.custom_disposal_reason = disposal_request.custom_disposal_reason
|
||||
issue.disposed_at = datetime.now()
|
||||
issue.reviewed_by_id = current_user.id
|
||||
issue.reviewed_at = datetime.now()
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return {
|
||||
"message": "부적합이 성공적으로 폐기되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"disposal_reason": issue.disposal_reason.value,
|
||||
"disposed_at": issue.disposed_at
|
||||
}
|
||||
|
||||
@router.post("/{issue_id}/review")
|
||||
async def review_issue(
|
||||
issue_id: int,
|
||||
review_request: IssueReviewRequest,
|
||||
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
부적합 검토 및 수정
|
||||
"""
|
||||
# 수신함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_inbox', db):
|
||||
raise HTTPException(status_code=403, detail="수신함 접근 권한이 없습니다.")
|
||||
# 부적합 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
if issue.review_status != ReviewStatus.pending_review:
|
||||
raise HTTPException(status_code=400, detail="검토 대기 중인 부적합만 수정할 수 있습니다.")
|
||||
|
||||
# 원본 데이터 보존 (최초 1회만)
|
||||
if not issue.original_data:
|
||||
issue.original_data = {
|
||||
"category": issue.category.value,
|
||||
"description": issue.description,
|
||||
"project_id": issue.project_id,
|
||||
"photo_path": issue.photo_path,
|
||||
"photo_path2": issue.photo_path2,
|
||||
"preserved_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 수정 이력 준비
|
||||
modifications = []
|
||||
current_time = datetime.now()
|
||||
|
||||
# 프로젝트 변경
|
||||
if review_request.project_id is not None and review_request.project_id != issue.project_id:
|
||||
# 프로젝트 존재 확인
|
||||
if review_request.project_id != 0: # 0은 프로젝트 없음을 의미
|
||||
project = db.query(Project).filter(Project.id == review_request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=400, detail="존재하지 않는 프로젝트입니다.")
|
||||
|
||||
modifications.append({
|
||||
"field": "project_id",
|
||||
"old_value": issue.project_id,
|
||||
"new_value": review_request.project_id,
|
||||
"modified_at": current_time.isoformat(),
|
||||
"modified_by": current_user.id
|
||||
})
|
||||
issue.project_id = review_request.project_id if review_request.project_id != 0 else None
|
||||
|
||||
# 카테고리 변경
|
||||
if review_request.category is not None and review_request.category != issue.category:
|
||||
modifications.append({
|
||||
"field": "category",
|
||||
"old_value": issue.category.value,
|
||||
"new_value": review_request.category.value,
|
||||
"modified_at": current_time.isoformat(),
|
||||
"modified_by": current_user.id
|
||||
})
|
||||
issue.category = review_request.category
|
||||
|
||||
# 설명 변경
|
||||
if review_request.description is not None and review_request.description != issue.description:
|
||||
modifications.append({
|
||||
"field": "description",
|
||||
"old_value": issue.description,
|
||||
"new_value": review_request.description,
|
||||
"modified_at": current_time.isoformat(),
|
||||
"modified_by": current_user.id
|
||||
})
|
||||
issue.description = review_request.description
|
||||
|
||||
# 수정 이력 업데이트
|
||||
if modifications:
|
||||
if issue.modification_log is None:
|
||||
issue.modification_log = []
|
||||
issue.modification_log.extend(modifications)
|
||||
|
||||
# SQLAlchemy에 변경사항 알림
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(issue, "modification_log")
|
||||
|
||||
# 검토자 정보 업데이트
|
||||
issue.reviewed_by_id = current_user.id
|
||||
issue.reviewed_at = current_time
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return {
|
||||
"message": "부적합 검토가 완료되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"modifications_count": len(modifications),
|
||||
"modifications": modifications,
|
||||
"reviewed_at": issue.reviewed_at
|
||||
}
|
||||
|
||||
@router.post("/{issue_id}/status")
|
||||
async def update_issue_status(
|
||||
issue_id: int,
|
||||
status_request: IssueStatusUpdateRequest,
|
||||
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
부적합 최종 상태 결정 (진행 중 / 완료)
|
||||
"""
|
||||
# 수신함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_inbox', db):
|
||||
raise HTTPException(status_code=403, detail="수신함 접근 권한이 없습니다.")
|
||||
# 부적합 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
if issue.review_status not in [ReviewStatus.pending_review, ReviewStatus.in_progress]:
|
||||
raise HTTPException(status_code=400, detail="이미 처리가 완료된 부적합입니다.")
|
||||
|
||||
# 상태 변경 검증
|
||||
if status_request.review_status not in [ReviewStatus.in_progress, ReviewStatus.completed]:
|
||||
raise HTTPException(status_code=400, detail="진행 중 또는 완료 상태만 설정할 수 있습니다.")
|
||||
|
||||
# 원본 데이터 보존 (최초 1회만)
|
||||
if not issue.original_data:
|
||||
issue.original_data = {
|
||||
"category": issue.category.value,
|
||||
"description": issue.description,
|
||||
"project_id": issue.project_id,
|
||||
"photo_path": issue.photo_path,
|
||||
"photo_path2": issue.photo_path2,
|
||||
"preserved_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 상태 변경
|
||||
old_status = issue.review_status
|
||||
issue.review_status = status_request.review_status
|
||||
issue.reviewed_by_id = current_user.id
|
||||
issue.reviewed_at = datetime.now()
|
||||
|
||||
# 진행 중 또는 완료 상태로 변경 시 프로젝트별 순번 자동 할당
|
||||
if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed]:
|
||||
if not issue.project_sequence_no:
|
||||
from sqlalchemy import text
|
||||
result = db.execute(
|
||||
text("SELECT generate_project_sequence_no(:project_id)"),
|
||||
{"project_id": issue.project_id}
|
||||
)
|
||||
issue.project_sequence_no = result.scalar()
|
||||
|
||||
# 완료 사진 업로드 처리
|
||||
if status_request.completion_photo and status_request.review_status == ReviewStatus.completed:
|
||||
try:
|
||||
completion_photo_path = save_base64_image(status_request.completion_photo, "completion")
|
||||
issue.completion_photo_path = completion_photo_path
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
|
||||
|
||||
# 완료 상태로 변경 시 추가 정보 처리
|
||||
if status_request.review_status == ReviewStatus.completed:
|
||||
issue.actual_completion_date = datetime.now().date()
|
||||
|
||||
# 해결방안 저장
|
||||
if status_request.solution:
|
||||
issue.solution = status_request.solution
|
||||
|
||||
# 담당부서 저장
|
||||
if status_request.responsible_department:
|
||||
issue.responsible_department = status_request.responsible_department
|
||||
|
||||
# 담당자 저장
|
||||
if status_request.responsible_person:
|
||||
issue.responsible_person = status_request.responsible_person
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return {
|
||||
"message": f"부적합 상태가 '{status_request.review_status.value}'로 변경되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"old_status": old_status.value,
|
||||
"new_status": issue.review_status.value,
|
||||
"reviewed_at": issue.reviewed_at,
|
||||
"destination": "관리함" if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed] else "수신함"
|
||||
}
|
||||
|
||||
@router.get("/{issue_id}/history")
|
||||
async def get_issue_history(
|
||||
issue_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
부적합 수정 이력 조회
|
||||
"""
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
return {
|
||||
"issue_id": issue.id,
|
||||
"original_data": issue.original_data,
|
||||
"modification_log": issue.modification_log or [],
|
||||
"review_status": issue.review_status.value,
|
||||
"reviewed_by_id": issue.reviewed_by_id,
|
||||
"reviewed_at": issue.reviewed_at,
|
||||
"disposal_info": {
|
||||
"disposal_reason": issue.disposal_reason.value if issue.disposal_reason else None,
|
||||
"custom_disposal_reason": issue.custom_disposal_reason,
|
||||
"disposed_at": issue.disposed_at
|
||||
} if issue.review_status == ReviewStatus.disposed else None
|
||||
}
|
||||
|
||||
@router.get("/statistics")
|
||||
async def get_inbox_statistics(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
수신함 통계 정보
|
||||
"""
|
||||
# 검토 대기 중
|
||||
pending_count = db.query(Issue).filter(Issue.review_status == ReviewStatus.pending_review).count()
|
||||
|
||||
# 오늘 처리된 건수
|
||||
today = datetime.now().date()
|
||||
today_processed = db.query(Issue).filter(
|
||||
Issue.reviewed_at >= today,
|
||||
Issue.review_status != ReviewStatus.pending_review
|
||||
).count()
|
||||
|
||||
# 폐기된 건수
|
||||
disposed_count = db.query(Issue).filter(Issue.review_status == ReviewStatus.disposed).count()
|
||||
|
||||
# 관리함으로 이동한 건수
|
||||
management_count = db.query(Issue).filter(
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
).count()
|
||||
|
||||
return {
|
||||
"pending_review": pending_count,
|
||||
"today_processed": today_processed,
|
||||
"total_disposed": disposed_count,
|
||||
"total_in_management": management_count,
|
||||
"total_issues": db.query(Issue).count()
|
||||
}
|
||||
|
||||
@router.get("/management-issues")
|
||||
async def get_management_issues(
|
||||
project_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""관리함 이슈 목록 조회 (중복 선택용)"""
|
||||
try:
|
||||
query = db.query(Issue).filter(
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
)
|
||||
|
||||
# 프로젝트 필터 적용
|
||||
if project_id:
|
||||
query = query.filter(Issue.project_id == project_id)
|
||||
|
||||
issues = query.order_by(Issue.reviewed_at.desc()).limit(50).all()
|
||||
|
||||
# 간단한 형태로 반환 (제목과 ID만)
|
||||
result = []
|
||||
for issue in issues:
|
||||
result.append({
|
||||
"id": issue.id,
|
||||
"description": issue.description[:100] + "..." if len(issue.description) > 100 else issue.description,
|
||||
"category": issue.category.value,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"reviewed_at": issue.reviewed_at.isoformat() if issue.reviewed_at else None,
|
||||
"duplicate_count": len(issue.duplicate_reporters) if issue.duplicate_reporters else 0
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"관리함 이슈 조회 중 오류가 발생했습니다: {str(e)}")
|
||||
488
system3-nonconformance/api/routers/issues.py
Normal file
488
system3-nonconformance/api/routers/issues.py
Normal file
@@ -0,0 +1,488 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, IssueStatus, User, UserRole, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user, get_current_admin
|
||||
from routers.page_permissions import check_page_access
|
||||
from services.file_service import save_base64_image, delete_file
|
||||
|
||||
router = APIRouter(prefix="/api/issues", tags=["issues"])
|
||||
|
||||
@router.post("/", response_model=schemas.Issue)
|
||||
async def create_issue(
|
||||
issue: schemas.IssueCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
print(f"DEBUG: 받은 issue 데이터: {issue}")
|
||||
print(f"DEBUG: project_id: {issue.project_id}")
|
||||
# 이미지 저장 (최대 5장)
|
||||
photo_paths = {}
|
||||
for i in range(1, 6):
|
||||
photo_field = f"photo{i if i > 1 else ''}"
|
||||
path_field = f"photo_path{i if i > 1 else ''}"
|
||||
photo_data = getattr(issue, photo_field, None)
|
||||
if photo_data:
|
||||
photo_paths[path_field] = save_base64_image(photo_data)
|
||||
else:
|
||||
photo_paths[path_field] = None
|
||||
|
||||
# Issue 생성
|
||||
db_issue = Issue(
|
||||
category=issue.category,
|
||||
description=issue.description,
|
||||
photo_path=photo_paths.get('photo_path'),
|
||||
photo_path2=photo_paths.get('photo_path2'),
|
||||
photo_path3=photo_paths.get('photo_path3'),
|
||||
photo_path4=photo_paths.get('photo_path4'),
|
||||
photo_path5=photo_paths.get('photo_path5'),
|
||||
reporter_id=current_user.id,
|
||||
project_id=issue.project_id,
|
||||
status=IssueStatus.new
|
||||
)
|
||||
db.add(db_issue)
|
||||
db.commit()
|
||||
db.refresh(db_issue)
|
||||
return db_issue
|
||||
|
||||
@router.get("/", response_model=List[schemas.Issue])
|
||||
async def read_issues(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[IssueStatus] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(Issue)
|
||||
|
||||
# 권한별 조회 제한
|
||||
if current_user.role == UserRole.admin:
|
||||
# 관리자는 모든 이슈 조회 가능
|
||||
pass
|
||||
else:
|
||||
# 일반 사용자는 본인이 등록한 이슈만 조회 가능
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Issue.status == status)
|
||||
|
||||
# 최신순 정렬 (report_date 기준)
|
||||
issues = query.order_by(Issue.report_date.desc()).offset(skip).limit(limit).all()
|
||||
return issues
|
||||
|
||||
@router.get("/admin/all", response_model=List[schemas.Issue])
|
||||
async def read_all_issues_admin(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[IssueStatus] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""이슈 관리 권한이 있는 사용자: 모든 부적합 조회"""
|
||||
# 이슈 관리 페이지 권한 확인 (관리함, 폐기함 등에서 사용)
|
||||
from routers.page_permissions import check_page_access
|
||||
|
||||
# 관리자이거나 이슈 관리 권한이 있는 사용자만 접근 가능
|
||||
if (current_user.role != 'admin' and
|
||||
not check_page_access(current_user.id, 'issues_manage', db) and
|
||||
not check_page_access(current_user.id, 'issues_inbox', db) and
|
||||
not check_page_access(current_user.id, 'issues_management', db) and
|
||||
not check_page_access(current_user.id, 'issues_archive', db)):
|
||||
raise HTTPException(status_code=403, detail="이슈 관리 권한이 없습니다.")
|
||||
|
||||
query = db.query(Issue)
|
||||
|
||||
if status:
|
||||
query = query.filter(Issue.status == status)
|
||||
|
||||
# 최신순 정렬 (report_date 기준)
|
||||
issues = query.order_by(Issue.report_date.desc()).offset(skip).limit(limit).all()
|
||||
return issues
|
||||
|
||||
@router.get("/{issue_id}", response_model=schemas.Issue)
|
||||
async def read_issue(
|
||||
issue_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
# 권한별 조회 제한
|
||||
if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="본인이 등록한 부적합만 조회할 수 있습니다."
|
||||
)
|
||||
|
||||
return issue
|
||||
|
||||
@router.put("/{issue_id}", response_model=schemas.Issue)
|
||||
async def update_issue(
|
||||
issue_id: int,
|
||||
issue_update: schemas.IssueUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
# 권한 확인
|
||||
if current_user.role == UserRole.user and issue.reporter_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to update this issue")
|
||||
|
||||
# 업데이트
|
||||
update_data = issue_update.dict(exclude_unset=True)
|
||||
|
||||
# 사진 업데이트 처리 (최대 5장)
|
||||
for i in range(1, 6):
|
||||
photo_field = f"photo{i if i > 1 else ''}"
|
||||
path_field = f"photo_path{i if i > 1 else ''}"
|
||||
|
||||
if photo_field in update_data:
|
||||
# 기존 사진 삭제
|
||||
existing_path = getattr(issue, path_field, None)
|
||||
if existing_path:
|
||||
delete_file(existing_path)
|
||||
|
||||
# 새 사진 저장
|
||||
if update_data[photo_field]:
|
||||
new_path = save_base64_image(update_data[photo_field])
|
||||
update_data[path_field] = new_path
|
||||
else:
|
||||
update_data[path_field] = None
|
||||
|
||||
# photo 필드는 제거 (DB에는 photo_path만 저장)
|
||||
del update_data[photo_field]
|
||||
|
||||
# work_hours가 입력되면 자동으로 상태를 complete로 변경
|
||||
if "work_hours" in update_data and update_data["work_hours"] > 0:
|
||||
if issue.status == IssueStatus.new:
|
||||
update_data["status"] = IssueStatus.complete
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(issue, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
return issue
|
||||
|
||||
@router.delete("/{issue_id}")
|
||||
async def delete_issue(
|
||||
issue_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from database.models import DeletionLog
|
||||
import json
|
||||
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
# 권한 확인 (관리자 또는 본인이 등록한 경우 삭제 가능)
|
||||
if current_user.role != UserRole.admin and issue.reporter_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="본인이 등록한 부적합만 삭제할 수 있습니다.")
|
||||
|
||||
# 이 이슈를 중복 대상으로 참조하는 다른 이슈들의 참조 제거
|
||||
referencing_issues = db.query(Issue).filter(Issue.duplicate_of_issue_id == issue_id).all()
|
||||
if referencing_issues:
|
||||
print(f"DEBUG: {len(referencing_issues)}개의 이슈가 이 이슈를 중복 대상으로 참조하고 있습니다. 참조를 제거합니다.")
|
||||
for ref_issue in referencing_issues:
|
||||
ref_issue.duplicate_of_issue_id = None
|
||||
db.flush() # 참조 제거를 먼저 커밋
|
||||
|
||||
# 삭제 로그 생성 (삭제 전 데이터 저장)
|
||||
issue_data = {
|
||||
"id": issue.id,
|
||||
"category": issue.category.value if issue.category else None,
|
||||
"description": issue.description,
|
||||
"status": issue.status.value if issue.status else None,
|
||||
"reporter_id": issue.reporter_id,
|
||||
"project_id": issue.project_id,
|
||||
"report_date": issue.report_date.isoformat() if issue.report_date else None,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes,
|
||||
"photo_path": issue.photo_path,
|
||||
"photo_path2": issue.photo_path2,
|
||||
"review_status": issue.review_status.value if issue.review_status else None,
|
||||
"solution": issue.solution,
|
||||
"responsible_department": issue.responsible_department.value if issue.responsible_department else None,
|
||||
"responsible_person": issue.responsible_person,
|
||||
"expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None,
|
||||
"actual_completion_date": issue.actual_completion_date.isoformat() if issue.actual_completion_date else None,
|
||||
}
|
||||
|
||||
deletion_log = DeletionLog(
|
||||
entity_type="issue",
|
||||
entity_id=issue.id,
|
||||
entity_data=issue_data,
|
||||
deleted_by_id=current_user.id,
|
||||
reason=f"사용자 {current_user.username}에 의해 삭제됨"
|
||||
)
|
||||
db.add(deletion_log)
|
||||
|
||||
# 이미지 파일 삭제 (신고 사진 최대 5장)
|
||||
for i in range(1, 6):
|
||||
path_field = f"photo_path{i if i > 1 else ''}"
|
||||
path = getattr(issue, path_field, None)
|
||||
if path:
|
||||
delete_file(path)
|
||||
|
||||
# 완료 사진 삭제 (최대 5장)
|
||||
for i in range(1, 6):
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
path = getattr(issue, path_field, None)
|
||||
if path:
|
||||
delete_file(path)
|
||||
|
||||
db.delete(issue)
|
||||
db.commit()
|
||||
return {"detail": "Issue deleted successfully", "logged": True}
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_issue_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""이슈 통계 조회"""
|
||||
query = db.query(Issue)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
total = query.count()
|
||||
new = query.filter(Issue.status == IssueStatus.NEW).count()
|
||||
progress = query.filter(Issue.status == IssueStatus.PROGRESS).count()
|
||||
complete = query.filter(Issue.status == IssueStatus.COMPLETE).count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"new": new,
|
||||
"progress": progress,
|
||||
"complete": complete
|
||||
}
|
||||
|
||||
@router.put("/{issue_id}/management")
|
||||
async def update_issue_management(
|
||||
issue_id: int,
|
||||
management_update: schemas.ManagementUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
관리함에서 이슈의 관리 관련 필드들을 업데이트합니다.
|
||||
"""
|
||||
print(f"DEBUG: Received management update for issue {issue_id}")
|
||||
print(f"DEBUG: Update data: {management_update}")
|
||||
print(f"DEBUG: Current user: {current_user.username}")
|
||||
|
||||
# 관리함 페이지 권한 확인
|
||||
if not (current_user.role == UserRole.admin or check_page_access(current_user.id, 'issues_management', db)):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 이슈 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
print(f"DEBUG: Found issue: {issue.id}")
|
||||
|
||||
# 관리함에서만 수정 가능한 필드들만 업데이트
|
||||
update_data = management_update.dict(exclude_unset=True)
|
||||
print(f"DEBUG: Update data dict: {update_data}")
|
||||
|
||||
# 완료 사진 처리 (최대 5장)
|
||||
completion_photo_fields = []
|
||||
for i in range(1, 6):
|
||||
photo_field = f"completion_photo{i if i > 1 else ''}"
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
|
||||
if photo_field in update_data and update_data[photo_field]:
|
||||
completion_photo_fields.append(photo_field)
|
||||
try:
|
||||
# 기존 사진 삭제
|
||||
existing_path = getattr(issue, path_field, None)
|
||||
if existing_path:
|
||||
delete_file(existing_path)
|
||||
|
||||
# 새 사진 저장
|
||||
new_path = save_base64_image(update_data[photo_field], "completion")
|
||||
setattr(issue, path_field, new_path)
|
||||
print(f"DEBUG: Saved {photo_field}: {new_path}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Photo save error for {photo_field}: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"완료 사진 저장 실패: {str(e)}")
|
||||
|
||||
# 나머지 필드 처리 (완료 사진 제외)
|
||||
for field, value in update_data.items():
|
||||
if field not in completion_photo_fields:
|
||||
print(f"DEBUG: Processing field {field} = {value}")
|
||||
try:
|
||||
setattr(issue, field, value)
|
||||
print(f"DEBUG: Set {field} = {value}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Field set error for {field}: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"필드 {field} 설정 실패: {str(e)}")
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
print(f"DEBUG: Successfully updated issue {issue.id}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Database commit error: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"데이터베이스 저장 실패: {str(e)}")
|
||||
|
||||
return {
|
||||
"message": "관리 정보가 업데이트되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"updated_fields": list(update_data.keys())
|
||||
}
|
||||
|
||||
@router.post("/{issue_id}/completion-request")
|
||||
async def request_completion(
|
||||
issue_id: int,
|
||||
request: schemas.CompletionRequestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
완료 신청 - 담당자가 작업 완료 후 완료 신청
|
||||
"""
|
||||
# 이슈 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
# 진행 중 상태인지 확인
|
||||
if issue.review_status != ReviewStatus.in_progress:
|
||||
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 완료 신청할 수 있습니다.")
|
||||
|
||||
# 이미 완료 신청된 경우 확인
|
||||
if issue.completion_requested_at:
|
||||
raise HTTPException(status_code=400, detail="이미 완료 신청된 부적합입니다.")
|
||||
|
||||
try:
|
||||
print(f"DEBUG: 완료 신청 시작 - Issue ID: {issue_id}, User: {current_user.username}")
|
||||
|
||||
# 완료 사진 저장 (최대 5장)
|
||||
saved_paths = []
|
||||
for i in range(1, 6):
|
||||
photo_field = f"completion_photo{i if i > 1 else ''}"
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
photo_data = getattr(request, photo_field, None)
|
||||
|
||||
if photo_data:
|
||||
print(f"DEBUG: {photo_field} 저장 시작")
|
||||
saved_path = save_base64_image(photo_data, "completion")
|
||||
if saved_path:
|
||||
setattr(issue, path_field, saved_path)
|
||||
saved_paths.append(saved_path)
|
||||
print(f"DEBUG: {photo_field} 저장 완료 - Path: {saved_path}")
|
||||
else:
|
||||
raise Exception(f"{photo_field} 저장에 실패했습니다.")
|
||||
|
||||
# 완료 신청 정보 업데이트
|
||||
print(f"DEBUG: DB 업데이트 시작")
|
||||
issue.completion_requested_at = datetime.now()
|
||||
issue.completion_requested_by_id = current_user.id
|
||||
issue.completion_comment = request.completion_comment
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
print(f"DEBUG: DB 업데이트 완료")
|
||||
|
||||
return {
|
||||
"message": "완료 신청이 성공적으로 제출되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"completion_requested_at": issue.completion_requested_at,
|
||||
"completion_photo_paths": saved_paths
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: 완료 신청 처리 오류 - {str(e)}")
|
||||
db.rollback()
|
||||
# 업로드된 파일이 있다면 삭제
|
||||
if 'saved_paths' in locals():
|
||||
for path in saved_paths:
|
||||
try:
|
||||
delete_file(path)
|
||||
except:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"완료 신청 처리 중 오류가 발생했습니다: {str(e)}")
|
||||
|
||||
@router.post("/{issue_id}/reject-completion")
|
||||
async def reject_completion_request(
|
||||
issue_id: int,
|
||||
request: schemas.CompletionRejectionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
완료 신청 반려 - 관리자가 완료 신청을 반려
|
||||
"""
|
||||
# 이슈 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
# 완료 신청이 있는지 확인
|
||||
if not issue.completion_requested_at:
|
||||
raise HTTPException(status_code=400, detail="완료 신청이 없는 부적합입니다.")
|
||||
|
||||
# 권한 확인 (관리자 또는 관리함 접근 권한이 있는 사용자)
|
||||
if current_user.role != UserRole.admin and not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="완료 반려 권한이 없습니다.")
|
||||
|
||||
try:
|
||||
print(f"DEBUG: 완료 반려 시작 - Issue ID: {issue_id}, User: {current_user.username}")
|
||||
|
||||
# 완료 사진 파일 삭제 (최대 5장)
|
||||
for i in range(1, 6):
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
photo_path = getattr(issue, path_field, None)
|
||||
if photo_path:
|
||||
try:
|
||||
delete_file(photo_path)
|
||||
print(f"DEBUG: {path_field} 삭제 완료")
|
||||
except Exception as e:
|
||||
print(f"WARNING: {path_field} 삭제 실패 - {str(e)}")
|
||||
|
||||
# 완료 신청 정보 초기화
|
||||
issue.completion_requested_at = None
|
||||
issue.completion_requested_by_id = None
|
||||
for i in range(1, 6):
|
||||
path_field = f"completion_photo_path{i if i > 1 else ''}"
|
||||
setattr(issue, path_field, None)
|
||||
issue.completion_comment = None
|
||||
|
||||
# 완료 반려 정보 기록 (전용 필드 사용)
|
||||
issue.completion_rejected_at = datetime.now()
|
||||
issue.completion_rejected_by_id = current_user.id
|
||||
issue.completion_rejection_reason = request.rejection_reason
|
||||
|
||||
# 상태는 in_progress로 유지
|
||||
issue.review_status = ReviewStatus.in_progress
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
print(f"DEBUG: 완료 반려 처리 완료")
|
||||
|
||||
return {
|
||||
"message": "완료 신청이 반려되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"rejection_reason": request.rejection_reason
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: 완료 반려 처리 오류 - {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"완료 반려 처리 중 오류가 발생했습니다: {str(e)}")
|
||||
212
system3-nonconformance/api/routers/management.py
Normal file
212
system3-nonconformance/api/routers/management.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, User, ReviewStatus
|
||||
from database.schemas import (
|
||||
ManagementUpdateRequest, AdditionalInfoUpdateRequest, Issue as IssueSchema, DailyReportStats
|
||||
)
|
||||
from routers.auth import get_current_user
|
||||
from routers.page_permissions import check_page_access
|
||||
|
||||
router = APIRouter(prefix="/api/management", tags=["management"])
|
||||
|
||||
@router.get("/", response_model=List[IssueSchema])
|
||||
async def get_management_issues(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
관리함 - 진행 중 및 완료된 부적합 목록 조회
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 진행 중 또는 완료된 이슈들 조회
|
||||
issues = db.query(Issue).filter(
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
).order_by(Issue.reviewed_at.desc()).all()
|
||||
|
||||
return issues
|
||||
|
||||
@router.put("/{issue_id}")
|
||||
async def update_issue(
|
||||
issue_id: int,
|
||||
update_request: ManagementUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
관리함에서 이슈 정보 업데이트
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 이슈 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
# 진행 중 또는 완료 대기 상태인지 확인
|
||||
if issue.review_status not in [ReviewStatus.in_progress]:
|
||||
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 수정할 수 있습니다.")
|
||||
|
||||
# 업데이트할 데이터 처리
|
||||
update_data = update_request.dict(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field == 'completion_photo' and value:
|
||||
# 완료 사진 Base64 처리
|
||||
from services.file_service import save_base64_image
|
||||
try:
|
||||
print(f"🔍 완료 사진 처리 시작 - 데이터 길이: {len(value)}")
|
||||
print(f"🔍 Base64 데이터 시작 부분: {value[:100]}...")
|
||||
photo_path = save_base64_image(value, "completion_")
|
||||
if photo_path:
|
||||
issue.completion_photo_path = photo_path
|
||||
print(f"✅ 완료 사진 저장 성공: {photo_path}")
|
||||
else:
|
||||
print("❌ 완료 사진 저장 실패: photo_path가 None")
|
||||
except Exception as e:
|
||||
print(f"❌ 완료 사진 저장 실패: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
elif field == 'final_description' and value:
|
||||
# final_description 업데이트 시 description도 함께 업데이트
|
||||
issue.final_description = value
|
||||
issue.description = value
|
||||
print(f"✅ final_description 및 description 업데이트: {value[:50]}...")
|
||||
continue
|
||||
elif field == 'final_category' and value:
|
||||
# final_category 업데이트 시 category도 함께 업데이트
|
||||
issue.final_category = value
|
||||
issue.category = value
|
||||
print(f"✅ final_category 및 category 업데이트: {value}")
|
||||
continue
|
||||
elif field == 'expected_completion_date' and value:
|
||||
# 날짜 필드 처리
|
||||
if not value.endswith('T00:00:00'):
|
||||
value = value + 'T00:00:00'
|
||||
setattr(issue, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return {
|
||||
"message": "이슈가 성공적으로 업데이트되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"updated_at": datetime.now()
|
||||
}
|
||||
|
||||
@router.put("/{issue_id}/additional-info")
|
||||
async def update_additional_info(
|
||||
issue_id: int,
|
||||
additional_info: AdditionalInfoUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
추가 정보 업데이트 (원인부서, 해당자 상세, 원인 상세)
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 이슈 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
# 진행 중 상태인지 확인
|
||||
if issue.review_status != ReviewStatus.in_progress:
|
||||
raise HTTPException(status_code=400, detail="진행 중 상태의 부적합만 추가 정보를 입력할 수 있습니다.")
|
||||
|
||||
# 추가 정보 업데이트
|
||||
update_data = additional_info.dict(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(issue, field, value)
|
||||
|
||||
# 추가 정보 입력 시간 및 입력자 기록
|
||||
issue.additional_info_updated_at = datetime.now()
|
||||
issue.additional_info_updated_by_id = current_user.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
return {
|
||||
"message": "추가 정보가 성공적으로 업데이트되었습니다.",
|
||||
"issue_id": issue.id,
|
||||
"updated_at": issue.additional_info_updated_at,
|
||||
"updated_by": current_user.username
|
||||
}
|
||||
|
||||
@router.get("/{issue_id}/additional-info")
|
||||
async def get_additional_info(
|
||||
issue_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
추가 정보 조회
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 이슈 조회
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="부적합을 찾을 수 없습니다.")
|
||||
|
||||
return {
|
||||
"issue_id": issue.id,
|
||||
"cause_department": issue.cause_department.value if issue.cause_department else None,
|
||||
"responsible_person_detail": issue.responsible_person_detail,
|
||||
"cause_detail": issue.cause_detail,
|
||||
"additional_info_updated_at": issue.additional_info_updated_at,
|
||||
"additional_info_updated_by_id": issue.additional_info_updated_by_id
|
||||
}
|
||||
|
||||
@router.get("/stats", response_model=DailyReportStats)
|
||||
async def get_management_stats(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
프로젝트별 관리함 통계 조회 (보고서용)
|
||||
"""
|
||||
# 관리함 페이지 권한 확인
|
||||
if not check_page_access(current_user.id, 'issues_management', db):
|
||||
raise HTTPException(status_code=403, detail="관리함 접근 권한이 없습니다.")
|
||||
|
||||
# 해당 프로젝트의 관리함 이슈들 조회
|
||||
issues = db.query(Issue).filter(
|
||||
Issue.project_id == project_id,
|
||||
Issue.review_status.in_([ReviewStatus.in_progress, ReviewStatus.completed])
|
||||
).all()
|
||||
|
||||
# 통계 계산
|
||||
stats = DailyReportStats()
|
||||
today = datetime.now().date()
|
||||
|
||||
for issue in issues:
|
||||
stats.total_count += 1
|
||||
|
||||
if issue.review_status == ReviewStatus.in_progress:
|
||||
stats.management_count += 1
|
||||
|
||||
# 지연 여부 확인
|
||||
if issue.expected_completion_date and issue.expected_completion_date < today:
|
||||
stats.delayed_count += 1
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
330
system3-nonconformance/api/routers/page_permissions.py
Normal file
330
system3-nonconformance/api/routers/page_permissions.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
페이지 권한 관리 API 라우터
|
||||
사용자별 페이지 접근 권한을 관리하는 엔드포인트들
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import User, UserPagePermission, UserRole
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["page-permissions"])
|
||||
|
||||
# Pydantic 모델들
|
||||
class PagePermissionRequest(BaseModel):
|
||||
user_id: int
|
||||
page_name: str
|
||||
can_access: bool
|
||||
notes: Optional[str] = None
|
||||
|
||||
class PagePermissionResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
page_name: str
|
||||
can_access: bool
|
||||
granted_by_id: Optional[int]
|
||||
granted_at: Optional[datetime]
|
||||
notes: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserPagePermissionSummary(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
full_name: Optional[str]
|
||||
role: str
|
||||
permissions: List[PagePermissionResponse]
|
||||
|
||||
# 기본 페이지 목록
|
||||
DEFAULT_PAGES = {
|
||||
'issues_create': {'title': '부적합 등록', 'default_access': True},
|
||||
'issues_view': {'title': '부적합 조회', 'default_access': True},
|
||||
'issues_manage': {'title': '부적합 관리', 'default_access': True},
|
||||
'issues_inbox': {'title': '수신함', 'default_access': True},
|
||||
'issues_management': {'title': '관리함', 'default_access': False},
|
||||
'issues_archive': {'title': '폐기함', 'default_access': False},
|
||||
'issues_dashboard': {'title': '현황판', 'default_access': True},
|
||||
'projects_manage': {'title': '프로젝트 관리', 'default_access': False},
|
||||
'daily_work': {'title': '일일 공수', 'default_access': False},
|
||||
'reports': {'title': '보고서', 'default_access': False},
|
||||
'users_manage': {'title': '사용자 관리', 'default_access': False}
|
||||
}
|
||||
|
||||
@router.post("/page-permissions/grant")
|
||||
async def grant_page_permission(
|
||||
request: PagePermissionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""페이지 권한 부여/취소"""
|
||||
|
||||
# 관리자만 권한 설정 가능
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 권한을 설정할 수 있습니다."
|
||||
)
|
||||
|
||||
# 대상 사용자 확인
|
||||
target_user = db.query(User).filter(User.id == request.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 유효한 페이지명 확인
|
||||
if request.page_name not in DEFAULT_PAGES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="유효하지 않은 페이지명입니다."
|
||||
)
|
||||
|
||||
# 기존 권한 확인
|
||||
existing_permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == request.user_id,
|
||||
UserPagePermission.page_name == request.page_name
|
||||
).first()
|
||||
|
||||
if existing_permission:
|
||||
# 기존 권한 업데이트
|
||||
existing_permission.can_access = request.can_access
|
||||
existing_permission.granted_by_id = current_user.id
|
||||
existing_permission.notes = request.notes
|
||||
db.commit()
|
||||
db.refresh(existing_permission)
|
||||
return {"message": "권한이 업데이트되었습니다.", "permission_id": existing_permission.id}
|
||||
else:
|
||||
# 새 권한 생성
|
||||
new_permission = UserPagePermission(
|
||||
user_id=request.user_id,
|
||||
page_name=request.page_name,
|
||||
can_access=request.can_access,
|
||||
granted_by_id=current_user.id,
|
||||
notes=request.notes
|
||||
)
|
||||
db.add(new_permission)
|
||||
db.commit()
|
||||
db.refresh(new_permission)
|
||||
return {"message": "권한이 설정되었습니다.", "permission_id": new_permission.id}
|
||||
|
||||
@router.get("/users/{user_id}/page-permissions", response_model=List[PagePermissionResponse])
|
||||
async def get_user_page_permissions(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 사용자의 페이지 권한 목록 조회"""
|
||||
|
||||
# 관리자이거나 본인의 권한만 조회 가능
|
||||
if current_user.role != UserRole.admin and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="권한이 없습니다."
|
||||
)
|
||||
|
||||
# 사용자 존재 확인
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 사용자의 페이지 권한 조회
|
||||
permissions = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == user_id
|
||||
).all()
|
||||
|
||||
return permissions
|
||||
|
||||
@router.get("/page-permissions/check/{user_id}/{page_name}")
|
||||
async def check_page_access(
|
||||
user_id: int,
|
||||
page_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""특정 사용자의 특정 페이지 접근 권한 확인"""
|
||||
|
||||
# 사용자 존재 확인
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# admin은 모든 페이지 접근 가능
|
||||
if user.role == UserRole.admin:
|
||||
return {"can_access": True, "reason": "admin_role"}
|
||||
|
||||
# 유효한 페이지명 확인
|
||||
if page_name not in DEFAULT_PAGES:
|
||||
return {"can_access": False, "reason": "invalid_page"}
|
||||
|
||||
# 개별 권한 확인
|
||||
permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == user_id,
|
||||
UserPagePermission.page_name == page_name
|
||||
).first()
|
||||
|
||||
if permission:
|
||||
return {
|
||||
"can_access": permission.can_access,
|
||||
"reason": "explicit_permission",
|
||||
"granted_at": permission.granted_at.isoformat() if permission.granted_at else None
|
||||
}
|
||||
|
||||
# 기본 권한 확인
|
||||
default_access = DEFAULT_PAGES[page_name]['default_access']
|
||||
return {
|
||||
"can_access": default_access,
|
||||
"reason": "default_permission"
|
||||
}
|
||||
|
||||
@router.get("/page-permissions/all-users", response_model=List[UserPagePermissionSummary])
|
||||
async def get_all_users_permissions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""모든 사용자의 페이지 권한 요약 조회 (관리자용)"""
|
||||
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 접근할 수 있습니다."
|
||||
)
|
||||
|
||||
# 모든 사용자 조회
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
|
||||
result = []
|
||||
for user in users:
|
||||
# 각 사용자의 권한 조회
|
||||
permissions = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == user.id
|
||||
).all()
|
||||
|
||||
result.append(UserPagePermissionSummary(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
role=user.role.value,
|
||||
permissions=permissions
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/page-permissions/available-pages")
|
||||
async def get_available_pages(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""사용 가능한 페이지 목록 조회"""
|
||||
|
||||
return {
|
||||
"pages": DEFAULT_PAGES,
|
||||
"total_count": len(DEFAULT_PAGES)
|
||||
}
|
||||
|
||||
@router.delete("/page-permissions/{permission_id}")
|
||||
async def delete_page_permission(
|
||||
permission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""페이지 권한 삭제 (기본값으로 되돌림)"""
|
||||
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 권한을 삭제할 수 있습니다."
|
||||
)
|
||||
|
||||
# 권한 조회
|
||||
permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.id == permission_id
|
||||
).first()
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="권한을 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 권한 삭제
|
||||
db.delete(permission)
|
||||
db.commit()
|
||||
|
||||
return {"message": "권한이 삭제되었습니다. 기본값이 적용됩니다."}
|
||||
|
||||
class BulkPermissionRequest(BaseModel):
|
||||
user_id: int
|
||||
permissions: List[dict] # [{"page_name": "issues_manage", "can_access": true}, ...]
|
||||
|
||||
@router.post("/page-permissions/bulk-grant")
|
||||
async def bulk_grant_permissions(
|
||||
request: BulkPermissionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""사용자의 여러 페이지 권한을 일괄 설정"""
|
||||
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 권한을 설정할 수 있습니다."
|
||||
)
|
||||
|
||||
# 대상 사용자 확인
|
||||
target_user = db.query(User).filter(User.id == request.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
updated_permissions = []
|
||||
|
||||
for perm_data in request.permissions:
|
||||
page_name = perm_data.get('page_name')
|
||||
can_access = perm_data.get('can_access', False)
|
||||
|
||||
# 유효한 페이지명 확인
|
||||
if page_name not in DEFAULT_PAGES:
|
||||
continue
|
||||
|
||||
# 기존 권한 확인
|
||||
existing_permission = db.query(UserPagePermission).filter(
|
||||
UserPagePermission.user_id == request.user_id,
|
||||
UserPagePermission.page_name == page_name
|
||||
).first()
|
||||
|
||||
if existing_permission:
|
||||
# 기존 권한 업데이트
|
||||
existing_permission.can_access = can_access
|
||||
existing_permission.granted_by_id = current_user.id
|
||||
updated_permissions.append(existing_permission)
|
||||
else:
|
||||
# 새 권한 생성
|
||||
new_permission = UserPagePermission(
|
||||
user_id=request.user_id,
|
||||
page_name=page_name,
|
||||
can_access=can_access,
|
||||
granted_by_id=current_user.id
|
||||
)
|
||||
db.add(new_permission)
|
||||
updated_permissions.append(new_permission)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"{len(updated_permissions)}개의 권한이 설정되었습니다.",
|
||||
"updated_count": len(updated_permissions)
|
||||
}
|
||||
129
system3-nonconformance/api/routers/projects.py
Normal file
129
system3-nonconformance/api/routers/projects.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from database.database import get_db
|
||||
from database.models import Project, User, UserRole
|
||||
from database.schemas import ProjectCreate, ProjectUpdate, Project as ProjectSchema
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/projects",
|
||||
tags=["projects"]
|
||||
)
|
||||
|
||||
def check_admin_permission(current_user: User = Depends(get_current_user)):
|
||||
"""관리자 권한 확인"""
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자 권한이 필요합니다."
|
||||
)
|
||||
return current_user
|
||||
|
||||
@router.options("/")
|
||||
async def projects_options():
|
||||
"""OPTIONS preflight 요청 처리"""
|
||||
return {"message": "OK"}
|
||||
|
||||
@router.post("/", response_model=ProjectSchema)
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 생성 (관리자만)"""
|
||||
# Job No. 중복 확인
|
||||
existing_project = db.query(Project).filter(Project.job_no == project.job_no).first()
|
||||
if existing_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 Job No.입니다."
|
||||
)
|
||||
|
||||
# 프로젝트 생성
|
||||
db_project = Project(
|
||||
job_no=project.job_no,
|
||||
project_name=project.project_name,
|
||||
created_by_id=current_user.id
|
||||
)
|
||||
|
||||
db.add(db_project)
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
|
||||
return db_project
|
||||
|
||||
@router.get("/", response_model=List[ProjectSchema])
|
||||
async def get_projects(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""프로젝트 목록 조회"""
|
||||
query = db.query(Project)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Project.is_active == True)
|
||||
|
||||
projects = query.offset(skip).limit(limit).all()
|
||||
return projects
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectSchema)
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""특정 프로젝트 조회"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
return project
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectSchema)
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
project_update: ProjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 수정 (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 업데이트할 필드만 수정
|
||||
update_data = project_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
return project
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 삭제 (비활성화) (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 실제 삭제 대신 비활성화
|
||||
project.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {"message": "프로젝트가 삭제되었습니다."}
|
||||
873
system3-nonconformance/api/routers/reports.py
Normal file
873
system3-nonconformance/api/routers/reports.py
Normal file
@@ -0,0 +1,873 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
from datetime import datetime, date
|
||||
from typing import List
|
||||
import io
|
||||
import re
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.drawing.image import Image as XLImage
|
||||
import os
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"total_hours": work.total_hours
|
||||
} for work in works]
|
||||
|
||||
@router.get("/daily-preview")
|
||||
async def preview_daily_report(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일보고서 미리보기 - 추출될 항목 목록"""
|
||||
|
||||
# 권한 확인
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 추출될 항목 조회 (진행 중 + 미추출 완료 항목)
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.project_id == project_id,
|
||||
or_(
|
||||
Issue.review_status == ReviewStatus.in_progress,
|
||||
and_(
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 이슈 리스트를 schema로 변환
|
||||
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
||||
|
||||
return {
|
||||
"project": schemas.Project.from_orm(project),
|
||||
"stats": stats,
|
||||
"issues": issues_data,
|
||||
"total_issues": len(issues)
|
||||
}
|
||||
|
||||
@router.post("/daily-export")
|
||||
async def export_daily_report(
|
||||
request: schemas.DailyReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""품질팀용 일일보고서 엑셀 내보내기"""
|
||||
|
||||
# 권한 확인 (품질팀만 접근 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_only = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (모두 조회)
|
||||
all_completed = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.completed
|
||||
).all()
|
||||
|
||||
# 완료 항목 중 "완료 후 추출 안된 것" 필터링
|
||||
# 규칙: 완료 처리 후 1회에 한해서만 "진행 중" 시트에 표시
|
||||
not_exported_after_completion = []
|
||||
for issue in all_completed:
|
||||
if issue.last_exported_at is None:
|
||||
# 한번도 추출 안됨 -> 진행 중 시트에 표시 (완료 후 첫 추출)
|
||||
not_exported_after_completion.append(issue)
|
||||
elif issue.actual_completion_date:
|
||||
# actual_completion_date가 있는 경우: 완료일과 마지막 추출일 비교
|
||||
completion_date = issue.actual_completion_date.replace(tzinfo=None) if issue.actual_completion_date.tzinfo else issue.actual_completion_date
|
||||
export_date = issue.last_exported_at.replace(tzinfo=None) if issue.last_exported_at.tzinfo else issue.last_exported_at
|
||||
if completion_date > export_date:
|
||||
# 완료일이 마지막 추출일보다 나중 -> 완료 후 아직 추출 안됨 -> 진행 중 시트에 표시
|
||||
not_exported_after_completion.append(issue)
|
||||
# else: 완료일이 마지막 추출일보다 이전 -> 이미 완료 후 추출됨 -> 완료됨 시트로
|
||||
# else: actual_completion_date가 없고 last_exported_at가 있음
|
||||
# -> 이미 한번 이상 추출됨 -> 완료됨 시트로
|
||||
|
||||
# "진행 중" 시트용: 진행 중 + 완료되고 아직 추출 안된 것
|
||||
in_progress_issues = in_progress_only + not_exported_after_completion
|
||||
|
||||
# 진행 중 시트 정렬: 지연중 -> 진행중 -> 완료됨 순서
|
||||
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# "완료됨" 시트용: 완료 항목 중 "완료 후 추출된 것"만 (진행 중 시트에 표시되는 것 제외)
|
||||
not_exported_ids = {issue.id for issue in not_exported_after_completion}
|
||||
completed_issues = [issue for issue in all_completed if issue.id not in not_exported_ids]
|
||||
|
||||
# 완료됨 시트도 정렬 (완료일 최신순)
|
||||
completed_issues = sorted(completed_issues, key=lambda x: -x.actual_completion_date.timestamp() if x.actual_completion_date else 0)
|
||||
|
||||
# 웹과 동일한 로직: 진행중 + 완료를 함께 정렬하여 순번 할당
|
||||
# (웹에서는 in_progress와 completed를 함께 가져와서 전체를 reviewed_at 순으로 정렬 후 순번 매김)
|
||||
all_management_issues = in_progress_only + all_completed
|
||||
|
||||
# reviewed_at 기준으로 정렬 (웹과 동일)
|
||||
all_management_issues = sorted(all_management_issues, key=lambda x: x.reviewed_at if x.reviewed_at else datetime.min)
|
||||
|
||||
# 프로젝트별로 그룹화하여 순번 할당 (웹과 동일한 로직)
|
||||
project_groups = {}
|
||||
for issue in all_management_issues:
|
||||
if issue.project_id not in project_groups:
|
||||
project_groups[issue.project_id] = []
|
||||
project_groups[issue.project_id].append(issue)
|
||||
|
||||
# 각 프로젝트별로 순번 재할당 (웹과 동일)
|
||||
for project_id, project_issues in project_groups.items():
|
||||
for idx, issue in enumerate(project_issues, 1):
|
||||
issue._display_no = idx
|
||||
|
||||
# 전체 이슈 (통계 계산용 및 추출 이력 업데이트용)
|
||||
issues = list(set(in_progress_only + all_completed)) # 중복 제거
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 엑셀 파일 생성
|
||||
wb = Workbook()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
stats_font = Font(bold=True, size=12)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
card_header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
label_font = Font(bold=True, size=10)
|
||||
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
content_font = Font(size=10)
|
||||
thick_border = Border(
|
||||
left=Side(style='medium'),
|
||||
right=Side(style='medium'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 두 개의 시트를 생성하고 각각 데이터 입력
|
||||
sheets_data = [
|
||||
(wb.active, in_progress_issues, "진행 중"),
|
||||
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
|
||||
]
|
||||
|
||||
sheets_data[0][0].title = "진행 중"
|
||||
|
||||
for ws, sheet_issues, sheet_title in sheets_data:
|
||||
# 제목 및 기본 정보
|
||||
ws.merge_cells('A1:L1')
|
||||
ws['A1'] = f"{project.project_name} - {sheet_title}"
|
||||
ws['A1'].font = Font(bold=True, size=16)
|
||||
ws['A1'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells('A2:L2')
|
||||
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}"
|
||||
ws['A2'].alignment = center_alignment
|
||||
|
||||
# 프로젝트 통계 (4행부터) - 진행 중 시트에만 표시
|
||||
if sheet_title == "진행 중":
|
||||
ws.merge_cells('A4:L4')
|
||||
ws['A4'] = "📊 프로젝트 현황"
|
||||
ws['A4'].font = Font(bold=True, size=14, color="FFFFFF")
|
||||
ws['A4'].fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
ws['A4'].alignment = center_alignment
|
||||
ws.row_dimensions[4].height = 25
|
||||
|
||||
# 통계 데이터 - 박스 형태로 개선
|
||||
stats_row = 5
|
||||
ws.row_dimensions[stats_row].height = 30
|
||||
|
||||
# 총 신고 수량 (파란색 계열)
|
||||
ws.merge_cells(f'A{stats_row}:B{stats_row}')
|
||||
ws[f'A{stats_row}'] = "총 신고 수량"
|
||||
ws[f'A{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
|
||||
ws[f'A{stats_row}'].fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid")
|
||||
ws[f'A{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'C{stats_row}'] = stats.total_count
|
||||
ws[f'C{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'C{stats_row}'].fill = PatternFill(start_color="DEEBF7", end_color="DEEBF7", fill_type="solid")
|
||||
ws[f'C{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 진행 현황 (노란색 계열)
|
||||
ws.merge_cells(f'D{stats_row}:E{stats_row}')
|
||||
ws[f'D{stats_row}'] = "진행 현황"
|
||||
ws[f'D{stats_row}'].font = Font(bold=True, size=11, color="000000")
|
||||
ws[f'D{stats_row}'].fill = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid")
|
||||
ws[f'D{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'F{stats_row}'] = stats.management_count
|
||||
ws[f'F{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'F{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
ws[f'F{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 완료 현황 (초록색 계열)
|
||||
ws.merge_cells(f'G{stats_row}:H{stats_row}')
|
||||
ws[f'G{stats_row}'] = "완료 현황"
|
||||
ws[f'G{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
|
||||
ws[f'G{stats_row}'].fill = PatternFill(start_color="70AD47", end_color="70AD47", fill_type="solid")
|
||||
ws[f'G{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'I{stats_row}'] = stats.completed_count
|
||||
ws[f'I{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'I{stats_row}'].fill = PatternFill(start_color="C6E0B4", end_color="C6E0B4", fill_type="solid")
|
||||
ws[f'I{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 지연 중 (빨간색 계열)
|
||||
ws.merge_cells(f'J{stats_row}:K{stats_row}')
|
||||
ws[f'J{stats_row}'] = "지연 중"
|
||||
ws[f'J{stats_row}'].font = Font(bold=True, size=11, color="FFFFFF")
|
||||
ws[f'J{stats_row}'].fill = PatternFill(start_color="E74C3C", end_color="E74C3C", fill_type="solid")
|
||||
ws[f'J{stats_row}'].alignment = center_alignment
|
||||
|
||||
ws[f'L{stats_row}'] = stats.delayed_count
|
||||
ws[f'L{stats_row}'].font = Font(bold=True, size=14)
|
||||
ws[f'L{stats_row}'].fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
|
||||
ws[f'L{stats_row}'].alignment = center_alignment
|
||||
|
||||
# 통계 박스에 테두리 적용
|
||||
thick_border = Border(
|
||||
left=Side(style='medium'),
|
||||
right=Side(style='medium'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
for col in ['A', 'C', 'D', 'F', 'G', 'I', 'J', 'L']:
|
||||
if col in ['A', 'D', 'G', 'J']: # 병합된 셀의 시작점
|
||||
for c in range(ord(col), ord(col) + 2): # 병합된 2개 셀
|
||||
ws.cell(row=stats_row, column=c - ord('A') + 1).border = thick_border
|
||||
else: # 숫자 셀
|
||||
ws[f'{col}{stats_row}'].border = thick_border
|
||||
|
||||
# 카드 형태로 데이터 입력 (완료됨 시트는 4행부터, 진행 중 시트는 7행부터)
|
||||
current_row = 4 if sheet_title == "완료됨" else 7
|
||||
|
||||
for idx, issue in enumerate(sheet_issues, 1):
|
||||
card_start_row = current_row
|
||||
|
||||
# 상태별 헤더 색상 설정
|
||||
header_color = get_issue_status_header_color(issue)
|
||||
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
|
||||
|
||||
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
|
||||
ws.merge_cells(f'A{current_row}:C{current_row}')
|
||||
# 동적으로 할당된 프로젝트별 순번 사용 (웹과 동일)
|
||||
issue_no = getattr(issue, '_display_no', issue.project_sequence_no or issue.id)
|
||||
ws[f'A{current_row}'] = f"No. {issue_no}"
|
||||
ws[f'A{current_row}'].font = card_header_font
|
||||
ws[f'A{current_row}'].fill = card_header_fill
|
||||
ws[f'A{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'D{current_row}:G{current_row}')
|
||||
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
|
||||
ws[f'D{current_row}'].font = card_header_font
|
||||
ws[f'D{current_row}'].fill = card_header_fill
|
||||
ws[f'D{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
|
||||
ws[f'H{current_row}'].font = card_header_font
|
||||
ws[f'H{current_row}'].fill = card_header_fill
|
||||
ws[f'H{current_row}'].alignment = center_alignment
|
||||
current_row += 1
|
||||
|
||||
# 부적합명
|
||||
ws[f'A{current_row}'] = "부적합명"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# final_description이 있으면 사용, 없으면 description 사용
|
||||
issue_title = issue.final_description or issue.description or "내용 없음"
|
||||
ws[f'B{current_row}'] = issue_title
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
current_row += 1
|
||||
|
||||
# 상세내용 (detail_notes가 실제 상세 설명)
|
||||
if issue.detail_notes:
|
||||
ws[f'A{current_row}'] = "상세내용"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.detail_notes
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
ws.row_dimensions[current_row].height = 50
|
||||
current_row += 1
|
||||
|
||||
# 원인분류
|
||||
ws[f'A{current_row}'] = "원인분류"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "원인부서"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:F{current_row}')
|
||||
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'G{current_row}'] = "신고자"
|
||||
ws[f'G{current_row}'].font = label_font
|
||||
ws[f'G{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
|
||||
ws[f'H{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 해결방안 (완료 반려 내용 및 댓글 제거)
|
||||
ws[f'A{current_row}'] = "해결방안"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# management_comment에서 완료 반려 패턴과 댓글 제거
|
||||
clean_solution = clean_management_comment_for_export(issue.management_comment)
|
||||
ws[f'B{current_row}'] = clean_solution
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 담당정보
|
||||
ws[f'A{current_row}'] = "담당부서"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "담당자"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:G{current_row}')
|
||||
ws[f'E{current_row}'] = issue.responsible_person or ""
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'H{current_row}'] = "조치예상일"
|
||||
ws[f'H{current_row}'].font = label_font
|
||||
ws[f'H{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'I{current_row}:L{current_row}')
|
||||
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
|
||||
ws[f'I{current_row}'].font = content_font
|
||||
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
|
||||
current_row += 1
|
||||
|
||||
# === 신고 사진 영역 ===
|
||||
report_photos = [
|
||||
issue.photo_path,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
]
|
||||
report_photos = [p for p in report_photos if p] # None 제거
|
||||
|
||||
if report_photos:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "신고 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
report_image_inserted = False
|
||||
|
||||
# 사진 위치: A, C, E, G, I (2열 간격)
|
||||
photo_columns = ['A', 'C', 'E', 'G', 'I']
|
||||
|
||||
for idx, photo in enumerate(report_photos):
|
||||
if idx >= 5: # 최대 5장
|
||||
break
|
||||
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 200) # 크기 줄임
|
||||
img.height = min(img.height, 150)
|
||||
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
if report_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 120
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# === 완료 관련 정보 (완료된 항목만) ===
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
# 완료 코멘트
|
||||
if issue.completion_comment:
|
||||
ws[f'A{current_row}'] = "완료 의견"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.completion_comment
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 완료 사진
|
||||
completion_photos = [
|
||||
issue.completion_photo_path,
|
||||
issue.completion_photo_path2,
|
||||
issue.completion_photo_path3,
|
||||
issue.completion_photo_path4,
|
||||
issue.completion_photo_path5
|
||||
]
|
||||
completion_photos = [p for p in completion_photos if p] # None 제거
|
||||
|
||||
if completion_photos:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "완료 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진들을 한 행에 일렬로 표시 (간격 좁게)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
completion_image_inserted = False
|
||||
|
||||
# 사진 위치: A, C, E, G, I (2열 간격)
|
||||
photo_columns = ['A', 'C', 'E', 'G', 'I']
|
||||
|
||||
for idx, photo in enumerate(completion_photos):
|
||||
if idx >= 5: # 최대 5장
|
||||
break
|
||||
photo_path = photo.replace('/uploads/', '/app/uploads/') if photo.startswith('/uploads/') else photo
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 200) # 크기 줄임
|
||||
img.height = min(img.height, 150)
|
||||
ws.add_image(img, f'{photo_columns[idx]}{current_row}')
|
||||
completion_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
if completion_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 120
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# 완료일 정보
|
||||
if issue.actual_completion_date:
|
||||
ws[f'A{current_row}'] = "완료일"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 사진이 하나도 없을 경우
|
||||
# 진행중: 신고 사진만 체크
|
||||
# 완료됨: 신고 사진 + 완료 사진 체크
|
||||
has_completion_photos = False
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
comp_photos = [p for p in [
|
||||
issue.completion_photo_path,
|
||||
issue.completion_photo_path2,
|
||||
issue.completion_photo_path3,
|
||||
issue.completion_photo_path4,
|
||||
issue.completion_photo_path5
|
||||
] if p]
|
||||
has_completion_photos = bool(comp_photos)
|
||||
has_any_photo = bool(report_photos) or has_completion_photos
|
||||
|
||||
if not has_any_photo:
|
||||
ws[f'A{current_row}'] = "첨부사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = "첨부된 사진 없음"
|
||||
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
current_row += 1
|
||||
|
||||
# 카드 전체에 테두리 적용 (A-L 열)
|
||||
card_end_row = current_row - 1
|
||||
for row in range(card_start_row, card_end_row + 1):
|
||||
for col in range(1, 13): # A-L 열 (12열)
|
||||
cell = ws.cell(row=row, column=col)
|
||||
if not cell.border or cell.border.left.style != 'medium':
|
||||
cell.border = border
|
||||
|
||||
# 카드 외곽에 굵은 테두리 (A-L 열) - 상태별 색상 적용
|
||||
border_color = header_color # 헤더와 같은 색상 사용
|
||||
for col in range(1, 13):
|
||||
ws.cell(row=card_start_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
|
||||
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
|
||||
top=Side(style='medium', color=border_color),
|
||||
bottom=Side(style='thin', color=border_color)
|
||||
)
|
||||
ws.cell(row=card_end_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin', color=border_color),
|
||||
right=Side(style='medium' if col == 12 else 'thin', color=border_color),
|
||||
top=Side(style='thin', color=border_color),
|
||||
bottom=Side(style='medium', color=border_color)
|
||||
)
|
||||
|
||||
# 카드 좌우 테두리도 색상 적용
|
||||
for row in range(card_start_row + 1, card_end_row):
|
||||
ws.cell(row=row, column=1).border = Border(
|
||||
left=Side(style='medium', color=border_color),
|
||||
right=ws.cell(row=row, column=1).border.right if ws.cell(row=row, column=1).border else Side(style='thin'),
|
||||
top=ws.cell(row=row, column=1).border.top if ws.cell(row=row, column=1).border else Side(style='thin'),
|
||||
bottom=ws.cell(row=row, column=1).border.bottom if ws.cell(row=row, column=1).border else Side(style='thin')
|
||||
)
|
||||
ws.cell(row=row, column=12).border = Border(
|
||||
left=ws.cell(row=row, column=12).border.left if ws.cell(row=row, column=12).border else Side(style='thin'),
|
||||
right=Side(style='medium', color=border_color),
|
||||
top=ws.cell(row=row, column=12).border.top if ws.cell(row=row, column=12).border else Side(style='thin'),
|
||||
bottom=ws.cell(row=row, column=12).border.bottom if ws.cell(row=row, column=12).border else Side(style='thin')
|
||||
)
|
||||
|
||||
# 카드 구분 (빈 행)
|
||||
current_row += 1
|
||||
|
||||
# 열 너비 조정
|
||||
ws.column_dimensions['A'].width = 12 # 레이블 열
|
||||
ws.column_dimensions['B'].width = 15 # 내용 열
|
||||
ws.column_dimensions['C'].width = 15 # 내용 열
|
||||
ws.column_dimensions['D'].width = 15 # 내용 열
|
||||
ws.column_dimensions['E'].width = 15 # 내용 열
|
||||
ws.column_dimensions['F'].width = 15 # 내용 열
|
||||
ws.column_dimensions['G'].width = 15 # 내용 열
|
||||
ws.column_dimensions['H'].width = 15 # 내용 열
|
||||
ws.column_dimensions['I'].width = 15 # 내용 열
|
||||
ws.column_dimensions['J'].width = 15 # 내용 열
|
||||
ws.column_dimensions['K'].width = 15 # 내용 열
|
||||
ws.column_dimensions['L'].width = 15 # 내용 열
|
||||
|
||||
# 엑셀 파일을 메모리에 저장
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
|
||||
# 추출 이력 업데이트
|
||||
export_time = datetime.now()
|
||||
for issue in issues:
|
||||
issue.last_exported_at = export_time
|
||||
issue.export_count = (issue.export_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
# 파일명 생성
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
|
||||
|
||||
# 한글 파일명을 위한 URL 인코딩
|
||||
from urllib.parse import quote
|
||||
encoded_filename = quote(filename.encode('utf-8'))
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(excel_buffer.read()),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
|
||||
"""프로젝트 통계 계산"""
|
||||
stats = schemas.DailyReportStats()
|
||||
|
||||
today = date.today()
|
||||
|
||||
for issue in issues:
|
||||
stats.total_count += 1
|
||||
|
||||
if issue.review_status == ReviewStatus.in_progress:
|
||||
stats.management_count += 1
|
||||
|
||||
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
|
||||
if issue.expected_completion_date:
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
stats.delayed_count += 1
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
|
||||
def get_category_text(category: IssueCategory) -> str:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
return status_map.get(status, str(status))
|
||||
|
||||
def clean_management_comment_for_export(text: str) -> str:
|
||||
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
|
||||
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
|
||||
|
||||
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
|
||||
lines = text.split('\n')
|
||||
filtered_lines = []
|
||||
for line in lines:
|
||||
# └ 또는 ↳로 시작하는 줄 제외
|
||||
if not re.match(r'^\s*[└↳]', line):
|
||||
filtered_lines.append(line)
|
||||
|
||||
# 3. 빈 줄 정리
|
||||
result = '\n'.join(filtered_lines).strip()
|
||||
# 연속된 빈 줄을 하나로
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
|
||||
return result
|
||||
|
||||
def get_status_color(status: ReviewStatus) -> str:
|
||||
"""상태별 색상 반환"""
|
||||
color_map = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
ReviewStatus.disposed: "F2F2F2" # 연한 회색
|
||||
}
|
||||
return color_map.get(status, None)
|
||||
|
||||
|
||||
def get_issue_priority(issue: Issue) -> int:
|
||||
"""이슈 우선순위 반환 (엑셀 정렬용)
|
||||
1: 지연 (빨강)
|
||||
2: 진행중 (노랑)
|
||||
3: 완료됨 (초록)
|
||||
"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return 3
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
# 조치 예상일이 지난 경우 지연
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return 1 # 지연
|
||||
return 2 # 진행중
|
||||
return 2
|
||||
|
||||
def get_issue_status_text(issue: Issue) -> str:
|
||||
"""이슈 상태 텍스트 반환"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return "완료됨"
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return "지연중"
|
||||
return "진행중"
|
||||
return get_status_text(issue.review_status)
|
||||
|
||||
def get_issue_status_header_color(issue: Issue) -> str:
|
||||
"""이슈 상태별 헤더 색상 반환"""
|
||||
priority = get_issue_priority(issue)
|
||||
if priority == 1: # 지연
|
||||
return "E74C3C" # 빨간색
|
||||
elif priority == 2: # 진행중
|
||||
return "FFD966" # 노랑색 (주황색 사이)
|
||||
elif priority == 3: # 완료
|
||||
return "92D050" # 진한 초록색
|
||||
return "4472C4" # 기본 파란색
|
||||
734
system3-nonconformance/api/routers/reports.py.bak
Normal file
734
system3-nonconformance/api/routers/reports.py.bak
Normal file
@@ -0,0 +1,734 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
from datetime import datetime, date
|
||||
from typing import List
|
||||
import io
|
||||
import re
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.drawing.image import Image as XLImage
|
||||
import os
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"total_hours": work.total_hours
|
||||
} for work in works]
|
||||
|
||||
@router.get("/daily-preview")
|
||||
async def preview_daily_report(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일보고서 미리보기 - 추출될 항목 목록"""
|
||||
|
||||
# 권한 확인
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 추출될 항목 조회 (진행 중 + 미추출 완료 항목)
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.project_id == project_id,
|
||||
or_(
|
||||
Issue.review_status == ReviewStatus.in_progress,
|
||||
and_(
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 이슈 리스트를 schema로 변환
|
||||
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
||||
|
||||
return {
|
||||
"project": schemas.Project.from_orm(project),
|
||||
"stats": stats,
|
||||
"issues": issues_data,
|
||||
"total_issues": len(issues)
|
||||
}
|
||||
|
||||
@router.post("/daily-export")
|
||||
async def export_daily_report(
|
||||
request: schemas.DailyReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""품질팀용 일일보고서 엑셀 내보내기"""
|
||||
|
||||
# 권한 확인 (품질팀만 접근 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (한번도 추출 안된 항목만)
|
||||
completed_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
).all()
|
||||
|
||||
# 진행중 항목 정렬: 지연 -> 진행중 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 완료 항목 정렬: 등록번호(project_sequence_no 또는 id) 순
|
||||
completed_issues = sorted(completed_issues, key=lambda x: x.project_sequence_no or x.id)
|
||||
|
||||
# 전체 이슈 (통계 계산용)
|
||||
issues = in_progress_issues + completed_issues
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 엑셀 파일 생성
|
||||
wb = Workbook()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
stats_font = Font(bold=True, size=12)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
card_header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
label_font = Font(bold=True, size=10)
|
||||
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
content_font = Font(size=10)
|
||||
thick_border = Border(
|
||||
left=Side(style='medium'),
|
||||
right=Side(style='medium'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 두 개의 시트를 생성하고 각각 데이터 입력
|
||||
sheets_data = [
|
||||
(wb.active, in_progress_issues, "진행 중"),
|
||||
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
|
||||
]
|
||||
|
||||
sheets_data[0][0].title = "진행 중"
|
||||
|
||||
for ws, sheet_issues, sheet_title in sheets_data:
|
||||
# 제목 및 기본 정보
|
||||
ws.merge_cells('A1:L1')
|
||||
ws['A1'] = f"{project.project_name} - {sheet_title}"
|
||||
ws['A1'].font = Font(bold=True, size=16)
|
||||
ws['A1'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells('A2:L2')
|
||||
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}"
|
||||
ws['A2'].alignment = center_alignment
|
||||
|
||||
# 프로젝트 통계 (4행부터)
|
||||
ws.merge_cells('A4:L4')
|
||||
ws['A4'] = "프로젝트 현황"
|
||||
ws['A4'].font = stats_font
|
||||
ws['A4'].fill = stats_fill
|
||||
ws['A4'].alignment = center_alignment
|
||||
|
||||
# 통계 데이터
|
||||
stats_row = 5
|
||||
ws[f'A{stats_row}'] = "총 신고 수량"
|
||||
ws[f'B{stats_row}'] = stats.total_count
|
||||
ws[f'D{stats_row}'] = "관리처리 현황"
|
||||
ws[f'E{stats_row}'] = stats.management_count
|
||||
ws[f'G{stats_row}'] = "완료 현황"
|
||||
ws[f'H{stats_row}'] = stats.completed_count
|
||||
ws[f'J{stats_row}'] = "지연 중"
|
||||
ws[f'K{stats_row}'] = stats.delayed_count
|
||||
|
||||
# 통계 스타일 적용
|
||||
for col in ['A', 'D', 'G', 'J']:
|
||||
ws[f'{col}{stats_row}'].font = Font(bold=True)
|
||||
ws[f'{col}{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
|
||||
# 카드 형태로 데이터 입력 (7행부터)
|
||||
current_row = 7
|
||||
|
||||
for idx, issue in enumerate(sheet_issues, 1):
|
||||
card_start_row = current_row
|
||||
|
||||
# 상태별 헤더 색상 설정
|
||||
header_color = get_issue_status_header_color(issue)
|
||||
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
|
||||
|
||||
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
|
||||
ws.merge_cells(f'A{current_row}:C{current_row}')
|
||||
ws[f'A{current_row}'] = f"No. {issue.project_sequence_no or issue.id}"
|
||||
ws[f'A{current_row}'].font = card_header_font
|
||||
ws[f'A{current_row}'].fill = card_header_fill
|
||||
ws[f'A{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'D{current_row}:G{current_row}')
|
||||
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
|
||||
ws[f'D{current_row}'].font = card_header_font
|
||||
ws[f'D{current_row}'].fill = card_header_fill
|
||||
ws[f'D{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
|
||||
ws[f'H{current_row}'].font = card_header_font
|
||||
ws[f'H{current_row}'].fill = card_header_fill
|
||||
ws[f'H{current_row}'].alignment = center_alignment
|
||||
current_row += 1
|
||||
|
||||
# 부적합명
|
||||
ws[f'A{current_row}'] = "부적합명"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# final_description이 있으면 사용, 없으면 description 사용
|
||||
issue_title = issue.final_description or issue.description or "내용 없음"
|
||||
ws[f'B{current_row}'] = issue_title
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
current_row += 1
|
||||
|
||||
# 상세내용 (detail_notes가 실제 상세 설명)
|
||||
if issue.detail_notes:
|
||||
ws[f'A{current_row}'] = "상세내용"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.detail_notes
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
ws.row_dimensions[current_row].height = 50
|
||||
current_row += 1
|
||||
|
||||
# 원인분류
|
||||
ws[f'A{current_row}'] = "원인분류"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "원인부서"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:F{current_row}')
|
||||
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'G{current_row}'] = "신고자"
|
||||
ws[f'G{current_row}'].font = label_font
|
||||
ws[f'G{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
|
||||
ws[f'H{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 해결방안 (완료 반려 내용 및 댓글 제거)
|
||||
ws[f'A{current_row}'] = "해결방안"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# management_comment에서 완료 반려 패턴과 댓글 제거
|
||||
clean_solution = clean_management_comment_for_export(issue.management_comment)
|
||||
ws[f'B{current_row}'] = clean_solution
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 담당정보
|
||||
ws[f'A{current_row}'] = "담당부서"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "담당자"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:G{current_row}')
|
||||
ws[f'E{current_row}'] = issue.responsible_person or ""
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'H{current_row}'] = "조치예상일"
|
||||
ws[f'H{current_row}'].font = label_font
|
||||
ws[f'H{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'I{current_row}:L{current_row}')
|
||||
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
|
||||
ws[f'I{current_row}'].font = content_font
|
||||
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
|
||||
current_row += 1
|
||||
|
||||
# === 신고 사진 영역 ===
|
||||
has_report_photo = issue.photo_path or issue.photo_path2
|
||||
if has_report_photo:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "신고 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진 행 (별도 행으로 분리)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
report_image_inserted = False
|
||||
|
||||
# 신고 사진 1
|
||||
if issue.photo_path:
|
||||
photo_path = issue.photo_path.replace('/uploads/', '/app/uploads/') if issue.photo_path.startswith('/uploads/') else issue.photo_path
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 첫 번째 사진
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
# 신고 사진 2
|
||||
if issue.photo_path2:
|
||||
photo_path2 = issue.photo_path2.replace('/uploads/', '/app/uploads/') if issue.photo_path2.startswith('/uploads/') else issue.photo_path2
|
||||
if os.path.exists(photo_path2):
|
||||
try:
|
||||
img = XLImage(photo_path2)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'G{current_row}') # G열에 두 번째 사진 (간격 확보)
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path2}): {e}")
|
||||
|
||||
if report_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# === 완료 관련 정보 (완료된 항목만) ===
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
# 완료 코멘트
|
||||
if issue.completion_comment:
|
||||
ws[f'A{current_row}'] = "완료 의견"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.completion_comment
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 완료 사진
|
||||
if issue.completion_photo_path:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "완료 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진 행 (별도 행으로 분리)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
completion_path = issue.completion_photo_path.replace('/uploads/', '/app/uploads/') if issue.completion_photo_path.startswith('/uploads/') else issue.completion_photo_path
|
||||
completion_image_inserted = False
|
||||
|
||||
if os.path.exists(completion_path):
|
||||
try:
|
||||
img = XLImage(completion_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 사진
|
||||
completion_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({completion_path}): {e}")
|
||||
|
||||
if completion_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# 완료일 정보
|
||||
if issue.actual_completion_date:
|
||||
ws[f'A{current_row}'] = "완료일"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 사진이 하나도 없을 경우
|
||||
# 진행중: 신고 사진만 체크
|
||||
# 완료됨: 신고 사진 + 완료 사진 체크
|
||||
has_any_photo = has_report_photo or (issue.review_status == ReviewStatus.completed and issue.completion_photo_path)
|
||||
|
||||
if not has_any_photo:
|
||||
ws[f'A{current_row}'] = "첨부사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = "첨부된 사진 없음"
|
||||
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
current_row += 1
|
||||
|
||||
# 카드 전체에 테두리 적용 (A-L 열)
|
||||
card_end_row = current_row - 1
|
||||
for row in range(card_start_row, card_end_row + 1):
|
||||
for col in range(1, 13): # A-L 열 (12열)
|
||||
cell = ws.cell(row=row, column=col)
|
||||
if not cell.border or cell.border.left.style != 'medium':
|
||||
cell.border = border
|
||||
|
||||
# 카드 외곽에 굵은 테두리 (A-L 열)
|
||||
for col in range(1, 13):
|
||||
ws.cell(row=card_start_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
ws.cell(row=card_end_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 카드 구분 (빈 행)
|
||||
current_row += 1
|
||||
|
||||
# 열 너비 조정
|
||||
ws.column_dimensions['A'].width = 12 # 레이블 열
|
||||
ws.column_dimensions['B'].width = 15 # 내용 열
|
||||
ws.column_dimensions['C'].width = 15 # 내용 열
|
||||
ws.column_dimensions['D'].width = 15 # 내용 열
|
||||
ws.column_dimensions['E'].width = 15 # 내용 열
|
||||
ws.column_dimensions['F'].width = 15 # 내용 열
|
||||
ws.column_dimensions['G'].width = 15 # 내용 열
|
||||
ws.column_dimensions['H'].width = 15 # 내용 열
|
||||
ws.column_dimensions['I'].width = 15 # 내용 열
|
||||
ws.column_dimensions['J'].width = 15 # 내용 열
|
||||
ws.column_dimensions['K'].width = 15 # 내용 열
|
||||
ws.column_dimensions['L'].width = 15 # 내용 열
|
||||
|
||||
# 엑셀 파일을 메모리에 저장
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
|
||||
# 추출 이력 업데이트
|
||||
export_time = datetime.now()
|
||||
for issue in issues:
|
||||
issue.last_exported_at = export_time
|
||||
issue.export_count = (issue.export_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
# 파일명 생성
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
|
||||
|
||||
# 한글 파일명을 위한 URL 인코딩
|
||||
from urllib.parse import quote
|
||||
encoded_filename = quote(filename.encode('utf-8'))
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(excel_buffer.read()),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
|
||||
"""프로젝트 통계 계산"""
|
||||
stats = schemas.DailyReportStats()
|
||||
|
||||
today = date.today()
|
||||
|
||||
for issue in issues:
|
||||
stats.total_count += 1
|
||||
|
||||
if issue.review_status == ReviewStatus.in_progress:
|
||||
stats.management_count += 1
|
||||
|
||||
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
|
||||
if issue.expected_completion_date:
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
stats.delayed_count += 1
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
|
||||
def get_category_text(category: IssueCategory) -> str:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
return status_map.get(status, str(status))
|
||||
|
||||
def clean_management_comment_for_export(text: str) -> str:
|
||||
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
|
||||
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
|
||||
|
||||
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
|
||||
lines = text.split('\n')
|
||||
filtered_lines = []
|
||||
for line in lines:
|
||||
# └ 또는 ↳로 시작하는 줄 제외
|
||||
if not re.match(r'^\s*[└↳]', line):
|
||||
filtered_lines.append(line)
|
||||
|
||||
# 3. 빈 줄 정리
|
||||
result = '\n'.join(filtered_lines).strip()
|
||||
# 연속된 빈 줄을 하나로
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
|
||||
return result
|
||||
|
||||
def get_status_color(status: ReviewStatus) -> str:
|
||||
"""상태별 색상 반환"""
|
||||
color_map = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
ReviewStatus.disposed: "F2F2F2" # 연한 회색
|
||||
}
|
||||
return color_map.get(status, None)
|
||||
|
||||
def get_issue_priority(issue: Issue) -> int:
|
||||
"""이슈 우선순위 반환 (엑셀 정렬용)
|
||||
1: 지연 (빨강)
|
||||
2: 진행중 (노랑)
|
||||
3: 완료됨 (초록)
|
||||
"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return 3
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
# 조치 예상일이 지난 경우 지연
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return 1 # 지연
|
||||
return 2 # 진행중
|
||||
return 2
|
||||
|
||||
def get_issue_status_text(issue: Issue) -> str:
|
||||
"""이슈 상태 텍스트 반환"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return "완료됨"
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return "지연중"
|
||||
return "진행중"
|
||||
return get_status_text(issue.review_status)
|
||||
|
||||
def get_issue_status_header_color(issue: Issue) -> str:
|
||||
"""이슈 상태별 헤더 색상 반환"""
|
||||
priority = get_issue_priority(issue)
|
||||
if priority == 1: # 지연
|
||||
return "E74C3C" # 빨간색
|
||||
elif priority == 2: # 진행중
|
||||
return "F39C12" # 주황/노란색
|
||||
elif priority == 3: # 완료
|
||||
return "27AE60" # 초록색
|
||||
return "4472C4" # 기본 파란색
|
||||
734
system3-nonconformance/api/routers/reports.py.bak2
Normal file
734
system3-nonconformance/api/routers/reports.py.bak2
Normal file
@@ -0,0 +1,734 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
from datetime import datetime, date
|
||||
from typing import List
|
||||
import io
|
||||
import re
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.drawing.image import Image as XLImage
|
||||
import os
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.material_missing:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.design_error:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.incoming_defect:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.complete:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.user:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"total_hours": work.total_hours
|
||||
} for work in works]
|
||||
|
||||
@router.get("/daily-preview")
|
||||
async def preview_daily_report(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일보고서 미리보기 - 추출될 항목 목록"""
|
||||
|
||||
# 권한 확인
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 추출될 항목 조회 (진행 중 + 미추출 완료 항목)
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.project_id == project_id,
|
||||
or_(
|
||||
Issue.review_status == ReviewStatus.in_progress,
|
||||
and_(
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 정렬: 지연 -> 진행중 -> 완료됨 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
issues = sorted(issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 이슈 리스트를 schema로 변환
|
||||
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
||||
|
||||
return {
|
||||
"project": schemas.Project.from_orm(project),
|
||||
"stats": stats,
|
||||
"issues": issues_data,
|
||||
"total_issues": len(issues)
|
||||
}
|
||||
|
||||
@router.post("/daily-export")
|
||||
async def export_daily_report(
|
||||
request: schemas.DailyReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""품질팀용 일일보고서 엑셀 내보내기"""
|
||||
|
||||
# 권한 확인 (품질팀만 접근 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == request.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (한번도 추출 안된 항목만)
|
||||
completed_issues = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.review_status == ReviewStatus.completed,
|
||||
Issue.last_exported_at == None
|
||||
).all()
|
||||
|
||||
# 진행중 항목 정렬: 지연 -> 진행중 순으로, 같은 상태 내에서는 신고일 최신순
|
||||
in_progress_issues = sorted(in_progress_issues, key=lambda x: (get_issue_priority(x), -x.report_date.timestamp() if x.report_date else 0))
|
||||
|
||||
# 완료 항목 정렬: 등록번호(project_sequence_no 또는 id) 순
|
||||
completed_issues = sorted(completed_issues, key=lambda x: x.project_sequence_no or x.id)
|
||||
|
||||
# 전체 이슈 (통계 계산용)
|
||||
issues = in_progress_issues + completed_issues
|
||||
|
||||
# 통계 계산
|
||||
stats = calculate_project_stats(issues)
|
||||
|
||||
# 엑셀 파일 생성
|
||||
wb = Workbook()
|
||||
|
||||
# 스타일 정의 (공통)
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
stats_font = Font(bold=True, size=12)
|
||||
stats_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
center_alignment = Alignment(horizontal='center', vertical='center')
|
||||
card_header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||
label_font = Font(bold=True, size=10)
|
||||
label_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
|
||||
content_font = Font(size=10)
|
||||
thick_border = Border(
|
||||
left=Side(style='medium'),
|
||||
right=Side(style='medium'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 두 개의 시트를 생성하고 각각 데이터 입력
|
||||
sheets_data = [
|
||||
(wb.active, in_progress_issues, "진행 중"),
|
||||
(wb.create_sheet(title="완료됨"), completed_issues, "완료됨")
|
||||
]
|
||||
|
||||
sheets_data[0][0].title = "진행 중"
|
||||
|
||||
for ws, sheet_issues, sheet_title in sheets_data:
|
||||
# 제목 및 기본 정보
|
||||
ws.merge_cells('A1:L1')
|
||||
ws['A1'] = f"{project.project_name} - {sheet_title}"
|
||||
ws['A1'].font = Font(bold=True, size=16)
|
||||
ws['A1'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells('A2:L2')
|
||||
ws['A2'] = f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}"
|
||||
ws['A2'].alignment = center_alignment
|
||||
|
||||
# 프로젝트 통계 (4행부터)
|
||||
ws.merge_cells('A4:L4')
|
||||
ws['A4'] = "프로젝트 현황"
|
||||
ws['A4'].font = stats_font
|
||||
ws['A4'].fill = stats_fill
|
||||
ws['A4'].alignment = center_alignment
|
||||
|
||||
# 통계 데이터
|
||||
stats_row = 5
|
||||
ws[f'A{stats_row}'] = "총 신고 수량"
|
||||
ws[f'B{stats_row}'] = stats.total_count
|
||||
ws[f'D{stats_row}'] = "관리처리 현황"
|
||||
ws[f'E{stats_row}'] = stats.management_count
|
||||
ws[f'G{stats_row}'] = "완료 현황"
|
||||
ws[f'H{stats_row}'] = stats.completed_count
|
||||
ws[f'J{stats_row}'] = "지연 중"
|
||||
ws[f'K{stats_row}'] = stats.delayed_count
|
||||
|
||||
# 통계 스타일 적용
|
||||
for col in ['A', 'D', 'G', 'J']:
|
||||
ws[f'{col}{stats_row}'].font = Font(bold=True)
|
||||
ws[f'{col}{stats_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||
|
||||
# 카드 형태로 데이터 입력 (7행부터)
|
||||
current_row = 7
|
||||
|
||||
for idx, issue in enumerate(sheet_issues, 1):
|
||||
card_start_row = current_row
|
||||
|
||||
# 상태별 헤더 색상 설정
|
||||
header_color = get_issue_status_header_color(issue)
|
||||
card_header_fill = PatternFill(start_color=header_color, end_color=header_color, fill_type="solid")
|
||||
|
||||
# 카드 헤더 (No, 상태, 신고일) - L열까지 확장
|
||||
ws.merge_cells(f'A{current_row}:C{current_row}')
|
||||
ws[f'A{current_row}'] = f"No. {issue.project_sequence_no or issue.id}"
|
||||
ws[f'A{current_row}'].font = card_header_font
|
||||
ws[f'A{current_row}'].fill = card_header_fill
|
||||
ws[f'A{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'D{current_row}:G{current_row}')
|
||||
ws[f'D{current_row}'] = f"상태: {get_issue_status_text(issue)}"
|
||||
ws[f'D{current_row}'].font = card_header_font
|
||||
ws[f'D{current_row}'].fill = card_header_fill
|
||||
ws[f'D{current_row}'].alignment = center_alignment
|
||||
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = f"신고일: {issue.report_date.strftime('%Y-%m-%d') if issue.report_date else '-'}"
|
||||
ws[f'H{current_row}'].font = card_header_font
|
||||
ws[f'H{current_row}'].fill = card_header_fill
|
||||
ws[f'H{current_row}'].alignment = center_alignment
|
||||
current_row += 1
|
||||
|
||||
# 부적합명
|
||||
ws[f'A{current_row}'] = "부적합명"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# final_description이 있으면 사용, 없으면 description 사용
|
||||
issue_title = issue.final_description or issue.description or "내용 없음"
|
||||
ws[f'B{current_row}'] = issue_title
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
current_row += 1
|
||||
|
||||
# 상세내용 (detail_notes가 실제 상세 설명)
|
||||
if issue.detail_notes:
|
||||
ws[f'A{current_row}'] = "상세내용"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.detail_notes
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='top')
|
||||
ws.row_dimensions[current_row].height = 50
|
||||
current_row += 1
|
||||
|
||||
# 원인분류
|
||||
ws[f'A{current_row}'] = "원인분류"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_category_text(issue.final_category or issue.category)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "원인부서"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:F{current_row}')
|
||||
ws[f'E{current_row}'] = get_department_text(issue.cause_department)
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'G{current_row}'] = "신고자"
|
||||
ws[f'G{current_row}'].font = label_font
|
||||
ws[f'G{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'H{current_row}:L{current_row}')
|
||||
ws[f'H{current_row}'] = issue.reporter.full_name or issue.reporter.username if issue.reporter else "-"
|
||||
ws[f'H{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 해결방안 (완료 반려 내용 및 댓글 제거)
|
||||
ws[f'A{current_row}'] = "해결방안"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
|
||||
# management_comment에서 완료 반려 패턴과 댓글 제거
|
||||
clean_solution = clean_management_comment_for_export(issue.management_comment)
|
||||
ws[f'B{current_row}'] = clean_solution
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 담당정보
|
||||
ws[f'A{current_row}'] = "담당부서"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = get_department_text(issue.responsible_department)
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
|
||||
ws[f'D{current_row}'] = "담당자"
|
||||
ws[f'D{current_row}'].font = label_font
|
||||
ws[f'D{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'E{current_row}:G{current_row}')
|
||||
ws[f'E{current_row}'] = issue.responsible_person or ""
|
||||
ws[f'E{current_row}'].font = content_font
|
||||
|
||||
ws[f'H{current_row}'] = "조치예상일"
|
||||
ws[f'H{current_row}'].font = label_font
|
||||
ws[f'H{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'I{current_row}:L{current_row}')
|
||||
ws[f'I{current_row}'] = issue.expected_completion_date.strftime('%Y-%m-%d') if issue.expected_completion_date else ""
|
||||
ws[f'I{current_row}'].font = content_font
|
||||
ws.row_dimensions[current_row].height = 20 # 기본 높이보다 20% 증가
|
||||
current_row += 1
|
||||
|
||||
# === 신고 사진 영역 ===
|
||||
has_report_photo = issue.photo_path or issue.photo_path2
|
||||
if has_report_photo:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "신고 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # 노란색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진 행 (별도 행으로 분리)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
report_image_inserted = False
|
||||
|
||||
# 신고 사진 1
|
||||
if issue.photo_path:
|
||||
photo_path = issue.photo_path.replace('/uploads/', '/app/uploads/') if issue.photo_path.startswith('/uploads/') else issue.photo_path
|
||||
if os.path.exists(photo_path):
|
||||
try:
|
||||
img = XLImage(photo_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 첫 번째 사진
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path}): {e}")
|
||||
|
||||
# 신고 사진 2
|
||||
if issue.photo_path2:
|
||||
photo_path2 = issue.photo_path2.replace('/uploads/', '/app/uploads/') if issue.photo_path2.startswith('/uploads/') else issue.photo_path2
|
||||
if os.path.exists(photo_path2):
|
||||
try:
|
||||
img = XLImage(photo_path2)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'G{current_row}') # G열에 두 번째 사진 (간격 확보)
|
||||
report_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({photo_path2}): {e}")
|
||||
|
||||
if report_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# === 완료 관련 정보 (완료된 항목만) ===
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
# 완료 코멘트
|
||||
if issue.completion_comment:
|
||||
ws[f'A{current_row}'] = "완료 의견"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = issue.completion_comment
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
ws[f'B{current_row}'].alignment = Alignment(wrap_text=True, vertical='center') # 수직 가운데 정렬
|
||||
ws.row_dimensions[current_row].height = 30
|
||||
current_row += 1
|
||||
|
||||
# 완료 사진
|
||||
if issue.completion_photo_path:
|
||||
# 라벨 행 (A~L 전체 병합)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
ws[f'A{current_row}'] = "완료 사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid") # 연두색
|
||||
ws[f'A{current_row}'].alignment = Alignment(horizontal='left', vertical='center')
|
||||
ws.row_dimensions[current_row].height = 18
|
||||
current_row += 1
|
||||
|
||||
# 사진 행 (별도 행으로 분리)
|
||||
ws.merge_cells(f'A{current_row}:L{current_row}')
|
||||
completion_path = issue.completion_photo_path.replace('/uploads/', '/app/uploads/') if issue.completion_photo_path.startswith('/uploads/') else issue.completion_photo_path
|
||||
completion_image_inserted = False
|
||||
|
||||
if os.path.exists(completion_path):
|
||||
try:
|
||||
img = XLImage(completion_path)
|
||||
img.width = min(img.width, 250)
|
||||
img.height = min(img.height, 180)
|
||||
ws.add_image(img, f'A{current_row}') # A열에 사진
|
||||
completion_image_inserted = True
|
||||
except Exception as e:
|
||||
print(f"이미지 삽입 실패 ({completion_path}): {e}")
|
||||
|
||||
if completion_image_inserted:
|
||||
ws.row_dimensions[current_row].height = 150 # 사진 행 높이 조정 (200 -> 150)
|
||||
else:
|
||||
ws[f'A{current_row}'] = "사진 파일을 찾을 수 없음"
|
||||
ws[f'A{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
ws.row_dimensions[current_row].height = 20
|
||||
current_row += 1
|
||||
|
||||
# 완료일 정보
|
||||
if issue.actual_completion_date:
|
||||
ws[f'A{current_row}'] = "완료일"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = PatternFill(start_color="D5F5D5", end_color="D5F5D5", fill_type="solid")
|
||||
ws.merge_cells(f'B{current_row}:C{current_row}')
|
||||
ws[f'B{current_row}'] = issue.actual_completion_date.strftime('%Y-%m-%d')
|
||||
ws[f'B{current_row}'].font = content_font
|
||||
current_row += 1
|
||||
|
||||
# 사진이 하나도 없을 경우
|
||||
# 진행중: 신고 사진만 체크
|
||||
# 완료됨: 신고 사진 + 완료 사진 체크
|
||||
has_any_photo = has_report_photo or (issue.review_status == ReviewStatus.completed and issue.completion_photo_path)
|
||||
|
||||
if not has_any_photo:
|
||||
ws[f'A{current_row}'] = "첨부사진"
|
||||
ws[f'A{current_row}'].font = label_font
|
||||
ws[f'A{current_row}'].fill = label_fill
|
||||
ws.merge_cells(f'B{current_row}:L{current_row}')
|
||||
ws[f'B{current_row}'] = "첨부된 사진 없음"
|
||||
ws[f'B{current_row}'].font = Font(size=9, italic=True, color="999999")
|
||||
current_row += 1
|
||||
|
||||
# 카드 전체에 테두리 적용 (A-L 열)
|
||||
card_end_row = current_row - 1
|
||||
for row in range(card_start_row, card_end_row + 1):
|
||||
for col in range(1, 13): # A-L 열 (12열)
|
||||
cell = ws.cell(row=row, column=col)
|
||||
if not cell.border or cell.border.left.style != 'medium':
|
||||
cell.border = border
|
||||
|
||||
# 카드 외곽에 굵은 테두리 (A-L 열)
|
||||
for col in range(1, 13):
|
||||
ws.cell(row=card_start_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='medium'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
ws.cell(row=card_end_row, column=col).border = Border(
|
||||
left=Side(style='medium' if col == 1 else 'thin'),
|
||||
right=Side(style='medium' if col == 12 else 'thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='medium')
|
||||
)
|
||||
|
||||
# 카드 구분 (빈 행)
|
||||
current_row += 1
|
||||
|
||||
# 열 너비 조정
|
||||
ws.column_dimensions['A'].width = 12 # 레이블 열
|
||||
ws.column_dimensions['B'].width = 15 # 내용 열
|
||||
ws.column_dimensions['C'].width = 15 # 내용 열
|
||||
ws.column_dimensions['D'].width = 15 # 내용 열
|
||||
ws.column_dimensions['E'].width = 15 # 내용 열
|
||||
ws.column_dimensions['F'].width = 15 # 내용 열
|
||||
ws.column_dimensions['G'].width = 15 # 내용 열
|
||||
ws.column_dimensions['H'].width = 15 # 내용 열
|
||||
ws.column_dimensions['I'].width = 15 # 내용 열
|
||||
ws.column_dimensions['J'].width = 15 # 내용 열
|
||||
ws.column_dimensions['K'].width = 15 # 내용 열
|
||||
ws.column_dimensions['L'].width = 15 # 내용 열
|
||||
|
||||
# 엑셀 파일을 메모리에 저장
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
|
||||
# 추출 이력 업데이트
|
||||
export_time = datetime.now()
|
||||
for issue in issues:
|
||||
issue.last_exported_at = export_time
|
||||
issue.export_count = (issue.export_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
# 파일명 생성
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
|
||||
|
||||
# 한글 파일명을 위한 URL 인코딩
|
||||
from urllib.parse import quote
|
||||
encoded_filename = quote(filename.encode('utf-8'))
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(excel_buffer.read()),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
)
|
||||
|
||||
def calculate_project_stats(issues: List[Issue]) -> schemas.DailyReportStats:
|
||||
"""프로젝트 통계 계산"""
|
||||
stats = schemas.DailyReportStats()
|
||||
|
||||
today = date.today()
|
||||
|
||||
for issue in issues:
|
||||
stats.total_count += 1
|
||||
|
||||
if issue.review_status == ReviewStatus.in_progress:
|
||||
stats.management_count += 1
|
||||
|
||||
# 지연 여부 확인 (expected_completion_date가 오늘보다 이전인 경우)
|
||||
if issue.expected_completion_date:
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
stats.delayed_count += 1
|
||||
|
||||
elif issue.review_status == ReviewStatus.completed:
|
||||
stats.completed_count += 1
|
||||
|
||||
return stats
|
||||
|
||||
def get_category_text(category: IssueCategory) -> str:
|
||||
"""카테고리 한글 변환"""
|
||||
category_map = {
|
||||
IssueCategory.material_missing: "자재 누락",
|
||||
IssueCategory.design_error: "설계 미스",
|
||||
IssueCategory.incoming_defect: "입고 불량",
|
||||
IssueCategory.inspection_miss: "검사 미스",
|
||||
IssueCategory.etc: "기타"
|
||||
}
|
||||
return category_map.get(category, str(category))
|
||||
|
||||
def get_department_text(department) -> str:
|
||||
"""부서 한글 변환"""
|
||||
if not department:
|
||||
return ""
|
||||
|
||||
department_map = {
|
||||
"production": "생산",
|
||||
"quality": "품질",
|
||||
"purchasing": "구매",
|
||||
"design": "설계",
|
||||
"sales": "영업"
|
||||
}
|
||||
return department_map.get(department, str(department))
|
||||
|
||||
def get_status_text(status: ReviewStatus) -> str:
|
||||
"""상태 한글 변환"""
|
||||
status_map = {
|
||||
ReviewStatus.pending_review: "검토 대기",
|
||||
ReviewStatus.in_progress: "진행 중",
|
||||
ReviewStatus.completed: "완료됨",
|
||||
ReviewStatus.disposed: "폐기됨"
|
||||
}
|
||||
return status_map.get(status, str(status))
|
||||
|
||||
def clean_management_comment_for_export(text: str) -> str:
|
||||
"""엑셀 내보내기용 management_comment 정리 (완료 반려, 댓글 제거)"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. 완료 반려 패턴 제거 ([완료 반려 - 날짜시간] 내용)
|
||||
text = re.sub(r'\[완료 반려[^\]]*\][^\n]*\n*', '', text)
|
||||
|
||||
# 2. 댓글 패턴 제거 (└, ↳로 시작하는 줄들)
|
||||
lines = text.split('\n')
|
||||
filtered_lines = []
|
||||
for line in lines:
|
||||
# └ 또는 ↳로 시작하는 줄 제외
|
||||
if not re.match(r'^\s*[└↳]', line):
|
||||
filtered_lines.append(line)
|
||||
|
||||
# 3. 빈 줄 정리
|
||||
result = '\n'.join(filtered_lines).strip()
|
||||
# 연속된 빈 줄을 하나로
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
|
||||
return result
|
||||
|
||||
def get_status_color(status: ReviewStatus) -> str:
|
||||
"""상태별 색상 반환"""
|
||||
color_map = {
|
||||
ReviewStatus.in_progress: "FFF2CC", # 연한 노랑
|
||||
ReviewStatus.completed: "E2EFDA", # 연한 초록
|
||||
ReviewStatus.disposed: "F2F2F2" # 연한 회색
|
||||
}
|
||||
return color_map.get(status, None)
|
||||
|
||||
def get_issue_priority(issue: Issue) -> int:
|
||||
"""이슈 우선순위 반환 (엑셀 정렬용)
|
||||
1: 지연 (빨강)
|
||||
2: 진행중 (노랑)
|
||||
3: 완료됨 (초록)
|
||||
"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return 3
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
# 조치 예상일이 지난 경우 지연
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return 1 # 지연
|
||||
return 2 # 진행중
|
||||
return 2
|
||||
|
||||
def get_issue_status_text(issue: Issue) -> str:
|
||||
"""이슈 상태 텍스트 반환"""
|
||||
if issue.review_status == ReviewStatus.completed:
|
||||
return "완료됨"
|
||||
elif issue.review_status == ReviewStatus.in_progress:
|
||||
if issue.expected_completion_date:
|
||||
today = date.today()
|
||||
expected_date = issue.expected_completion_date.date() if isinstance(issue.expected_completion_date, datetime) else issue.expected_completion_date
|
||||
if expected_date < today:
|
||||
return "지연중"
|
||||
return "진행중"
|
||||
return get_status_text(issue.review_status)
|
||||
|
||||
def get_issue_status_header_color(issue: Issue) -> str:
|
||||
"""이슈 상태별 헤더 색상 반환"""
|
||||
priority = get_issue_priority(issue)
|
||||
if priority == 1: # 지연
|
||||
return "E74C3C" # 빨간색
|
||||
elif priority == 2: # 진행중
|
||||
return "F39C12" # 주황/노란색
|
||||
elif priority == 3: # 완료
|
||||
return "27AE60" # 초록색
|
||||
return "4472C4" # 기본 파란색
|
||||
Reference in New Issue
Block a user