From b59569ca114882deb90dd36024b82415b6dddb4a Mon Sep 17 00:00:00 2001 From: byun Date: Mon, 1 Jun 2026 08:28:18 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=88=84=EC=A0=81=20=EC=88=9C=EC=9C=84=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=95=20=EC=88=98=EC=A0=95=20=E2=80=94=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20Y=EC=B6=95,=20=EC=B6=A9=EC=A0=84=EA=B8=B0?= =?UTF-8?q?=EB=B3=84=20=EC=8A=A4=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: 충전기 Y축 + 에러코드별 스택 (충전기 중심) - 변경: 에러코드 Y축 + 충전기별 스택 (에러코드 순위 중심) - 어떤 에러코드가 가장 많이 발생했는지 + 어떤 충전기에서 발생했는지 한눈에 확인 Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 84 +++++++++++++--------- frontend/static/pages/admin/dashboard.html | 34 ++++----- 2 files changed, 63 insertions(+), 55 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8cd4e5d..3f8cbbd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -543,63 +543,77 @@ def stats_top_chargers(limit: int = 10): @app.get("/api/stats/charger-error-codes") -def stats_charger_error_codes(charger_limit: int = 10, code_limit: int = 6): - """충전기별 에러코드 누적 건수 Top N (에러코드 입력된 신고 기준).""" +def stats_charger_error_codes(code_limit: int = 10, charger_limit: int = 5): + """에러코드별 누적 건수 Top N (어느 충전기에서 발생했는지 함께 반환).""" from database import SessionLocal from sqlalchemy import text from collections import defaultdict db = SessionLocal() try: rows = db.execute(text(""" - SELECT rep.charger_id, - COALESCE(c.station_name, rep.charger_id) AS station_name, - COALESCE(c.name, '') AS charger_name, - TRIM(rep.error_code) AS error_code, - COUNT(*) AS cnt + SELECT TRIM(rep.error_code) AS error_code, + rep.charger_id, + COALESCE(c.station_name, rep.charger_id) AS station_name, + COALESCE(c.name, '') AS charger_name, + COUNT(*) AS cnt FROM reports rep LEFT JOIN chargers c ON c.id = rep.charger_id WHERE rep.error_code IS NOT NULL AND TRIM(rep.error_code) != '' - GROUP BY rep.charger_id, c.station_name, c.name, TRIM(rep.error_code) + GROUP BY TRIM(rep.error_code), rep.charger_id, c.station_name, c.name """)).fetchall() if not rows: - return {"chargers": [], "error_codes": []} + return {"error_codes": [], "charger_labels": {}} + # error_code → {total, chargers: {cid: cnt}} + code_info = {} + # charger_id → label, total across all codes charger_info = {} - code_totals = defaultdict(int) - for row in rows: - cid, sname, cname, ecode, cnt = row + for ecode, cid, sname, cname, cnt in rows: cnt = int(cnt) - if cid not in charger_info: - charger_info[cid] = {"station_name": sname, "charger_name": cname, - "total": 0, "errors": {}} - charger_info[cid]["total"] += cnt - charger_info[cid]["errors"][ecode] = cnt - code_totals[ecode] += cnt + if ecode not in code_info: + code_info[ecode] = {"total": 0, "chargers": {}} + code_info[ecode]["total"] += cnt + code_info[ecode]["chargers"][cid] = cnt - top_chargers = sorted(charger_info.items(), key=lambda x: -x[1]["total"])[:charger_limit] - top_codes = [c for c, _ in sorted(code_totals.items(), key=lambda x: -x[1])[:code_limit]] + if cid not in charger_info: + label = sname + (f" ({cname})" if cname else "") + if len(label) > 20: label = label[:18] + "…" + charger_info[cid] = {"label": label, "total": 0} + charger_info[cid]["total"] += cnt + + # Top N error codes by total + top_codes = sorted(code_info.items(), key=lambda x: -x[1]["total"])[:code_limit] + + # Top M chargers across all top codes + top_cids = [cid for cid, _ in + sorted(charger_info.items(), key=lambda x: -x[1]["total"])[:charger_limit]] result = [] - for cid, info in reversed(top_chargers): # 역순: 차트에서 1위가 위에 - label = info["station_name"] - if info["charger_name"]: - label += f" ({info['charger_name']})" - if len(label) > 22: - label = label[:20] + "…" - errors = info["errors"] - other = sum(cnt for code, cnt in errors.items() if code not in top_codes) - entry = {"charger_id": cid, "label": label, "total": info["total"]} - for code in top_codes: - entry[code] = errors.get(code, 0) + for ecode, info in reversed(top_codes): # 역순: 차트에서 1위가 위에 + entry = {"error_code": ecode, "total": info["total"]} + other = 0 + for cid, cnt in info["chargers"].items(): + if cid in top_cids: + entry[cid] = cnt + else: + other += cnt if other: - entry["기타"] = other + entry["__other__"] = other result.append(entry) - has_other = any(r.get("기타", 0) > 0 for r in result) - all_codes = top_codes + (["기타"] if has_other else []) - return {"chargers": result, "error_codes": all_codes} + has_other = any(r.get("__other__", 0) > 0 for r in result) + charger_labels = {cid: charger_info[cid]["label"] for cid in top_cids} + if has_other: + charger_labels["__other__"] = "기타" + + dataset_keys = top_cids + (["__other__"] if has_other else []) + return { + "error_codes": result, # 에러코드별 집계 (역순) + "charger_labels": charger_labels, # charger_id → 표시명 + "dataset_keys": dataset_keys, # 차트 dataset 순서 + } finally: db.close() diff --git a/frontend/static/pages/admin/dashboard.html b/frontend/static/pages/admin/dashboard.html index cbe9555..9574bd1 100644 --- a/frontend/static/pages/admin/dashboard.html +++ b/frontend/static/pages/admin/dashboard.html @@ -230,8 +230,8 @@
-
⚡ 충전기별 에러코드 누적 순위 Top 10
- 에러코드 입력된 신고 기준 +
⚠️ 에러코드 누적 순위 Top 10
+ 에러코드 입력된 신고 기준 · 색상 = 충전기
@@ -636,29 +636,29 @@ async function loadTopChargersChart() { }); } -/* ── 충전기별 에러코드 차트 ── */ +/* ── 에러코드별 누적 순위 차트 ── */ let _errorCodesChart = null; async function loadErrorCodesChart() { const data = await API.get('/stats/charger-error-codes'); const wrap = document.getElementById('errorCodesChartWrap'); - if (!data.chargers || !data.chargers.length) { + if (!data.error_codes || !data.error_codes.length) { wrap.innerHTML = '
에러코드가 입력된 신고가 없습니다.
'; return; } - const { chargers, error_codes: codes } = data; - const CODE_COLORS = ['#EF4444','#F59E0B','#3B82F6','#10B981','#8B5CF6','#EC4899','#94A3B8']; + const { error_codes, charger_labels, dataset_keys } = data; + const CHARGER_COLORS = ['#3B82F6','#EF4444','#F59E0B','#10B981','#8B5CF6','#94A3B8']; - wrap.style.height = (chargers.length * 32 + 50) + 'px'; + wrap.style.height = (error_codes.length * 32 + 50) + 'px'; if (_errorCodesChart) _errorCodesChart.destroy(); const ctx = document.getElementById('errorCodesChart').getContext('2d'); _errorCodesChart = new Chart(ctx, { type: 'bar', data: { - labels: chargers.map(c => c.label), - datasets: codes.map((code, i) => ({ - label: code, - data: chargers.map(c => c[code] || 0), - backgroundColor: CODE_COLORS[i % CODE_COLORS.length], + labels: error_codes.map(e => e.error_code), + datasets: dataset_keys.map((key, i) => ({ + label: charger_labels[key], + data: error_codes.map(e => e[key] || 0), + backgroundColor: CHARGER_COLORS[i % CHARGER_COLORS.length], borderRadius: 3, stack: 'err', })) @@ -667,11 +667,6 @@ async function loadErrorCodesChart() { indexAxis: 'y', responsive: true, maintainAspectRatio: false, - onClick: (evt, elems) => { - if (!elems.length) return; - const c = chargers[elems[0].index]; - location.href = `/pages/admin/reports.html?charger_id=${encodeURIComponent(c.charger_id)}`; - }, onHover: (evt, elems) => { evt.native.target.style.cursor = elems.length ? 'pointer' : 'default'; }, plugins: { legend: { display: true, position: 'top', @@ -679,11 +674,10 @@ async function loadErrorCodesChart() { tooltip: { callbacks: { title: items => { - const c = chargers[items[0].dataIndex]; - return c.label + ` (총 ${c.total}건)`; + const e = error_codes[items[0].dataIndex]; + return `에러코드: ${e.error_code} (총 ${e.total}건)`; }, label: c => `${c.dataset.label}: ${c.raw}건`, - afterLabel: () => '(클릭하면 신고 목록으로)', } } },