## 처리시간 지표 - 업무시간 기준(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>
213 lines
9.6 KiB
Python
213 lines
9.6 KiB
Python
import os
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.orm import Session
|
|
from typing import List, Optional
|
|
from database import get_db
|
|
import models
|
|
from auth import require_admin, get_current_user
|
|
from utils import generate_qr
|
|
|
|
router = APIRouter(prefix="/api/chargers", tags=["chargers"])
|
|
|
|
# ── 충전기 종류 ──────────────────────────────────────
|
|
@router.get("/types")
|
|
def list_types(db: Session = Depends(get_db)):
|
|
types = db.query(models.ChargerType).order_by(models.ChargerType.id).all()
|
|
return [{"id": t.id, "name": t.name, "description": t.description,
|
|
"charger_count": len(t.chargers)} for t in types]
|
|
|
|
@router.post("/types")
|
|
def create_type(name: str = Form(...), description: str = Form(""),
|
|
db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
t = models.ChargerType(name=name, description=description)
|
|
db.add(t); db.commit(); db.refresh(t)
|
|
return {"id": t.id, "name": t.name}
|
|
|
|
@router.put("/types/{type_id}")
|
|
def update_type(type_id: int, name: str = Form(...), description: str = Form(""),
|
|
db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
t = db.query(models.ChargerType).filter_by(id=type_id).first()
|
|
if not t: raise HTTPException(404, "종류를 찾을 수 없습니다.")
|
|
t.name = name; t.description = description
|
|
db.commit()
|
|
return {"id": t.id, "name": t.name}
|
|
|
|
@router.delete("/types/{type_id}")
|
|
def delete_type(type_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
t = db.query(models.ChargerType).filter_by(id=type_id).first()
|
|
if not t: raise HTTPException(404)
|
|
if t.chargers: raise HTTPException(400, "해당 종류로 등록된 충전기가 있어 삭제할 수 없습니다.")
|
|
db.delete(t); db.commit()
|
|
return {"ok": True}
|
|
|
|
# ── 충전기 종류별 에러 코드 ──────────────────────────
|
|
@router.get("/types/{type_id}/errors")
|
|
def list_type_errors(type_id: int, db: Session = Depends(get_db)):
|
|
errors = (db.query(models.ChargerTypeError)
|
|
.filter_by(charger_type_id=type_id)
|
|
.order_by(models.ChargerTypeError.display_order)
|
|
.all())
|
|
return [{"id": e.id, "error_code": e.error_code, "error_name": e.error_name,
|
|
"range_condition": e.range_condition, "description": e.description,
|
|
"auto_recovery": e.auto_recovery, "display_order": e.display_order}
|
|
for e in errors]
|
|
|
|
@router.post("/types/{type_id}/errors")
|
|
def create_type_error(
|
|
type_id: int,
|
|
error_code: str = Form(...), error_name: str = Form(...),
|
|
range_condition: str = Form(""), description: str = Form(""),
|
|
auto_recovery: bool = Form(True), display_order: int = Form(0),
|
|
db: Session = Depends(get_db), _=Depends(require_admin)
|
|
):
|
|
if not db.query(models.ChargerType).filter_by(id=type_id).first():
|
|
raise HTTPException(404)
|
|
e = models.ChargerTypeError(
|
|
charger_type_id=type_id, error_code=error_code, error_name=error_name,
|
|
range_condition=range_condition or None, description=description or None,
|
|
auto_recovery=auto_recovery, display_order=display_order
|
|
)
|
|
db.add(e); db.commit(); db.refresh(e)
|
|
return {"id": e.id}
|
|
|
|
@router.put("/types/{type_id}/errors/{error_id}")
|
|
def update_type_error(
|
|
type_id: int, error_id: int,
|
|
error_code: str = Form(...), error_name: str = Form(...),
|
|
range_condition: str = Form(""), description: str = Form(""),
|
|
auto_recovery: bool = Form(True), display_order: int = Form(0),
|
|
db: Session = Depends(get_db), _=Depends(require_admin)
|
|
):
|
|
e = db.query(models.ChargerTypeError).filter_by(id=error_id, charger_type_id=type_id).first()
|
|
if not e: raise HTTPException(404)
|
|
e.error_code = error_code; e.error_name = error_name
|
|
e.range_condition = range_condition or None; e.description = description or None
|
|
e.auto_recovery = auto_recovery; e.display_order = display_order
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
@router.delete("/types/{type_id}/errors/{error_id}")
|
|
def delete_type_error(
|
|
type_id: int, error_id: int,
|
|
db: Session = Depends(get_db), _=Depends(require_admin)
|
|
):
|
|
e = db.query(models.ChargerTypeError).filter_by(id=error_id, charger_type_id=type_id).first()
|
|
if not e: raise HTTPException(404)
|
|
db.delete(e); db.commit()
|
|
return {"ok": True}
|
|
|
|
# ── 충전기 ──────────────────────────────────────────
|
|
@router.delete("/bulk")
|
|
def bulk_delete_chargers(
|
|
ids: List[str] = Body(...),
|
|
db: Session = Depends(get_db),
|
|
_=Depends(require_admin)
|
|
):
|
|
if not ids:
|
|
raise HTTPException(400, "삭제할 항목을 선택하세요.")
|
|
has_reports = db.query(models.Report.charger_id).filter(
|
|
models.Report.charger_id.in_(ids)).distinct().all()
|
|
if has_reports:
|
|
blocked = [r[0] for r in has_reports]
|
|
raise HTTPException(400, f"신고 내역이 있는 충전기는 삭제할 수 없습니다: {', '.join(blocked)}")
|
|
result = db.query(models.Charger).filter(models.Charger.id.in_(ids)).delete(synchronize_session=False)
|
|
db.commit()
|
|
return {"deleted": result}
|
|
|
|
@router.get("")
|
|
def list_chargers(db: Session = Depends(get_db)):
|
|
chargers = db.query(models.Charger).order_by(models.Charger.id).all()
|
|
result = []
|
|
for c in chargers:
|
|
pending = db.query(models.Report).filter(
|
|
models.Report.charger_id == c.id,
|
|
models.Report.status.in_(["pending", "in_progress"])
|
|
).count()
|
|
result.append({
|
|
"id": c.id, "name": c.name, "station_name": c.station_name,
|
|
"cpo_name": c.cpo_name, "location_detail": c.location_detail,
|
|
"installed_at": str(c.installed_at) if c.installed_at else None,
|
|
"gps_lat": c.gps_lat, "gps_lng": c.gps_lng, "is_active": c.is_active,
|
|
"charger_type": c.charger_type.name if c.charger_type else None,
|
|
"charger_type_id": c.charger_type_id,
|
|
"pending_reports": pending,
|
|
})
|
|
return result
|
|
|
|
@router.get("/{charger_id}/errors")
|
|
def get_charger_errors(charger_id: str, db: Session = Depends(get_db)):
|
|
c = db.query(models.Charger).filter_by(id=charger_id).first()
|
|
if not c: raise HTTPException(404)
|
|
if not c.charger_type_id: return []
|
|
errors = (db.query(models.ChargerTypeError)
|
|
.filter_by(charger_type_id=c.charger_type_id)
|
|
.order_by(models.ChargerTypeError.display_order)
|
|
.all())
|
|
return [{"id": e.id, "error_code": e.error_code, "error_name": e.error_name,
|
|
"range_condition": e.range_condition, "auto_recovery": e.auto_recovery}
|
|
for e in errors]
|
|
|
|
@router.get("/{charger_id}")
|
|
def get_charger(charger_id: str, db: Session = Depends(get_db)):
|
|
c = db.query(models.Charger).filter_by(id=charger_id).first()
|
|
if not c: raise HTTPException(404, "충전기를 찾을 수 없습니다.")
|
|
return {
|
|
"id": c.id, "name": c.name, "station_name": c.station_name,
|
|
"cpo_name": c.cpo_name, "location_detail": c.location_detail,
|
|
"installed_at": str(c.installed_at) if c.installed_at else None,
|
|
"gps_lat": c.gps_lat, "gps_lng": c.gps_lng, "is_active": c.is_active,
|
|
"charger_type": c.charger_type.name if c.charger_type else None,
|
|
"charger_type_id": c.charger_type_id,
|
|
}
|
|
|
|
@router.post("")
|
|
def create_charger(
|
|
id: str = Form(...), charger_type_id: int = Form(...),
|
|
name: str = Form(...), station_name: str = Form(...),
|
|
location_detail: str = Form(""), cpo_name: str = Form(""),
|
|
installed_at: Optional[str] = Form(None),
|
|
gps_lat: Optional[float] = Form(None), gps_lng: Optional[float] = Form(None),
|
|
db: Session = Depends(get_db), _=Depends(require_admin)
|
|
):
|
|
if db.query(models.Charger).filter_by(id=id).first():
|
|
raise HTTPException(400, "이미 존재하는 충전기 ID입니다.")
|
|
c = models.Charger(
|
|
id=id, charger_type_id=charger_type_id, name=name,
|
|
station_name=station_name, location_detail=location_detail,
|
|
cpo_name=cpo_name, installed_at=installed_at or None,
|
|
gps_lat=gps_lat, gps_lng=gps_lng
|
|
)
|
|
db.add(c); db.commit()
|
|
domain = os.getenv("DOMAIN", "localhost")
|
|
qr_path = generate_qr(id, domain)
|
|
return {"id": c.id, "qr_path": qr_path}
|
|
|
|
@router.put("/{charger_id}")
|
|
def update_charger(
|
|
charger_id: str,
|
|
charger_type_id: int = Form(...), name: str = Form(...),
|
|
station_name: str = Form(...), location_detail: str = Form(""),
|
|
cpo_name: str = Form(""), installed_at: Optional[str] = Form(None),
|
|
gps_lat: Optional[float] = Form(None), gps_lng: Optional[float] = Form(None),
|
|
db: Session = Depends(get_db), _=Depends(require_admin)
|
|
):
|
|
c = db.query(models.Charger).filter_by(id=charger_id).first()
|
|
if not c: raise HTTPException(404)
|
|
c.charger_type_id = charger_type_id; c.name = name
|
|
c.station_name = station_name; c.location_detail = location_detail
|
|
c.cpo_name = cpo_name; c.installed_at = installed_at or None
|
|
c.gps_lat = gps_lat; c.gps_lng = gps_lng
|
|
db.commit()
|
|
domain = os.getenv("DOMAIN", "localhost")
|
|
qr_path = generate_qr(charger_id, domain)
|
|
return {"id": c.id, "qr_path": qr_path}
|
|
|
|
@router.post("/{charger_id}/qr")
|
|
def regenerate_qr(charger_id: str, db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
c = db.query(models.Charger).filter_by(id=charger_id).first()
|
|
if not c: raise HTTPException(404)
|
|
domain = os.getenv("DOMAIN", "localhost")
|
|
qr_path = generate_qr(charger_id, domain)
|
|
return {"qr_path": qr_path}
|