## 처리시간 지표 - 업무시간 기준(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>
263 lines
11 KiB
Python
263 lines
11 KiB
Python
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import desc
|
|
from io import BytesIO
|
|
from datetime import datetime
|
|
from urllib.parse import quote
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
from database import get_db
|
|
import models
|
|
from auth import require_admin
|
|
|
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
|
|
|
NAVY = "0B1E3D"
|
|
LIGHT = "D6EAF8"
|
|
|
|
def style_header(ws, headers, row=1):
|
|
bd = Side(style="thin", color="AAAAAA")
|
|
for col, h in enumerate(headers, 1):
|
|
cell = ws.cell(row=row, column=col, value=h)
|
|
cell.font = Font(bold=True, color="FFFFFF", size=11)
|
|
cell.fill = PatternFill("solid", fgColor=NAVY)
|
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
|
ws.row_dimensions[row].height = 20
|
|
|
|
def style_row(ws, row_num, num_cols, even=True):
|
|
bd = Side(style="thin", color="DDDDDD")
|
|
for col in range(1, num_cols + 1):
|
|
cell = ws.cell(row=row_num, column=col)
|
|
if even:
|
|
cell.fill = PatternFill("solid", fgColor="F4F7FB")
|
|
cell.border = Border(left=bd, right=bd, top=bd, bottom=bd)
|
|
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
|
|
|
def fmt_dt(dt):
|
|
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
|
|
|
|
def fmt_d(d):
|
|
return str(d) if d else ""
|
|
|
|
def elapsed(start, end):
|
|
if not start or not end: return ""
|
|
diff = end - start
|
|
total = int(diff.total_seconds())
|
|
h, m = divmod(total // 60, 60)
|
|
return f"{h}시간 {m}분"
|
|
|
|
def make_response(wb: openpyxl.Workbook, korean_name: str) -> StreamingResponse:
|
|
"""엑셀 파일을 StreamingResponse로 반환 — 한글 파일명 URL 인코딩 처리"""
|
|
buf = BytesIO()
|
|
wb.save(buf)
|
|
buf.seek(0)
|
|
date_str = datetime.now().strftime("%Y%m%d_%H%M")
|
|
filename = f"{korean_name}_{date_str}.xlsx"
|
|
encoded = quote(filename, safe="") # 한글 URL 인코딩
|
|
cd_header = f"attachment; filename*=UTF-8''{encoded}"
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": cd_header},
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 1. AS 신고 목록
|
|
# ─────────────────────────────────────────────
|
|
@router.get("/reports")
|
|
def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "AS신고목록"
|
|
ws.freeze_panes = "A2"
|
|
|
|
headers = [
|
|
"접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일",
|
|
"신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명",
|
|
"신고자연락처","문제발생시각","신고일시","신고출처","신고자","처리상태",
|
|
"담당정비사","정비사소속","조치유형","조치내용",
|
|
"조치시작","조치완료","작업소요시간","신고→완료소요시간",
|
|
"문제원인(관리자)","비고","출장비부담주체","출장비금액(원)","출장비상태",
|
|
"처리담당자","처리일시","연결개선항목번호"
|
|
]
|
|
style_header(ws, headers)
|
|
|
|
col_widths = [10,14,14,14,18,14,12,12,12,22,12,24,14,16,16,10,16,12,
|
|
12,14,16,24,16,16,12,18,24,24,16,12,12,12,16,18]
|
|
for i, w in enumerate(col_widths, 1):
|
|
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
|
|
|
reports = db.query(models.Report).order_by(desc(models.Report.reported_at)).all()
|
|
for row_num, r in enumerate(reports, 2):
|
|
c = r.charger
|
|
repair = r.repair_links[0].repair if r.repair_links else None
|
|
cost = repair.cost if repair else None
|
|
imp_ids = [
|
|
ir.improvement_id
|
|
for ir in db.query(models.ImprovementReport).filter_by(report_id=r.id).all()
|
|
]
|
|
seq_no = row_num - 1 # 순차번호 (1부터 시작)
|
|
|
|
row_data = [
|
|
seq_no,
|
|
r.charger_id,
|
|
c.charger_type.name if c and c.charger_type else "",
|
|
c.name if c else "",
|
|
c.station_name if c else "",
|
|
c.cpo_name if c else "",
|
|
fmt_d(c.installed_at) if c else "",
|
|
r.gps_lat or "",
|
|
r.gps_lng or "",
|
|
", ".join(r.issue_types) if r.issue_types else "",
|
|
r.error_code or "",
|
|
r.issue_detail or "",
|
|
r.contact or "",
|
|
fmt_dt(r.occurred_at),
|
|
fmt_dt(r.reported_at),
|
|
{"qr": "QR스캔", "admin": "관리자접수", "dashboard": "대시보드접수"}.get(r.source or "qr", r.source or "qr"),
|
|
r.reporter.name if r.reporter else "",
|
|
r.status,
|
|
repair.mechanic.name if repair and repair.mechanic else "",
|
|
repair.mechanic.company if repair and repair.mechanic else "",
|
|
", ".join(repair.repair_types) if repair and repair.repair_types else "",
|
|
repair.description if repair else "",
|
|
fmt_dt(repair.started_at) if repair else "",
|
|
fmt_dt(repair.completed_at) if repair else "",
|
|
elapsed(repair.started_at, repair.completed_at) if repair else "",
|
|
elapsed(r.occurred_at or r.reported_at, repair.completed_at if repair else None),
|
|
cost.root_cause if cost else "",
|
|
cost.admin_note if cost else "",
|
|
cost.cost_party_type if cost else "",
|
|
cost.cost_amount if cost else "",
|
|
cost.cost_status if cost else "",
|
|
cost.reviewer.name if cost and cost.reviewer else "",
|
|
fmt_dt(cost.reviewed_at) if cost else "",
|
|
", ".join(str(i) for i in imp_ids) if imp_ids else "",
|
|
]
|
|
for col, val in enumerate(row_data, 1):
|
|
ws.cell(row=row_num, column=col, value=val)
|
|
style_row(ws, row_num, len(headers), row_num % 2 == 0)
|
|
ws.row_dimensions[row_num].height = 16
|
|
|
|
return make_response(wb, "AS신고목록")
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 2. 출장비 목록
|
|
# ─────────────────────────────────────────────
|
|
@router.get("/costs")
|
|
def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "출장비목록"
|
|
ws.freeze_panes = "A2"
|
|
|
|
headers = [
|
|
"신고번호","충전기ID","충전기종류","충전소명","조치완료일",
|
|
"정비사","소속","문제원인","비고",
|
|
"출장비부담주체","제조사명","금액(원)","처리상태",
|
|
"처리담당자","처리일시"
|
|
]
|
|
style_header(ws, headers)
|
|
for i, w in enumerate([10,14,14,18,16,12,14,24,24,16,16,12,12,12,16], 1):
|
|
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
|
|
|
costs = db.query(models.RepairCost).join(models.Repair).order_by(
|
|
desc(models.RepairCost.reviewed_at)).all()
|
|
|
|
for row_num, cost in enumerate(costs, 2):
|
|
repair = cost.repair
|
|
rids = [rr.report_id for rr in repair.report_links]
|
|
charger_id = station_name = charger_type = ""
|
|
if rids:
|
|
r = db.query(models.Report).filter_by(id=rids[0]).first()
|
|
if r and r.charger:
|
|
charger_id = r.charger_id
|
|
station_name = r.charger.station_name
|
|
charger_type = r.charger.charger_type.name if r.charger.charger_type else ""
|
|
|
|
row_data = [
|
|
", ".join(str(i) for i in rids),
|
|
charger_id, charger_type, station_name,
|
|
fmt_dt(repair.completed_at),
|
|
repair.mechanic.name if repair.mechanic else "",
|
|
repair.mechanic.company if repair.mechanic else "",
|
|
cost.root_cause or "",
|
|
cost.admin_note or "",
|
|
cost.cost_party_type or "",
|
|
cost.manufacturer.company if cost.manufacturer else (cost.cost_party_custom or ""),
|
|
cost.cost_amount or 0,
|
|
cost.cost_status or "",
|
|
cost.reviewer.name if cost.reviewer else "",
|
|
fmt_dt(cost.reviewed_at),
|
|
]
|
|
for col, val in enumerate(row_data, 1):
|
|
ws.cell(row=row_num, column=col, value=val)
|
|
style_row(ws, row_num, len(headers), row_num % 2 == 0)
|
|
ws.row_dimensions[row_num].height = 16
|
|
|
|
return make_response(wb, "출장비목록")
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 3. 개선항목 목록
|
|
# ─────────────────────────────────────────────
|
|
@router.get("/improvements")
|
|
def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)):
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "개선항목목록"
|
|
ws.freeze_panes = "A2"
|
|
|
|
headers = [
|
|
"번호","제목","분류","우선순위","개선내용","관련부품",
|
|
"담당제조사","담당자","연락처","연결AS건수","연결AS번호","연결AS신고자",
|
|
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
|
|
"등록관리자","등록일시"
|
|
]
|
|
style_header(ws, headers)
|
|
for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,24,12,14,14,24,12,16], 1):
|
|
ws.column_dimensions[ws.cell(1, i).column_letter].width = w
|
|
|
|
imps = db.query(models.Improvement).order_by(desc(models.Improvement.created_at)).all()
|
|
for row_num, imp in enumerate(imps, 2):
|
|
rids = [ir.report_id for ir in imp.report_links]
|
|
|
|
reporters = []
|
|
for ir in imp.report_links:
|
|
r = ir.report
|
|
if not r:
|
|
continue
|
|
if r.source == "admin" and r.reporter:
|
|
reporters.append(f"#{r.id} {r.reporter.name}(관리자)")
|
|
elif r.contact:
|
|
reporters.append(f"#{r.id} {r.contact}(QR)")
|
|
else:
|
|
reporters.append(f"#{r.id} 익명(QR)")
|
|
|
|
row_data = [
|
|
imp.id, imp.title, imp.category, imp.priority,
|
|
imp.description, imp.part_name or "",
|
|
imp.manufacturer.company if imp.manufacturer else "",
|
|
imp.manufacturer.name if imp.manufacturer else "",
|
|
imp.manufacturer.phone if imp.manufacturer else "",
|
|
len(rids),
|
|
", ".join(str(i) for i in rids),
|
|
"\n".join(reporters),
|
|
imp.status,
|
|
fmt_d(imp.sw_deploy_target),
|
|
fmt_d(imp.sw_deployed_at),
|
|
imp.manufacturer_memo or "",
|
|
imp.creator.name if imp.creator else "",
|
|
fmt_dt(imp.created_at),
|
|
]
|
|
for col, val in enumerate(row_data, 1):
|
|
ws.cell(row=row_num, column=col, value=val)
|
|
style_row(ws, row_num, len(headers), row_num % 2 == 0)
|
|
ws.row_dimensions[row_num].height = 16
|
|
|
|
return make_response(wb, "개선항목목록")
|