diff --git a/backend/main.py b/backend/main.py index 9cf11d2..230df81 100644 --- a/backend/main.py +++ b/backend/main.py @@ -542,22 +542,52 @@ def stats_top_chargers(limit: int = 10): db.close() -@app.get("/api/stats/charger-error-codes") -def stats_charger_error_codes(code_limit: int = 10): - """에러코드별 누적 건수 Top N (단순 순위).""" +@app.get("/api/stats/top-stations") +def stats_top_stations(limit: int = 10): + """충전소별 누적 고장 신고 건수 Top N.""" from database import SessionLocal from sqlalchemy import text db = SessionLocal() try: rows = db.execute(text(""" - SELECT TRIM(error_code) AS error_code, COUNT(*) AS cnt + SELECT COALESCE(c.station_name, rep.charger_id) AS station_name, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE rep.status = 'done') AS done_cnt, + COUNT(*) FILTER (WHERE rep.status != 'done') AS active_cnt + FROM reports rep + LEFT JOIN chargers c ON c.id = rep.charger_id + GROUP BY COALESCE(c.station_name, rep.charger_id) + ORDER BY total DESC + LIMIT :lim + """), {"lim": limit}).fetchall() + return [ + { + "station_name": row[0], + "total": int(row[1]), + "done": int(row[2]), + "active": int(row[3]), + } + for row in rows + ] + finally: + db.close() + + +@app.get("/api/stats/charger-error-codes") +def stats_charger_error_codes(code_limit: int = 10): + """에러코드별 누적 건수 Top N (에러코드 없음 포함).""" + from database import SessionLocal + from sqlalchemy import text + db = SessionLocal() + try: + rows = db.execute(text(""" + SELECT COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음') AS error_code, + COUNT(*) AS cnt FROM reports - WHERE error_code IS NOT NULL AND TRIM(error_code) != '' - GROUP BY TRIM(error_code) + GROUP BY COALESCE(NULLIF(TRIM(COALESCE(error_code, '')), ''), '에러코드 없음') ORDER BY cnt DESC LIMIT :limit """), {"limit": code_limit}).fetchall() - # 역순: 차트 Y축에서 1위가 맨 위 result = [{"error_code": r[0], "total": int(r[1])} for r in reversed(rows)] return {"error_codes": result} finally: diff --git a/backend/models.py b/backend/models.py index 498c274..c500a84 100644 --- a/backend/models.py +++ b/backend/models.py @@ -64,6 +64,9 @@ class Report(Base): closed_at = Column(TIMESTAMP) closed_by = Column(Integer, ForeignKey("users.id"), nullable=True) re_dispatch_count = Column(Integer, default=0) + report_scope = Column(String(20), default="single") # single | station | type | multi + scope_charger_count = Column(Integer, default=1) + charger_ids = Column(ARRAY(Text), nullable=True) # multi 범위일 때 선택된 충전기 ID 목록 charger = relationship("Charger", back_populates="reports") photos = relationship("ReportPhoto", back_populates="report", cascade="all, delete-orphan") repair_links = relationship("RepairReport", back_populates="report") @@ -121,16 +124,20 @@ class RepairCost(Base): repair_id = Column(Integer, ForeignKey("repairs.id", ondelete="CASCADE"), unique=True) root_cause = Column(Text) admin_note = Column(Text) - cost_party_type = Column(String(20)) - cost_party_manufacturer_id = Column(Integer, ForeignKey("users.id")) - cost_party_custom = Column(String(100)) + cost_party_type = Column(String(50)) + cost_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL")) + cost_party_custom = Column(String(200)) + recv_party_type = Column(String(50)) + recv_party_manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL")) + recv_party_custom = Column(String(200)) cost_amount = Column(Integer, default=0) cost_status = Column(String(20), default="pending") reviewed_by = Column(Integer, ForeignKey("users.id")) reviewed_at = Column(TIMESTAMP) repair = relationship("Repair", back_populates="cost") reviewer = relationship("User", foreign_keys=[reviewed_by]) - manufacturer = relationship("User", foreign_keys=[cost_party_manufacturer_id]) + cost_manufacturer = relationship("Manufacturer", foreign_keys=[cost_party_manufacturer_id]) + recv_manufacturer = relationship("Manufacturer", foreign_keys=[recv_party_manufacturer_id]) class Improvement(Base): __tablename__ = "improvements" @@ -141,13 +148,13 @@ class Improvement(Base): priority = Column(String(10), default="normal") part_name = Column(String(100)) status = Column(String(20), default="registered") - manufacturer_id = Column(Integer, ForeignKey("users.id")) + manufacturer_id = Column(Integer, ForeignKey("manufacturers.id", ondelete="SET NULL")) created_by = Column(Integer, ForeignKey("users.id")) sw_deploy_target = Column(Date) sw_deployed_at = Column(Date) manufacturer_memo = Column(Text) created_at = Column(TIMESTAMP, server_default=func.now()) - manufacturer = relationship("User", foreign_keys=[manufacturer_id]) + manufacturer = relationship("Manufacturer", foreign_keys=[manufacturer_id]) creator = relationship("User", foreign_keys=[created_by]) report_links = relationship("ImprovementReport", back_populates="improvement", cascade="all, delete-orphan") attachments = relationship("ImprovementAttachment", back_populates="improvement", cascade="all, delete-orphan") diff --git a/backend/routers/costs.py b/backend/routers/costs.py index 46b9508..66b090e 100644 --- a/backend/routers/costs.py +++ b/backend/routers/costs.py @@ -40,9 +40,14 @@ def list_costs( "completed_at": repair.completed_at.isoformat() if repair.completed_at else None, "root_cause": cost.root_cause, "admin_note": cost.admin_note, "cost_party_type": cost.cost_party_type, + "cost_party_manufacturer_id": cost.cost_party_manufacturer_id, "cost_party_custom": cost.cost_party_custom, + "cost_manufacturer_name": cost.cost_manufacturer.name if cost.cost_manufacturer else None, + "recv_party_type": cost.recv_party_type, + "recv_party_manufacturer_id": cost.recv_party_manufacturer_id, + "recv_party_custom": cost.recv_party_custom, + "recv_manufacturer_name": cost.recv_manufacturer.name if cost.recv_manufacturer else None, "cost_amount": cost.cost_amount, "cost_status": cost.cost_status, - "manufacturer_name": cost.manufacturer.name if cost.manufacturer else None, "reviewed_by_name": cost.reviewer.name if cost.reviewer else None, "reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None, }) @@ -79,6 +84,9 @@ def upsert_cost( cost_party_type: str = Form(...), cost_party_manufacturer_id: Optional[int] = Form(None), cost_party_custom: str = Form(""), + recv_party_type: str = Form(""), + recv_party_manufacturer_id: Optional[int] = Form(None), + recv_party_custom: str = Form(""), cost_amount: int = Form(0), cost_status: str = Form("pending"), db: Session = Depends(get_db), @@ -93,6 +101,9 @@ def upsert_cost( cost.cost_party_type = cost_party_type cost.cost_party_manufacturer_id = cost_party_manufacturer_id or None cost.cost_party_custom = cost_party_custom or None + cost.recv_party_type = recv_party_type or None + cost.recv_party_manufacturer_id = recv_party_manufacturer_id or None + cost.recv_party_custom = recv_party_custom or None cost.cost_amount = cost_amount; cost.cost_status = cost_status cost.reviewed_by = current_user.id; cost.reviewed_at = datetime.now() else: @@ -101,6 +112,9 @@ def upsert_cost( cost_party_type=cost_party_type, cost_party_manufacturer_id=cost_party_manufacturer_id or None, cost_party_custom=cost_party_custom or None, + recv_party_type=recv_party_type or None, + recv_party_manufacturer_id=recv_party_manufacturer_id or None, + recv_party_custom=recv_party_custom or None, cost_amount=cost_amount, cost_status=cost_status, reviewed_by=current_user.id, reviewed_at=datetime.now() ) diff --git a/backend/routers/export.py b/backend/routers/export.py index 02953a1..2e3ba82 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -1,10 +1,11 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import desc from io import BytesIO -from datetime import datetime +from datetime import datetime, timedelta from urllib.parse import quote +from typing import Optional import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from database import get_db @@ -13,19 +14,29 @@ from auth import require_admin router = APIRouter(prefix="/api/export", tags=["export"]) -NAVY = "0B1E3D" -LIGHT = "D6EAF8" +NAVY = "0B1E3D" +LIGHT = "D6EAF8" +GREEN = "1B5E20" +ORANGE = "E65100" +PURPLE = "4A148C" +TEAL = "004D40" -def style_header(ws, headers, row=1): + +def _hdr_cell(ws, row, col, value, color=NAVY): bd = Side(style="thin", color="AAAAAA") + c = ws.cell(row=row, column=col, value=value) + c.font = Font(bold=True, color="FFFFFF", size=10) + c.fill = PatternFill("solid", fgColor=color) + c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + c.border = Border(left=bd, right=bd, top=bd, bottom=bd) + + +def style_header(ws, headers, row=1, color=NAVY): 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) + _hdr_cell(ws, row, col, h, color) 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): @@ -35,51 +46,357 @@ def style_row(ws, row_num, num_cols, even=True): 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()) + if total < 0: return "" h, m = divmod(total // 60, 60) return f"{h}시간 {m}분" + +def set_col_widths(ws, widths): + for i, w in enumerate(widths, 1): + ws.column_dimensions[ws.cell(1, i).column_letter].width = w + + 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}" + date_str = datetime.now().strftime("%Y%m%d_%H%M") + filename = f"{korean_name}_{date_str}.xlsx" + encoded = quote(filename, safe="") + cd = f"attachment; filename*=UTF-8''{encoded}" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": cd_header}, + headers={"Content-Disposition": cd}, ) +def _parse_dates(date_from, date_to): + try: + dt_from = datetime.strptime(date_from, "%Y-%m-%d") if date_from else None + dt_to = (datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)) if date_to else None + except ValueError: + raise HTTPException(400, "날짜 형식 오류 (YYYY-MM-DD)") + return dt_from, dt_to + + # ───────────────────────────────────────────── -# 1. AS 신고 목록 +# 시트 빌더 — 공용 +# ───────────────────────────────────────────── + +CLOSURE_LABEL = { + "natural": "증상자연소거", + "remote_reset": "원격리셋후증상소거", + "false_alarm": "인지오류", + "other": "기타", +} +SOURCE_LABEL = { + "qr": "QR스캔", + "admin": "관리자접수", + "dashboard": "대시보드접수", +} +COST_STATUS_LABEL = { + "pending": "미처리", "billed": "청구완료", "waived": "면제", "settled": "정산완료", +} +PARTY_LABEL = { + "cpo": "CPO(운영사)", "manufacturer": "업체", "self": "자체부담", + "user": "사용자과실", "other": "기타", +} +IMP_STATUS_LABEL = { + "registered": "등록", "reviewing": "검토중", "developing": "개발중", + "deployed": "배포완료", "done": "완료", +} +RESULT_LABEL = { + "done": "완료", "in_progress": "진행중", "waiting": "부품대기", "revisit": "재방문", +} + + +def _ws_reports(wb, db, dt_from, dt_to): + ws = wb.create_sheet("AS신고이력") + ws.freeze_panes = "A2" + headers = [ + "신고번호", "충전기ID", "충전기종류", "충전기명", "충전소명", "CPO", + "문제유형", "에러코드", "상세설명", + "신고자연락처", "문제발생시각", "신고일시", "신고출처", "신고자", + "처리상태", "담당정비사", "소속", + "조치유형", "조치내용", "조치시작", "조치완료", "작업소요시간", + "재조치횟수", "문제원인", "비고", + "출장비부담", "출장비금액(원)", "출장비상태", + "상황종료사유", "상황종료일시", + ] + style_header(ws, headers, color=NAVY) + set_col_widths(ws, [10,14,14,14,18,12,22,12,26,14,16,16,10,14,12,12,12,16,26,16,16,12,8,24,24,14,12,12,18,16]) + + q = db.query(models.Report) + if dt_from: q = q.filter(models.Report.reported_at >= dt_from) + if dt_to: q = q.filter(models.Report.reported_at < dt_to) + rows = q.order_by(desc(models.Report.reported_at)).all() + + for rn, r in enumerate(rows, 2): + c = r.charger + repair = r.repair_links[0].repair if r.repair_links else None + cost = repair.cost if repair else None + ws.append([ + 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 "", + ", ".join(r.issue_types or []), + r.error_code or "", + r.issue_detail or "", + r.contact or "", + fmt_dt(r.occurred_at), + fmt_dt(r.reported_at), + SOURCE_LABEL.get(r.source or "qr", r.source or ""), + 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 or []) if repair 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 "", + r.re_dispatch_count or 0, + cost.root_cause if cost else "", + cost.admin_note if cost else "", + PARTY_LABEL.get(cost.cost_party_type, cost.cost_party_type or "") if cost else "", + cost.cost_amount if cost else "", + COST_STATUS_LABEL.get(cost.cost_status, cost.cost_status or "") if cost else "", + CLOSURE_LABEL.get(r.closure_type or "", ""), + fmt_dt(r.closed_at), + ]) + style_row(ws, rn, len(headers), rn % 2 == 0) + ws.row_dimensions[rn].height = 16 + + return len(rows) + + +def _ws_repairs(wb, db, dt_from, dt_to): + ws = wb.create_sheet("조치이력") + ws.freeze_panes = "A2" + headers = [ + "조치번호", "연결신고번호", "충전기ID", "충전소명", "충전기종류", + "정비사", "소속", + "조치유형", "조치내용", + "시작시각", "완료시각", "소요시간", + "처리결과", "재조치요청", + "승인완료", "승인자", "승인일시", + ] + style_header(ws, headers, color=GREEN) + set_col_widths(ws, [10,16,14,18,14,12,14,18,30,16,16,12,10,10,10,12,16]) + + q = db.query(models.Repair) + if dt_from: q = q.filter(models.Repair.completed_at >= dt_from) + if dt_to: q = q.filter(models.Repair.completed_at < dt_to) + rows = q.order_by(desc(models.Repair.completed_at)).all() + + for rn, rep in enumerate(rows, 2): + rids = [rr.report_id for rr in rep.report_links] + charger_id = station = ctype = "" + if rids: + r = db.query(models.Report).filter_by(id=rids[0]).first() + if r and r.charger: + charger_id = r.charger_id + station = r.charger.station_name or "" + ctype = r.charger.charger_type.name if r.charger.charger_type else "" + ws.append([ + rep.id, + ", ".join(str(i) for i in rids), + charger_id, station, ctype, + rep.mechanic.name if rep.mechanic else "", + rep.mechanic.company if rep.mechanic else "", + ", ".join(rep.repair_types or []), + rep.description or "", + fmt_dt(rep.started_at), + fmt_dt(rep.completed_at), + elapsed(rep.started_at, rep.completed_at), + RESULT_LABEL.get(rep.result_status or "", rep.result_status or ""), + "예" if rep.re_dispatch_requested else "아니오", + "예" if rep.approved_at else "아니오", + rep.approver.name if rep.approver else "", + fmt_dt(rep.approved_at), + ]) + style_row(ws, rn, len(headers), rn % 2 == 0) + ws.row_dimensions[rn].height = 16 + + return len(rows) + + +def _ws_improvements(wb, db, dt_from, dt_to): + ws = wb.create_sheet("개선항목") + ws.freeze_panes = "A2" + headers = [ + "번호", "제목", "분류", "우선순위", "개선내용", "관련부품", + "담당업체", "담당자(대표)", "연락처", + "연결AS건수", "연결AS번호", + "진행상태", "SW배포목표일", "SW실제배포일", + "제조사메모", "등록자", "등록일시", + ] + style_header(ws, headers, color=PURPLE) + set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16]) + + q = db.query(models.Improvement) + if dt_from: q = q.filter(models.Improvement.created_at >= dt_from) + if dt_to: q = q.filter(models.Improvement.created_at < dt_to) + rows = q.order_by(desc(models.Improvement.created_at)).all() + + for rn, imp in enumerate(rows, 2): + rids = [ir.report_id for ir in imp.report_links] + ws.append([ + imp.id, imp.title, imp.category, imp.priority, + imp.description, imp.part_name or "", + imp.manufacturer.name if imp.manufacturer else "", + imp.manufacturer.representative_name if imp.manufacturer else "", + imp.manufacturer.phone if imp.manufacturer else "", + len(rids), + ", ".join(str(i) for i in rids), + IMP_STATUS_LABEL.get(imp.status, 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), + ]) + style_row(ws, rn, len(headers), rn % 2 == 0) + ws.row_dimensions[rn].height = 16 + + return len(rows) + + +def _ws_costs(wb, db, dt_from, dt_to): + ws = wb.create_sheet("출장비정산") + ws.freeze_panes = "A2" + headers = [ + "신고번호", "충전기ID", "충전소명", "충전기종류", + "정비사", "소속", "조치완료일", + "문제원인", "비고", + "부담주체유형", "부담업체명", "부담기타", + "수급주체유형", "수급업체명", "수급기타", + "금액(원)", "처리상태", + "처리담당자", "처리일시", + ] + style_header(ws, headers, color=ORANGE) + set_col_widths(ws, [14,14,18,14,12,14,16,26,26,12,16,16,12,16,16,12,12,12,16]) + + q = db.query(models.RepairCost).join(models.Repair) + if dt_from: q = q.filter(models.Repair.completed_at >= dt_from) + if dt_to: q = q.filter(models.Repair.completed_at < dt_to) + rows = q.order_by(desc(models.Repair.completed_at)).all() + + for rn, cost in enumerate(rows, 2): + repair = cost.repair + rids = [rr.report_id for rr in repair.report_links] + charger_id = station = ctype = "" + if rids: + r = db.query(models.Report).filter_by(id=rids[0]).first() + if r and r.charger: + charger_id = r.charger_id + station = r.charger.station_name or "" + ctype = r.charger.charger_type.name if r.charger.charger_type else "" + ws.append([ + ", ".join(str(i) for i in rids), + charger_id, station, ctype, + repair.mechanic.name if repair.mechanic else "", + repair.mechanic.company if repair.mechanic else "", + fmt_dt(repair.completed_at), + cost.root_cause or "", + cost.admin_note or "", + PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""), + cost.cost_manufacturer.name if cost.cost_manufacturer else "", + cost.cost_party_custom or "", + PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""), + cost.recv_manufacturer.name if cost.recv_manufacturer else "", + cost.recv_party_custom or "", + cost.cost_amount or 0, + COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""), + cost.reviewer.name if cost.reviewer else "", + fmt_dt(cost.reviewed_at), + ]) + style_row(ws, rn, len(headers), rn % 2 == 0) + ws.row_dimensions[rn].height = 16 + + return len(rows) + + +def _ws_summary(wb, counts, dt_from, dt_to, date_from, date_to): + ws = wb.create_sheet("요약", 0) # 맨 앞에 삽입 + ws.sheet_view.showGridLines = False + + period = f"{date_from or '전체'} ~ {date_to or '전체'}" + + def lbl(row, col, text, bold=False, size=11, color="1A2B4A"): + c = ws.cell(row=row, column=col, value=text) + c.font = Font(bold=bold, size=size, color=color) + c.alignment = Alignment(vertical="center") + + def val(row, col, text, color="1A2B4A", bold=True): + c = ws.cell(row=row, column=col, value=text) + c.font = Font(bold=bold, size=13, color=color) + c.alignment = Alignment(vertical="center", horizontal="center") + + ws.row_dimensions[1].height = 16 + ws.row_dimensions[2].height = 36 + ws.row_dimensions[3].height = 14 + + c = ws.cell(row=2, column=2, value="EV AS 관리 통합 이력") + c.font = Font(bold=True, size=18, color=NAVY) + c.alignment = Alignment(vertical="center") + ws.cell(row=2, column=5, value=f"조회 기간: {period}").font = Font(size=11, color="666666") + + sheet_names = ["AS신고이력", "조치이력", "개선항목", "출장비정산"] + colors = [NAVY, GREEN, PURPLE, ORANGE] + labels = ["AS 신고", "조치 이력", "개선항목", "출장비 정산"] + + for i, (name, color, label, cnt) in enumerate(zip(sheet_names, colors, labels, counts)): + row = 5 + i * 3 + ws.row_dimensions[row].height = 24 + ws.row_dimensions[row+1].height = 22 + cell = ws.cell(row=row, column=2, value=f"● {label}") + cell.font = Font(bold=True, size=12, color=color) + cell.alignment = Alignment(vertical="center") + ws.cell(row=row, column=3, value=f"{cnt}건").font = Font(bold=True, size=14, color=color) + ws.cell(row=row+1, column=2, + value=f'자세한 내용은 "{name}" 시트를 확인하세요').font = Font(size=9, color="888888") + + ws.column_dimensions["A"].width = 4 + ws.column_dimensions["B"].width = 22 + ws.column_dimensions["C"].width = 14 + ws.column_dimensions["D"].width = 10 + ws.column_dimensions["E"].width = 30 + + +# ───────────────────────────────────────────── +# 1. AS 신고 목록 (개별) # ───────────────────────────────────────────── @router.get("/reports") -def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): +def export_reports( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + _=Depends(require_admin) +): + dt_from, dt_to = _parse_dates(date_from, date_to) wb = openpyxl.Workbook() ws = wb.active ws.title = "AS신고목록" ws.freeze_panes = "A2" - CLOSURE_LABEL = { - "natural": "증상자연소거", - "remote_reset": "원격리셋후증상소거", - "false_alarm": "인지오류", - "other": "기타", - } headers = [ "접수번호","충전기ID","충전기종류","충전기명","충전소명","CPO명","설치일", "신고위치(위도)","신고위치(경도)","문제유형","에러코드","상세설명", @@ -92,14 +409,15 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): "재조치횟수" ] style_header(ws, headers) + set_col_widths(ws, [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, + 18,24,16,14,10]) - 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, - 18,24,16,14,10] - for i, w in enumerate(col_widths, 1): - ws.column_dimensions[ws.cell(1, i).column_letter].width = w + q = db.query(models.Report) + if dt_from: q = q.filter(models.Report.reported_at >= dt_from) + if dt_to: q = q.filter(models.Report.reported_at < dt_to) + reports = q.order_by(desc(models.Report.reported_at)).all() - 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 @@ -108,10 +426,8 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): 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.id, r.charger_id, c.charger_type.name if c and c.charger_type else "", c.name if c else "", @@ -120,18 +436,18 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): 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 "", + ", ".join(r.issue_types or []), 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"), + SOURCE_LABEL.get(r.source or "qr", r.source or ""), 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 "", + ", ".join(repair.repair_types or []) if repair else "", repair.description if repair else "", fmt_dt(repair.started_at) if repair else "", fmt_dt(repair.completed_at) if repair else "", @@ -139,13 +455,13 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): 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 "", + PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or "") if cost else "", cost.cost_amount if cost else "", - cost.cost_status if cost else "", + COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or "") 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 "", - CLOSURE_LABEL.get(r.closure_type, "") if r.closure_type else "", + CLOSURE_LABEL.get(r.closure_type or "", ""), r.closure_note or "", fmt_dt(r.closed_at), r.closer.name if r.closer else "", @@ -160,10 +476,16 @@ def export_reports(db: Session = Depends(get_db), _=Depends(require_admin)): # ───────────────────────────────────────────── -# 2. 출장비 목록 +# 2. 출장비 목록 (개별) # ───────────────────────────────────────────── @router.get("/costs") -def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)): +def export_costs( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + _=Depends(require_admin) +): + dt_from, dt_to = _parse_dates(date_from, date_to) wb = openpyxl.Workbook() ws = wb.active ws.title = "출장비목록" @@ -172,39 +494,45 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)): 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 + set_col_widths(ws, [14,14,14,18,16,12,14,26,26,12,16,16,12,16,16,12,12,12,16]) - costs = db.query(models.RepairCost).join(models.Repair).order_by( - desc(models.RepairCost.reviewed_at)).all() + q = db.query(models.RepairCost).join(models.Repair) + if dt_from: q = q.filter(models.Repair.completed_at >= dt_from) + if dt_to: q = q.filter(models.Repair.completed_at < dt_to) + costs = q.order_by(desc(models.Repair.completed_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 = "" + charger_id = station = ctype = "" 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 "" + charger_id = r.charger_id + station = r.charger.station_name or "" + ctype = 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, + charger_id, ctype, station, 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 ""), + PARTY_LABEL.get(cost.cost_party_type or "", cost.cost_party_type or ""), + cost.cost_manufacturer.name if cost.cost_manufacturer else "", + cost.cost_party_custom or "", + PARTY_LABEL.get(cost.recv_party_type or "", cost.recv_party_type or ""), + cost.recv_manufacturer.name if cost.recv_manufacturer else "", + cost.recv_party_custom or "", cost.cost_amount or 0, - cost.cost_status or "", + COST_STATUS_LABEL.get(cost.cost_status or "", cost.cost_status or ""), cost.reviewer.name if cost.reviewer else "", fmt_dt(cost.reviewed_at), ] @@ -217,10 +545,16 @@ def export_costs(db: Session = Depends(get_db), _=Depends(require_admin)): # ───────────────────────────────────────────── -# 3. 개선항목 목록 +# 3. 개선항목 목록 (개별) # ───────────────────────────────────────────── @router.get("/improvements") -def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)): +def export_improvements( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + _=Depends(require_admin) +): + dt_from, dt_to = _parse_dates(date_from, date_to) wb = openpyxl.Workbook() ws = wb.active ws.title = "개선항목목록" @@ -228,40 +562,29 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)) headers = [ "번호","제목","분류","우선순위","개선내용","관련부품", - "담당제조사","담당자","연락처","연결AS건수","연결AS번호","연결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 + set_col_widths(ws, [8,26,10,10,32,14,16,14,14,10,20,12,14,14,26,12,16]) + + q = db.query(models.Improvement) + if dt_from: q = q.filter(models.Improvement.created_at >= dt_from) + if dt_to: q = q.filter(models.Improvement.created_at < dt_to) + imps = q.order_by(desc(models.Improvement.created_at)).all() - 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 "", + imp.manufacturer.name if imp.manufacturer else "", + imp.manufacturer.representative_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, + IMP_STATUS_LABEL.get(imp.status, imp.status), fmt_d(imp.sw_deploy_target), fmt_d(imp.sw_deployed_at), imp.manufacturer_memo or "", @@ -274,3 +597,28 @@ def export_improvements(db: Session = Depends(get_db), _=Depends(require_admin)) ws.row_dimensions[row_num].height = 16 return make_response(wb, "개선항목목록") + + +# ───────────────────────────────────────────── +# 4. 통합 다운로드 (멀티 시트) +# ───────────────────────────────────────────── +@router.get("/full") +def export_full( + date_from: Optional[str] = None, + date_to: Optional[str] = None, + db: Session = Depends(get_db), + _=Depends(require_admin) +): + dt_from, dt_to = _parse_dates(date_from, date_to) + + wb = openpyxl.Workbook() + wb.remove(wb.active) # 기본 빈 시트 제거 + + c1 = _ws_reports(wb, db, dt_from, dt_to) + c2 = _ws_repairs(wb, db, dt_from, dt_to) + c3 = _ws_improvements(wb, db, dt_from, dt_to) + c4 = _ws_costs(wb, db, dt_from, dt_to) + _ws_summary(wb, [c1, c2, c3, c4], dt_from, dt_to, date_from, date_to) + + period = f"{date_from or '전체'}~{date_to or '전체'}" + return make_response(wb, f"EV_AS_통합이력_{period}") diff --git a/backend/routers/improvements.py b/backend/routers/improvements.py index 199ad24..36bf003 100644 --- a/backend/routers/improvements.py +++ b/backend/routers/improvements.py @@ -1,7 +1,7 @@ import json from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Body from sqlalchemy.orm import Session -from sqlalchemy import desc, text +from sqlalchemy import desc, text, func from typing import List, Optional from datetime import datetime from database import get_db @@ -18,7 +18,7 @@ def _fmt(imp: models.Improvement): "part_name": imp.part_name, "status": imp.status, "manufacturer_id": imp.manufacturer_id, "manufacturer_name": imp.manufacturer.name if imp.manufacturer else None, - "manufacturer_company": imp.manufacturer.company if imp.manufacturer else None, + "manufacturer_company": None, "created_by_name": imp.creator.name if imp.creator else None, "sw_deploy_target": str(imp.sw_deploy_target) if imp.sw_deploy_target else None, "sw_deployed_at": str(imp.sw_deployed_at) if imp.sw_deployed_at else None, @@ -27,7 +27,7 @@ def _fmt(imp: models.Improvement): "report_ids": [ir.report_id for ir in imp.report_links], "report_count": len(imp.report_links), "attachments": [{"path": a.file_path, "name": a.file_name} for a in imp.attachments], - "logs": [{"old": l.old_status, "new": l.new_status, "memo": l.memo, + "logs": [{"old_status": l.old_status, "new_status": l.new_status, "memo": l.memo, "changed_at": l.changed_at.isoformat(), "by": l.changer.name if l.changer else None} for l in imp.logs], } @@ -52,7 +52,22 @@ def get_improvement(imp_id: int, db: Session = Depends(get_db), if not imp: raise HTTPException(404) if current_user.role == "manufacturer" and imp.manufacturer_id != current_user.id: raise HTTPException(403) - return _fmt(imp) + result = _fmt(imp) + rids = [ir.report_id for ir in imp.report_links] + if rids: + seq_subq = db.query( + models.Report.id.label("rid"), + func.row_number().over( + order_by=[models.Report.reported_at.asc(), models.Report.id.asc()] + ).label("seq") + ).subquery() + seqs = {row.rid: row.seq for row in + db.query(seq_subq.c.rid, seq_subq.c.seq) + .filter(seq_subq.c.rid.in_(rids)).all()} + result["report_links"] = [{"id": rid, "seq": seqs.get(rid, rid)} for rid in rids] + else: + result["report_links"] = [] + return result @router.post("") async def create_improvement( diff --git a/backend/routers/repairs.py b/backend/routers/repairs.py index ec42f5a..6907eb8 100644 --- a/backend/routers/repairs.py +++ b/backend/routers/repairs.py @@ -36,6 +36,16 @@ def _fmt_repair(repair: models.Repair) -> dict: "issue_types": r.issue_types, "status": r.status, }) + + # 같은 신고에 연결된 조치 목록에서 현재 조치의 순번 계산 (오래된 것=1차) + attempt = 1 + if repair.report_links: + first_report = repair.report_links[0].report + if first_report and first_report.repair_links: + all_repair_ids = sorted(rl.repair_id for rl in first_report.repair_links) + if repair.id in all_repair_ids: + attempt = all_repair_ids.index(repair.id) + 1 + return { "id": repair.id, "charger_id": charger_id, @@ -56,6 +66,7 @@ def _fmt_repair(repair: models.Repair) -> dict: "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), + "attempt": attempt, } diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 37c53e1..a7b6c53 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -49,6 +49,10 @@ def _fmt_report(r: models.Report, db: Session): "closure_note": r.closure_note, "closed_at": r.closed_at.isoformat() if r.closed_at else None, "closed_by_name": r.closer.name if r.closer else None, + "re_dispatch_count": r.re_dispatch_count or 0, + "report_scope": r.report_scope or "single", + "scope_charger_count": r.scope_charger_count or 1, + "charger_ids": r.charger_ids or [], } @router.post("") @@ -105,7 +109,8 @@ async def create_report( @router.post("/batch") async def create_batch_report( charger_id: str = Form(...), - scope: str = Form("single"), # single | station | type + scope: str = Form("single"), # single | station | type | multi + charger_ids: Optional[str] = Form(None), # JSON: ["id1","id2",...] for multi scope issue_types: str = Form(...), issue_detail: str = Form(""), error_code: str = Form(""), @@ -124,14 +129,28 @@ async def create_batch_report( charger = db.query(models.Charger).filter_by(id=charger_id).first() if not charger: raise HTTPException(404, "충전기를 찾을 수 없습니다.") - if scope == "station": - targets = db.query(models.Charger).filter_by( + selected_ids = None + if scope == "multi" and charger_ids: + selected_ids = json.loads(charger_ids) + all_targets = [charger] + report_scope = "multi" + scope_charger_count = len(selected_ids) + elif scope == "station": + all_targets = db.query(models.Charger).filter_by( station_name=charger.station_name, is_active=True).all() + report_scope = "station" + scope_charger_count = len(all_targets) elif scope == "type" and charger.charger_type_id: - targets = db.query(models.Charger).filter_by( + all_targets = db.query(models.Charger).filter_by( charger_type_id=charger.charger_type_id, is_active=True).all() + report_scope = "type" + scope_charger_count = len(all_targets) else: - targets = [charger] + all_targets = [charger] + report_scope = "single" + scope_charger_count = 1 + + targets = [charger] # 항상 단일 신고 생성 setting = db.query(models.SystemSetting).filter_by(key="report_visibility_policy").first() policy = setting.value if setting else "immediate" @@ -143,7 +162,6 @@ async def create_batch_report( else: source_value = "qr" - # Read all photo bytes upfront so they can be written for each target photo_data = [] for photo in photos: if photo.filename: @@ -160,6 +178,9 @@ async def create_batch_report( ocpp_log=ocpp_log or None, source=source_value, reported_by=current_user.id if current_user else None, + report_scope=report_scope, + scope_charger_count=scope_charger_count, + charger_ids=selected_ids, ) db.add(r); db.commit(); db.refresh(r) @@ -181,6 +202,7 @@ async def create_batch_report( def list_reports( status: Optional[str] = None, charger_id: Optional[str] = None, + station_name: Optional[str] = None, active_only: bool = False, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user) @@ -196,12 +218,17 @@ def list_reports( q = (db.query(models.Report, seq_subq.c.seq) .join(seq_subq, models.Report.id == seq_subq.c.rid) .order_by(desc(models.Report.reported_at))) - if status: + if status == "pending_all": + q = q.filter(models.Report.status.in_(["pending", "pending_approval"])) + elif status: q = q.filter(models.Report.status == status) elif active_only: q = q.filter(models.Report.status.in_( ["pending", "pending_approval", "in_progress", "waiting", "revisit"])) if charger_id: q = q.filter(models.Report.charger_id == charger_id) + if station_name: + q = (q.join(models.Charger, models.Report.charger_id == models.Charger.id, isouter=True) + .filter(models.Charger.station_name == station_name)) if current_user.role == "mechanic": q = q.filter(models.Report.status != "pending_approval") @@ -254,20 +281,26 @@ def get_report(report_id: int, db: Session = Depends(get_db), "root_cause": cost.root_cause, "admin_note": cost.admin_note, "cost_party_type": cost.cost_party_type, + "cost_party_manufacturer_id": cost.cost_party_manufacturer_id, "cost_party_custom": cost.cost_party_custom, + "cost_manufacturer_name": cost.cost_manufacturer.name if cost.cost_manufacturer else None, + "recv_party_type": cost.recv_party_type, + "recv_party_manufacturer_id": cost.recv_party_manufacturer_id, + "recv_party_custom": cost.recv_party_custom, + "recv_manufacturer_name": cost.recv_manufacturer.name if cost.recv_manufacturer else None, "cost_amount": cost.cost_amount, "cost_status": cost.cost_status, - "manufacturer_name": cost.manufacturer.name if cost.manufacturer else None, - } if (cost and include_cost) else None, + "reviewed_by_name": cost.reviewer.name if cost.reviewer else None, + "reviewed_at": cost.reviewed_at.isoformat() if cost.reviewed_at else None, + } if cost else None, "linked_improvements": _get_linked_improvements(repair, db) if include_cost else [], } if r.repair_links: sorted_links = sorted(r.repair_links, key=lambda l: l.repair_id, reverse=True) result["repair"] = _fmt_one_repair(sorted_links[0].repair) - # 재조치로 인한 이전 조치 이력 (최신 제외, re_dispatch_requested=True인 것) result["prev_repairs"] = [ - _fmt_one_repair(link.repair, include_cost=False) + _fmt_one_repair(link.repair, include_cost=True) for link in sorted_links[1:] if link.repair ] @@ -306,6 +339,8 @@ def bulk_delete_reports( @router.patch("/{report_id}") async def update_report( report_id: int, + charger_id: Optional[str] = Form(None), + scope: Optional[str] = Form(None), issue_types: Optional[str] = Form(None), issue_detail: Optional[str] = Form(None), error_code: Optional[str] = Form(None), @@ -320,6 +355,24 @@ async def update_report( import json r = db.query(models.Report).filter_by(id=report_id).first() if not r: raise HTTPException(404) + if charger_id is not None and charger_id.strip(): + ch = db.query(models.Charger).filter_by(id=charger_id.strip()).first() + if not ch: raise HTTPException(400, "충전기를 찾을 수 없습니다") + r.charger_id = charger_id.strip() + if scope is not None and scope in ("single", "station", "type"): + ref = db.query(models.Charger).filter_by(id=r.charger_id).first() + if scope == "station" and ref: + count = db.query(models.Charger).filter_by( + station_name=ref.station_name, is_active=True).count() + elif scope == "type" and ref and ref.charger_type_id: + count = db.query(models.Charger).filter_by( + charger_type_id=ref.charger_type_id, is_active=True).count() + else: + count = 1 + r.report_scope = scope + r.scope_charger_count = count + if scope != "multi": + r.charger_ids = None if issue_types is not None: r.issue_types = json.loads(issue_types) if issue_detail is not None: diff --git a/frontend/static/pages/admin/accounts.html b/frontend/static/pages/admin/accounts.html index ce71998..0f5b061 100644 --- a/frontend/static/pages/admin/accounts.html +++ b/frontend/static/pages/admin/accounts.html @@ -6,9 +6,10 @@ #btnDelete { display:none; }
- + +