에러코드 누적 순위 차트 축 수정 — 에러코드 Y축, 충전기별 스택
- 기존: 충전기 Y축 + 에러코드별 스택 (충전기 중심) - 변경: 에러코드 Y축 + 충전기별 스택 (에러코드 순위 중심) - 어떤 에러코드가 가장 많이 발생했는지 + 어떤 충전기에서 발생했는지 한눈에 확인 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -543,63 +543,77 @@ def stats_top_chargers(limit: int = 10):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats/charger-error-codes")
|
@app.get("/api/stats/charger-error-codes")
|
||||||
def stats_charger_error_codes(charger_limit: int = 10, code_limit: int = 6):
|
def stats_charger_error_codes(code_limit: int = 10, charger_limit: int = 5):
|
||||||
"""충전기별 에러코드 누적 건수 Top N (에러코드 입력된 신고 기준)."""
|
"""에러코드별 누적 건수 Top N (어느 충전기에서 발생했는지 함께 반환)."""
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
rows = db.execute(text("""
|
rows = db.execute(text("""
|
||||||
SELECT rep.charger_id,
|
SELECT TRIM(rep.error_code) AS error_code,
|
||||||
COALESCE(c.station_name, rep.charger_id) AS station_name,
|
rep.charger_id,
|
||||||
COALESCE(c.name, '') AS charger_name,
|
COALESCE(c.station_name, rep.charger_id) AS station_name,
|
||||||
TRIM(rep.error_code) AS error_code,
|
COALESCE(c.name, '') AS charger_name,
|
||||||
COUNT(*) AS cnt
|
COUNT(*) AS cnt
|
||||||
FROM reports rep
|
FROM reports rep
|
||||||
LEFT JOIN chargers c ON c.id = rep.charger_id
|
LEFT JOIN chargers c ON c.id = rep.charger_id
|
||||||
WHERE rep.error_code IS NOT NULL
|
WHERE rep.error_code IS NOT NULL
|
||||||
AND TRIM(rep.error_code) != ''
|
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()
|
""")).fetchall()
|
||||||
|
|
||||||
if not rows:
|
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 = {}
|
charger_info = {}
|
||||||
code_totals = defaultdict(int)
|
|
||||||
|
|
||||||
for row in rows:
|
for ecode, cid, sname, cname, cnt in rows:
|
||||||
cid, sname, cname, ecode, cnt = row
|
|
||||||
cnt = int(cnt)
|
cnt = int(cnt)
|
||||||
if cid not in charger_info:
|
if ecode not in code_info:
|
||||||
charger_info[cid] = {"station_name": sname, "charger_name": cname,
|
code_info[ecode] = {"total": 0, "chargers": {}}
|
||||||
"total": 0, "errors": {}}
|
code_info[ecode]["total"] += cnt
|
||||||
charger_info[cid]["total"] += cnt
|
code_info[ecode]["chargers"][cid] = cnt
|
||||||
charger_info[cid]["errors"][ecode] = cnt
|
|
||||||
code_totals[ecode] += cnt
|
|
||||||
|
|
||||||
top_chargers = sorted(charger_info.items(), key=lambda x: -x[1]["total"])[:charger_limit]
|
if cid not in charger_info:
|
||||||
top_codes = [c for c, _ in sorted(code_totals.items(), key=lambda x: -x[1])[:code_limit]]
|
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 = []
|
result = []
|
||||||
for cid, info in reversed(top_chargers): # 역순: 차트에서 1위가 위에
|
for ecode, info in reversed(top_codes): # 역순: 차트에서 1위가 위에
|
||||||
label = info["station_name"]
|
entry = {"error_code": ecode, "total": info["total"]}
|
||||||
if info["charger_name"]:
|
other = 0
|
||||||
label += f" ({info['charger_name']})"
|
for cid, cnt in info["chargers"].items():
|
||||||
if len(label) > 22:
|
if cid in top_cids:
|
||||||
label = label[:20] + "…"
|
entry[cid] = cnt
|
||||||
errors = info["errors"]
|
else:
|
||||||
other = sum(cnt for code, cnt in errors.items() if code not in top_codes)
|
other += cnt
|
||||||
entry = {"charger_id": cid, "label": label, "total": info["total"]}
|
|
||||||
for code in top_codes:
|
|
||||||
entry[code] = errors.get(code, 0)
|
|
||||||
if other:
|
if other:
|
||||||
entry["기타"] = other
|
entry["__other__"] = other
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
|
|
||||||
has_other = any(r.get("기타", 0) > 0 for r in result)
|
has_other = any(r.get("__other__", 0) > 0 for r in result)
|
||||||
all_codes = top_codes + (["기타"] if has_other else [])
|
charger_labels = {cid: charger_info[cid]["label"] for cid in top_cids}
|
||||||
return {"chargers": result, "error_codes": all_codes}
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -230,8 +230,8 @@
|
|||||||
<!-- 충전기별 에러코드 누적 순위 -->
|
<!-- 충전기별 에러코드 누적 순위 -->
|
||||||
<div class="card" style="margin-bottom:20px">
|
<div class="card" style="margin-bottom:20px">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
||||||
<div class="card-title" style="margin:0">⚡ 충전기별 에러코드 누적 순위 Top 10</div>
|
<div class="card-title" style="margin:0">⚠️ 에러코드 누적 순위 Top 10</div>
|
||||||
<span style="font-size:11px;color:var(--gray4)">에러코드 입력된 신고 기준</span>
|
<span style="font-size:11px;color:var(--gray4)">에러코드 입력된 신고 기준 · 색상 = 충전기</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="errorCodesChartWrap" style="position:relative">
|
<div id="errorCodesChartWrap" style="position:relative">
|
||||||
<canvas id="errorCodesChart"></canvas>
|
<canvas id="errorCodesChart"></canvas>
|
||||||
@@ -636,29 +636,29 @@ async function loadTopChargersChart() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 충전기별 에러코드 차트 ── */
|
/* ── 에러코드별 누적 순위 차트 ── */
|
||||||
let _errorCodesChart = null;
|
let _errorCodesChart = null;
|
||||||
async function loadErrorCodesChart() {
|
async function loadErrorCodesChart() {
|
||||||
const data = await API.get('/stats/charger-error-codes');
|
const data = await API.get('/stats/charger-error-codes');
|
||||||
const wrap = document.getElementById('errorCodesChartWrap');
|
const wrap = document.getElementById('errorCodesChartWrap');
|
||||||
if (!data.chargers || !data.chargers.length) {
|
if (!data.error_codes || !data.error_codes.length) {
|
||||||
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
|
wrap.innerHTML = '<div style="color:var(--gray4);font-size:13px;text-align:center;padding:30px 0">에러코드가 입력된 신고가 없습니다.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { chargers, error_codes: codes } = data;
|
const { error_codes, charger_labels, dataset_keys } = data;
|
||||||
const CODE_COLORS = ['#EF4444','#F59E0B','#3B82F6','#10B981','#8B5CF6','#EC4899','#94A3B8'];
|
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();
|
if (_errorCodesChart) _errorCodesChart.destroy();
|
||||||
const ctx = document.getElementById('errorCodesChart').getContext('2d');
|
const ctx = document.getElementById('errorCodesChart').getContext('2d');
|
||||||
_errorCodesChart = new Chart(ctx, {
|
_errorCodesChart = new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: chargers.map(c => c.label),
|
labels: error_codes.map(e => e.error_code),
|
||||||
datasets: codes.map((code, i) => ({
|
datasets: dataset_keys.map((key, i) => ({
|
||||||
label: code,
|
label: charger_labels[key],
|
||||||
data: chargers.map(c => c[code] || 0),
|
data: error_codes.map(e => e[key] || 0),
|
||||||
backgroundColor: CODE_COLORS[i % CODE_COLORS.length],
|
backgroundColor: CHARGER_COLORS[i % CHARGER_COLORS.length],
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
stack: 'err',
|
stack: 'err',
|
||||||
}))
|
}))
|
||||||
@@ -667,11 +667,6 @@ async function loadErrorCodesChart() {
|
|||||||
indexAxis: 'y',
|
indexAxis: 'y',
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
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'; },
|
onHover: (evt, elems) => { evt.native.target.style.cursor = elems.length ? 'pointer' : 'default'; },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: true, position: 'top',
|
legend: { display: true, position: 'top',
|
||||||
@@ -679,11 +674,10 @@ async function loadErrorCodesChart() {
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
title: items => {
|
title: items => {
|
||||||
const c = chargers[items[0].dataIndex];
|
const e = error_codes[items[0].dataIndex];
|
||||||
return c.label + ` (총 ${c.total}건)`;
|
return `에러코드: ${e.error_code} (총 ${e.total}건)`;
|
||||||
},
|
},
|
||||||
label: c => `${c.dataset.label}: ${c.raw}건`,
|
label: c => `${c.dataset.label}: ${c.raw}건`,
|
||||||
afterLabel: () => '(클릭하면 신고 목록으로)',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user