Files
ev-charger-as/backend/routers/repairs.py
byun 2e8751ea6c 기능 추가 및 버그 수정 — 처리시간 지표, 대시보드 차트, UI 개선
## 처리시간 지표
- 업무시간 기준(09-18 평일) / 공휴일 제외 24h / 달력 기준 3가지 모드 선택
- 공휴일 DB 관리 (holidays 테이블, 수동 등록·삭제·일괄 추가)
- 2026년 공휴일 등록 지원
- 설정 페이지에서 라디오 버튼으로 모드 선택

## 대시보드 차트
- 월별 평균 처리시간 막대 차트 추가
- 월별 신고 접수 건수 누적 막대 차트 추가
- 월별 → 일별 드릴다운 (막대 클릭 시 해당 월의 일별 차트로 전환)
- 일별 막대 클릭 시 처리 완료/신고 접수 상세 내역 모달
- 충전기별 누적 고장 건수 Top 10 수평 막대 차트 추가

## 신고 목록
- # 컬럼을 DB PK 대신 현재 목록 순서(1, 2, 3…)로 표시
- 엑셀 export 접수번호도 순차번호로 변경

## 모바일 네비게이션 버그 수정
- 모바일에서 가로 오버플로우 시 nav가 body 넓이로 늘어나
  햄버거 버튼이 화면 밖으로 밀리는 문제 수정
- nav를 position:fixed + body padding-top:54px 로 변경 (전체 페이지 적용)
- 충전기 관리·신고 목록 페이지 지도 컨테이너에 isolation:isolate 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 06:52:56 +09:00

296 lines
12 KiB
Python

import json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from datetime import datetime
from database import get_db
import models
from auth import require_mechanic, require_admin, get_current_user
from utils import save_upload
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
STATUS_MAP = {
"done": "done",
"waiting": "waiting",
"revisit": "revisit",
"in_progress": "in_progress",
}
def _fmt_repair(repair: models.Repair) -> dict:
reports = []
charger_id = None
station_name = None
charger_name = None
for link in repair.report_links:
r = link.report
if r:
if not charger_id and r.charger:
charger_id = r.charger_id
station_name = r.charger.station_name
charger_name = r.charger.name
reports.append({
"id": r.id,
"charger_id": r.charger_id,
"issue_types": r.issue_types,
"status": r.status,
})
return {
"id": repair.id,
"charger_id": charger_id,
"charger_name": charger_name,
"station_name": station_name,
"repair_types": repair.repair_types,
"description": repair.description,
"result_status": repair.result_status,
"mechanic_lat": repair.mechanic_lat,
"mechanic_lng": repair.mechanic_lng,
"started_at": repair.started_at.isoformat(),
"completed_at": repair.completed_at.isoformat() if repair.completed_at else None,
"approved_at": repair.approved_at.isoformat() if repair.approved_at else None,
"approved_by_name": repair.approver.name if repair.approved_by and repair.approver else None,
"photos_before": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "before"],
"photos_after": [{"id": p.id, "path": p.file_path} for p in repair.photos if p.photo_type == "after"],
"reports": reports,
"report_count": len(reports),
}
@router.get("/pending")
def pending_reports(db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
"""정비사용: 처리 가능한 신고 목록 (pending / in_progress)"""
q = db.query(models.Report).filter(
models.Report.status.in_(["pending", "in_progress"])
).order_by(desc(models.Report.reported_at))
result = []
for r in q.all():
c = r.charger
# in_progress 신고는 연결된 repair_id 포함 → 편집 모드로 연결
repair_id = None
if r.repair_links:
repair_id = r.repair_links[0].repair_id
result.append({
"id": r.id, "charger_id": r.charger_id,
"charger_name": c.name if c else None,
"station_name": c.station_name if c else None,
"charger_type": c.charger_type.name if c and c.charger_type else None,
"issue_types": r.issue_types, "status": r.status,
"reported_at": r.reported_at.isoformat(),
"occurred_at": r.occurred_at.isoformat() if r.occurred_at else None,
"repair_id": repair_id,
"gps_lat": c.gps_lat if c else None,
"gps_lng": c.gps_lng if c else None,
"location_detail": c.location_detail if c else None,
})
return result
@router.get("/my")
def my_repairs(db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)):
repairs = db.query(models.Repair).filter_by(
mechanic_id=current_user.id
).order_by(desc(models.Repair.completed_at)).limit(100).all()
return [_fmt_repair(r) for r in repairs]
@router.get("/charger/{charger_id}/open")
def open_reports_for_charger(charger_id: str, db: Session = Depends(get_db),
_=Depends(require_mechanic)):
"""특정 충전기의 미처리 신고 목록 (중복처리용)"""
reports = db.query(models.Report).filter(
models.Report.charger_id == charger_id,
models.Report.status.in_(["pending", "in_progress"])
).order_by(models.Report.reported_at).all()
return [{
"id": r.id, "issue_types": r.issue_types,
"issue_detail": r.issue_detail, "status": r.status,
"reported_at": r.reported_at.isoformat(),
"photos": [p.file_path for p in r.photos],
} for r in reports]
@router.get("/{repair_id}")
def get_repair(repair_id: int, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
if repair.mechanic_id != current_user.id and current_user.role != "admin":
raise HTTPException(403, "접근 권한이 없습니다.")
return _fmt_repair(repair)
@router.post("")
async def create_repair(
report_ids: str = Form(...),
repair_types: str = Form(...),
description: str = Form(...),
result_status: str = Form("done"),
mechanic_lat: Optional[float] = Form(None),
mechanic_lng: Optional[float] = Form(None),
photos_before: List[UploadFile] = File(default=[]),
photos_after: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)
):
rids = json.loads(report_ids)
repair = models.Repair(
mechanic_id=current_user.id,
repair_types=json.loads(repair_types),
description=description,
started_at=datetime.now(),
completed_at=datetime.now(),
result_status=result_status,
mechanic_lat=mechanic_lat,
mechanic_lng=mechanic_lng,
)
db.add(repair); db.commit(); db.refresh(repair)
for rid in rids:
r = db.query(models.Report).filter_by(id=rid).first()
if r:
r.status = STATUS_MAP.get(result_status, "in_progress")
db.add(models.RepairReport(repair_id=repair.id, report_id=rid))
for photo in photos_before:
if photo.filename:
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="before",
file_path=save_upload(photo, f"repairs/{repair.id}")))
for photo in photos_after:
if photo.filename:
db.add(models.RepairPhoto(repair_id=repair.id, photo_type="after",
file_path=save_upload(photo, f"repairs/{repair.id}")))
db.commit()
return {"id": repair.id}
@router.put("/{repair_id}")
async def update_repair(
repair_id: int,
repair_types: str = Form(...),
description: str = Form(...),
result_status: str = Form("done"),
mechanic_lat: Optional[float] = Form(None),
mechanic_lng: Optional[float] = Form(None),
photos_before: List[UploadFile] = File(default=[]),
photos_after: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)
):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
if repair.mechanic_id != current_user.id:
raise HTTPException(403, "본인 조치 이력만 수정할 수 있습니다.")
if repair.approved_at:
raise HTTPException(403, "관리자가 승인한 조치는 수정할 수 없습니다.")
repair.repair_types = json.loads(repair_types)
repair.description = description
repair.result_status = result_status
repair.completed_at = datetime.now()
if mechanic_lat is not None: repair.mechanic_lat = mechanic_lat
if mechanic_lng is not None: repair.mechanic_lng = mechanic_lng
for link in repair.report_links:
r = link.report
if r: r.status = STATUS_MAP.get(result_status, "in_progress")
for photo in photos_before:
if photo.filename:
db.add(models.RepairPhoto(repair_id=repair_id, photo_type="before",
file_path=save_upload(photo, f"repairs/{repair_id}")))
for photo in photos_after:
if photo.filename:
db.add(models.RepairPhoto(repair_id=repair_id, photo_type="after",
file_path=save_upload(photo, f"repairs/{repair_id}")))
db.commit()
return {"ok": True}
@router.post("/{repair_id}/approve")
def approve_repair(
repair_id: int,
improvement_action: str = Form("none"), # none | link | create
improvement_id: Optional[int] = Form(None),
imp_title: str = Form(""),
imp_category: str = Form(""),
imp_description: str = Form(""),
imp_priority: str = Form("normal"),
imp_manufacturer_id: Optional[int] = Form(None),
db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin)
):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
repair.approved_at = datetime.now()
repair.approved_by = current_user.id
target_imp_id = None
if improvement_action == "link" and improvement_id:
target_imp_id = improvement_id
elif improvement_action == "create":
if not imp_title or not imp_category or not imp_description:
raise HTTPException(400, "개선항목 제목, 분류, 내용을 모두 입력해 주세요.")
imp = models.Improvement(
title=imp_title, category=imp_category,
description=imp_description, priority=imp_priority,
manufacturer_id=imp_manufacturer_id or None,
created_by=current_user.id,
)
db.add(imp); db.flush()
db.add(models.ImprovementLog(
improvement_id=imp.id, changed_by=current_user.id,
old_status=None, new_status="registered",
memo=f"조치 승인 시 생성 (수리 #{repair_id})"
))
target_imp_id = imp.id
if target_imp_id:
for link in repair.report_links:
exists = db.query(models.ImprovementReport).filter_by(
improvement_id=target_imp_id, report_id=link.report_id
).first()
if not exists:
db.add(models.ImprovementReport(
improvement_id=target_imp_id, report_id=link.report_id
))
db.commit()
return {"ok": True, "improvement_id": target_imp_id}
@router.delete("/{repair_id}")
def cancel_repair(
repair_id: int,
db: Session = Depends(get_db),
_=Depends(require_admin)
):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
# 연결된 신고를 접수(pending) 상태로 되돌림
for link in repair.report_links:
if link.report:
link.report.status = "pending"
db.delete(repair) # cascade: RepairReport, RepairPhoto 자동 삭제
db.commit()
return {"ok": True}
@router.delete("/{repair_id}/photos/{photo_id}")
def delete_repair_photo(repair_id: int, photo_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_mechanic)):
repair = db.query(models.Repair).filter_by(id=repair_id).first()
if not repair: raise HTTPException(404)
if repair.mechanic_id != current_user.id and current_user.role != "admin":
raise HTTPException(403)
if repair.approved_at and current_user.role != "admin":
raise HTTPException(403, "승인된 조치는 수정할 수 없습니다.")
photo = db.query(models.RepairPhoto).filter_by(id=photo_id, repair_id=repair_id).first()
if not photo: raise HTTPException(404)
db.delete(photo); db.commit()
return {"ok": True}