""" 정기 PM (예방정비) 관리 API. 엔드포인트: Templates: GET /api/pm/templates — 체크리스트 템플릿 목록 POST /api/pm/templates — 템플릿 생성 PATCH /api/pm/templates/{id} — 템플릿 수정 DELETE /api/pm/templates/{id} — 템플릿 삭제 Schedules: GET /api/pm/schedules — PM 반복 스케줄 목록 POST /api/pm/schedules — 스케줄 등록 PATCH /api/pm/schedules/{id} — 스케줄 수정 DELETE /api/pm/schedules/{id} — 스케줄 삭제 POST /api/pm/schedules/{id}/trigger — 즉시 실행 (WorkTimetable 생성) Results: GET /api/pm/results/{timetable_id} — 점검 결과 목록 POST /api/pm/results/{timetable_id}/init — 체크리스트 초기화 (템플릿 → 결과 행 생성) PATCH /api/pm/results/{result_id} — 단일 결과 업데이트 GET /api/pm/results/{timetable_id}/report — Excel 보고서 다운로드 """ from __future__ import annotations import io import logging from datetime import datetime, date from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( Institution, Server, WorkTimetable, PmFrequency, PmItemResult, PmTemplate, PmTemplateCreate, PmTemplateOut, PmSchedule, PmScheduleCreate, PmScheduleOut, PmResult, PmResultOut, PmResultUpdate, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/pm", tags=["pm"]) RESULT_COLOR = { "PASS": "C6EFCE", "FAIL": "FFC7CE", "WARNING": "FFEB9C", "NA": "F4F4F4", } # ══════════════════════════════════════════════════════════════════════════════ # PM 체크리스트 템플릿 # ══════════════════════════════════════════════════════════════════════════════ @router.get("/templates", response_model=List[PmTemplateOut]) async def list_templates( server_role: Optional[str] = Query(None), category: Optional[str] = Query(None), active_only: bool = Query(True), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """체크리스트 템플릿 목록. server_role / category 필터 지원.""" q = select(PmTemplate) if active_only: q = q.where(PmTemplate.is_active.is_(True)) if server_role: q = q.where(PmTemplate.server_role == server_role) if category: q = q.where(PmTemplate.category == category) q = q.order_by(PmTemplate.server_role, PmTemplate.category, PmTemplate.item_order) result = await db.execute(q) return result.scalars().all() @router.post("/templates", response_model=PmTemplateOut, status_code=201) async def create_template( payload: PmTemplateCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.") tmpl = PmTemplate(**payload.model_dump()) db.add(tmpl) await db.commit() await db.refresh(tmpl) return tmpl @router.patch("/templates/{template_id}", response_model=PmTemplateOut) async def update_template( template_id: int, payload: PmTemplateCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.") r = await db.execute(select(PmTemplate).where(PmTemplate.id == template_id)) tmpl = r.scalars().first() if not tmpl: raise HTTPException(404, "템플릿을 찾을 수 없습니다.") for k, v in payload.model_dump(exclude_unset=True).items(): setattr(tmpl, k, v) await db.commit() await db.refresh(tmpl) return tmpl @router.delete("/templates/{template_id}", status_code=204) async def delete_template( template_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.") r = await db.execute(select(PmTemplate).where(PmTemplate.id == template_id)) tmpl = r.scalars().first() if not tmpl: raise HTTPException(404, "템플릿을 찾을 수 없습니다.") tmpl.is_active = False # 소프트 삭제 await db.commit() # ══════════════════════════════════════════════════════════════════════════════ # PM 반복 스케줄 # ══════════════════════════════════════════════════════════════════════════════ @router.get("/schedules", response_model=List[PmScheduleOut]) async def list_schedules( inst_id: Optional[int] = Query(None), active_only: bool = Query(True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): q = select(PmSchedule) if active_only: q = q.where(PmSchedule.is_active.is_(True)) if inst_id: q = q.where(PmSchedule.inst_id == inst_id) if current_user.role == UserRole.CUSTOMER and current_user.inst_code: r_i = await db.execute( select(Institution).where(Institution.inst_code == current_user.inst_code) ) own = r_i.scalars().first() if own: q = q.where(PmSchedule.inst_id == own.id) q = q.order_by(PmSchedule.next_scheduled) result = await db.execute(q) return result.scalars().all() @router.post("/schedules", response_model=PmScheduleOut, status_code=201) async def create_schedule( payload: PmScheduleCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.") if payload.frequency == PmFrequency.CUSTOM and not payload.cron_expr: raise HTTPException(422, "CUSTOM 주기에는 cron_expr이 필요합니다.") sched = PmSchedule( **payload.model_dump(), created_by=current_user.username, ) # next_scheduled 초기 계산 from core.scheduler import _calc_next sched.next_scheduled = _calc_next(sched, datetime.now()) db.add(sched) await db.commit() await db.refresh(sched) return sched @router.patch("/schedules/{schedule_id}", response_model=PmScheduleOut) async def update_schedule( schedule_id: int, payload: PmScheduleCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.") r = await db.execute(select(PmSchedule).where(PmSchedule.id == schedule_id)) sched = r.scalars().first() if not sched: raise HTTPException(404, "스케줄을 찾을 수 없습니다.") for k, v in payload.model_dump(exclude_unset=True).items(): setattr(sched, k, v) sched.updated_at = datetime.now() await db.commit() await db.refresh(sched) return sched @router.delete("/schedules/{schedule_id}", status_code=204) async def delete_schedule( schedule_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.") r = await db.execute(select(PmSchedule).where(PmSchedule.id == schedule_id)) sched = r.scalars().first() if not sched: raise HTTPException(404, "스케줄을 찾을 수 없습니다.") sched.is_active = False await db.commit() @router.post("/schedules/{schedule_id}/trigger", status_code=201) async def trigger_pm_now( schedule_id: int, scheduled_at: Optional[datetime] = Query(None, description="예정 일시 (미입력 시 즉시)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """PM 스케줄을 즉시 또는 지정 일시로 WorkTimetable에 등록.""" if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한이 없습니다.") r = await db.execute(select(PmSchedule).where(PmSchedule.id == schedule_id)) sched = r.scalars().first() if not sched: raise HTTPException(404, "스케줄을 찾을 수 없습니다.") target_dt = scheduled_at or datetime.now() wt = WorkTimetable( work_type = "PM", title = f"{sched.schedule_name} PM (수동 실행)", inst_id = sched.inst_id, server_id = sched.server_id, scheduled_at = target_dt, content = ( f"PM 수동 실행\n" f"스케줄: {sched.schedule_name} ({sched.frequency})\n" f"담당자: {sched.assignee or '미지정'}" ), result_status = "PENDING", assignee = sched.assignee, reviewer = sched.reviewer, created_by = current_user.username, ) db.add(wt) sched.last_generated = datetime.now() await db.commit() await db.refresh(wt) # 템플릿 ID가 설정된 경우 PmResult 행 초기화 timetable_id = wt.id if sched.template_ids: template_id_list = [ int(t.strip()) for t in sched.template_ids.split(",") if t.strip().isdigit() ] if template_id_list: tmpl_result = await db.execute( select(PmTemplate).where( PmTemplate.id.in_(template_id_list), PmTemplate.is_active.is_(True), ).order_by(PmTemplate.item_order) ) templates = tmpl_result.scalars().all() for tmpl in templates: pr = PmResult( timetable_id = timetable_id, template_id = tmpl.id, item_title = tmpl.item_title, item_desc = tmpl.item_desc, check_command = tmpl.check_command, result = PmItemResult.NA, ) db.add(pr) await db.commit() return { "timetable_id": timetable_id, "scheduled_at": target_dt.isoformat(), "message": f"PM WorkTimetable 생성 완료 (ID: {timetable_id})", } # ══════════════════════════════════════════════════════════════════════════════ # PM 점검 결과 (체크리스트) # ══════════════════════════════════════════════════════════════════════════════ @router.get("/results/{timetable_id}", response_model=List[PmResultOut]) async def get_results( timetable_id: int, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """타임테이블 ID에 해당하는 PM 체크리스트 항목 목록.""" result = await db.execute( select(PmResult) .where(PmResult.timetable_id == timetable_id) .order_by(PmResult.id) ) return result.scalars().all() @router.post("/results/{timetable_id}/init", status_code=201) async def init_checklist( timetable_id: int, server_role: Optional[str] = Query(None, description="템플릿 필터 — 서버 역할"), category: Optional[str] = Query(None, description="템플릿 필터 — 카테고리"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ 템플릿에서 PmResult 행을 일괄 초기화. 이미 결과가 있으면 건너뜀. """ if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한이 없습니다.") # WorkTimetable 확인 wt = (await db.execute( select(WorkTimetable).where(WorkTimetable.id == timetable_id) )).scalars().first() if not wt: raise HTTPException(404, "타임테이블을 찾을 수 없습니다.") # 기존 결과 있으면 건너뜀 existing = (await db.execute( select(PmResult).where(PmResult.timetable_id == timetable_id) )).scalars().all() if existing: return {"created": 0, "message": "이미 체크리스트가 초기화되어 있습니다.", "total": len(existing)} # 템플릿 조회 q = select(PmTemplate).where(PmTemplate.is_active.is_(True)) if server_role: q = q.where(PmTemplate.server_role == server_role) if category: q = q.where(PmTemplate.category == category) q = q.order_by(PmTemplate.item_order) templates = (await db.execute(q)).scalars().all() for tmpl in templates: pr = PmResult( timetable_id = timetable_id, template_id = tmpl.id, item_title = tmpl.item_title, item_desc = tmpl.item_desc, check_command = tmpl.check_command, result = PmItemResult.NA, ) db.add(pr) await db.commit() return { "created": len(templates), "message": f"{len(templates)}개 체크리스트 항목이 초기화되었습니다.", "total": len(templates), } @router.patch("/results/{result_id}", response_model=PmResultOut) async def update_result( result_id: int, payload: PmResultUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """단일 PM 점검 항목 결과 업데이트.""" if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한이 없습니다.") r = await db.execute(select(PmResult).where(PmResult.id == result_id)) item = r.scalars().first() if not item: raise HTTPException(404, "점검 결과 항목을 찾을 수 없습니다.") for k, v in payload.model_dump(exclude_unset=True).items(): setattr(item, k, v) item.checked_by = current_user.username item.checked_at = datetime.now() await db.commit() await db.refresh(item) return item @router.get("/results/{timetable_id}/report") async def download_report( timetable_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """PM 점검 보고서 Excel 다운로드.""" if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한이 없습니다.") try: from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter except ImportError: raise HTTPException(500, "openpyxl 라이브러리가 필요합니다. pip install openpyxl") # 타임테이블 정보 wt = (await db.execute( select(WorkTimetable).where(WorkTimetable.id == timetable_id) )).scalars().first() if not wt: raise HTTPException(404, "타임테이블을 찾을 수 없습니다.") results = (await db.execute( select(PmResult).where(PmResult.timetable_id == timetable_id).order_by(PmResult.id) )).scalars().all() # 기관/서버명 조회 inst_name = "" if wt.inst_id: inst = (await db.execute( select(Institution).where(Institution.id == wt.inst_id) )).scalars().first() if inst: inst_name = inst.inst_name server_name = "" if wt.server_id: srv = (await db.execute( select(Server).where(Server.id == wt.server_id) )).scalars().first() if srv: server_name = srv.server_name # Excel 생성 wb = Workbook() ws = wb.active ws.title = "PM 점검결과" ws.freeze_panes = "A3" hdr_fill = PatternFill("solid", fgColor="1E3A5F") hdr_font = Font(name="맑은 고딕", bold=True, color="FFFFFF", size=10) thin = Side(style="thin", color="CCCCCC") bdr = Border(left=thin, right=thin, top=thin, bottom=thin) # 타이틀 행 ws.merge_cells("A1:H1") title_cell = ws["A1"] title_cell.value = ( f"GUARDiA ITSM — PM 점검 결과 | {wt.title} | " f"{wt.scheduled_at.strftime('%Y-%m-%d') if wt.scheduled_at else ''}" ) title_cell.font = Font(name="맑은 고딕", bold=True, size=12) title_cell.alignment = Alignment(horizontal="center", vertical="center") ws.row_dimensions[1].height = 25 # 헤더 headers = [ ("No", 5), ("카테고리", 14), ("점검 항목", 35), ("확인 명령어", 30), ("기준값", 18), ("실제값", 20), ("결과", 10), ("비고", 25), ] for ci, (h, w) in enumerate(headers, 1): cell = ws.cell(2, ci, h) cell.font = hdr_font; cell.fill = hdr_fill cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = bdr ws.column_dimensions[get_column_letter(ci)].width = w ws.row_dimensions[2].height = 20 # 데이터 행 for ri, item in enumerate(results, 3): tmpl_cat = "" if item.template_id: tmpl_obj = (await db.execute( select(PmTemplate).where(PmTemplate.id == item.template_id) )).scalars().first() if tmpl_obj: tmpl_cat = tmpl_obj.category or "" row_vals = [ ri - 2, tmpl_cat, item.item_title, item.check_command or "", (await db.execute( select(PmTemplate.expected_value).where(PmTemplate.id == item.template_id) )).scalar_one_or_none() if item.template_id else "", item.actual_value or "", item.result or "NA", item.result_note or "", ] result_fill_color = RESULT_COLOR.get(item.result or "NA", "F4F4F4") for ci, val in enumerate(row_vals, 1): cell = ws.cell(ri, ci, val) cell.border = bdr cell.alignment = Alignment( horizontal="center" if ci in (1, 7) else "left", vertical="center", wrap_text=True, ) if ci == 7: cell.fill = PatternFill("solid", fgColor=result_fill_color) # 통계 ws.append([]) pass_cnt = sum(1 for r in results if r.result == PmItemResult.PASS) fail_cnt = sum(1 for r in results if r.result == PmItemResult.FAIL) warn_cnt = sum(1 for r in results if r.result == PmItemResult.WARNING) na_cnt = sum(1 for r in results if r.result == PmItemResult.NA) summary_row = ws.max_row + 1 ws.cell(summary_row, 1, "합계") ws.cell(summary_row, 2, f"총 {len(results)}건") ws.cell(summary_row, 3, f"PASS: {pass_cnt} FAIL: {fail_cnt} WARNING: {warn_cnt} NA: {na_cnt}") for ci in range(1, 4): ws.cell(summary_row, ci).font = Font(name="맑은 고딕", bold=True) # 메타 시트 ws2 = wb.create_sheet("점검정보") meta = [ ("타임테이블 ID", timetable_id), ("작업명", wt.title), ("기관", inst_name), ("서버", server_name), ("예정일", wt.scheduled_at.strftime("%Y-%m-%d") if wt.scheduled_at else ""), ("담당자", wt.assignee or ""), ("검토자", wt.reviewer or ""), ("결과상태", wt.result_status), ] for r, (k, v) in enumerate(meta, 1): ws2.cell(r, 1, k).font = Font(bold=True) ws2.cell(r, 2, str(v)) buf = io.BytesIO() wb.save(buf) buf.seek(0) from urllib.parse import quote fname = f"PM점검결과_{wt.scheduled_at.strftime('%Y%m%d') if wt.scheduled_at else 'NO_DATE'}.xlsx" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(fname)}"}, )