246 lines
11 KiB
Python
246 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,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()
|
|
]
|
|
|
|
row_data = [
|
|
r.id,
|
|
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),
|
|
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번호",
|
|
"진행상태","SW배포목표일","SW실제배포일","제조사메모",
|
|
"등록관리자","등록일시"
|
|
]
|
|
style_header(ws, headers)
|
|
for i, w in enumerate([8,24,10,10,30,14,16,12,14,10,18,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]
|
|
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),
|
|
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, "개선항목목록")
|