EV 충전 플랫폼 초기 백업

This commit is contained in:
root
2026-04-18 05:59:31 +09:00
commit 4558ac10c0
40 changed files with 6246 additions and 0 deletions

625
simulator.html Normal file
View File

@@ -0,0 +1,625 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EV 충전 시뮬레이터</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg-0:#06090f;--bg-1:#0c1018;--bg-2:#121824;--bg-3:#1a2236;
--bg-card:rgba(18,24,36,0.85);
--accent:#00d4ff;--accent-dim:rgba(0,212,255,0.12);
--green:#10b981;--green-dim:rgba(16,185,129,0.12);
--amber:#f59e0b;--amber-dim:rgba(245,158,11,0.12);
--red:#ef4444;--red-dim:rgba(239,68,68,0.12);
--purple:#8b5cf6;--purple-dim:rgba(139,92,246,0.12);
--text:#e2e8f0;--text-2:#94a3b8;--text-3:#64748b;
--border:rgba(255,255,255,0.06);--border-accent:rgba(0,212,255,0.15);
--radius:12px;--radius-sm:8px;
--font-display:'Outfit',sans-serif;--font-body:'Noto Sans KR',sans-serif;--font-mono:'JetBrains Mono',monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg-0);color:var(--text);font-family:var(--font-body);min-height:100vh}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 60% 40% at 15% 5%,rgba(0,212,255,0.04) 0%,transparent 60%),radial-gradient(ellipse 50% 30% at 85% 90%,rgba(139,92,246,0.03) 0%,transparent 60%);pointer-events:none}
.container{max-width:1100px;margin:0 auto;padding:32px 24px;position:relative;z-index:1}
/* 헤더 */
.header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid var(--border)}
.header h1{font-family:var(--font-display);font-size:28px;font-weight:700;color:#fff;letter-spacing:-0.02em}
.header small{font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:0.2em;display:block;margin-top:4px}
.header-right{text-align:right}
.header-link{font-family:var(--font-mono);font-size:11px;color:var(--text-3);text-decoration:none;transition:color 0.15s}
.header-link:hover{color:var(--accent)}
/* 레이아웃 */
.layout{display:grid;grid-template-columns:340px 1fr;gap:24px}
@media(max-width:800px){.layout{grid-template-columns:1fr}}
/* 파라미터 패널 */
.params-panel{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);position:sticky;top:24px;height:fit-content}
.params-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.params-title{font-family:var(--font-display);font-size:15px;font-weight:600;color:#fff}
.params-body{padding:20px}
.param-section{margin-bottom:20px}
.param-section-title{font-family:var(--font-mono);font-size:9px;letter-spacing:0.2em;color:var(--accent);text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
.param-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.param-row.full{grid-template-columns:1fr}
.param-group{display:flex;flex-direction:column;gap:4px}
.param-label{font-size:11px;color:var(--text-3);font-weight:500}
.param-input{padding:8px 10px;background:var(--bg-3);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--font-mono);font-size:12px;outline:none;transition:border-color 0.15s;width:100%}
.param-input:focus{border-color:var(--accent)}
.param-input::placeholder{color:var(--text-3)}
select.param-input{cursor:pointer}
.param-hint{font-size:10px;color:var(--text-3);font-family:var(--font-mono);margin-top:2px}
/* 충전 시뮬레이션 슬라이더 */
.charge-slider-wrap{margin:12px 0}
.charge-slider{width:100%;-webkit-appearance:none;height:6px;border-radius:3px;background:var(--bg-3);outline:none}
.charge-slider::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--accent);cursor:pointer;box-shadow:0 0 8px rgba(0,212,255,0.3)}
.charge-preview{display:flex;justify-content:space-between;margin-top:6px;font-family:var(--font-mono);font-size:11px;color:var(--text-2)}
.charge-preview .val{color:var(--accent);font-weight:500}
/* 버튼 */
.btn-run{width:100%;padding:14px;background:linear-gradient(135deg,rgba(0,212,255,0.15),rgba(16,185,129,0.1));border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--font-display);font-size:15px;font-weight:600;cursor:pointer;transition:all 0.2s;margin-top:16px;letter-spacing:0.02em}
.btn-run:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 24px rgba(0,212,255,0.1)}
.btn-run:disabled{opacity:0.4;cursor:not-allowed}
.btn-run.running{animation:runPulse 1.5s ease-in-out infinite}
@keyframes runPulse{0%,100%{box-shadow:0 0 0 0 rgba(0,212,255,0.2)}50%{box-shadow:0 0 0 8px rgba(0,212,255,0)}}
.btn-step{width:100%;padding:10px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-2);font-family:var(--font-mono);font-size:11px;cursor:pointer;transition:all 0.15s;margin-top:8px}
.btn-step:hover{border-color:var(--amber);color:var(--amber)}
.btn-row{display:flex;gap:8px;margin-top:8px}
.btn-row .btn-step{flex:1}
/* 결과 패널 */
.results-panel{display:flex;flex-direction:column;gap:12px}
.step-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);overflow:hidden;transition:border-color 0.3s}
.step-card.success{border-color:rgba(16,185,129,0.3)}
.step-card.error{border-color:rgba(239,68,68,0.3)}
.step-card.running{border-color:rgba(0,212,255,0.3)}
.step-card.waiting{opacity:0.5}
.step-header{display:flex;align-items:center;gap:12px;padding:14px 18px;cursor:pointer;user-select:none}
.step-num{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:11px;font-weight:600;flex-shrink:0;border:1.5px solid var(--border);color:var(--text-3);background:var(--bg-3);transition:all 0.3s}
.step-card.success .step-num{border-color:var(--green);color:var(--green);background:var(--green-dim)}
.step-card.error .step-num{border-color:var(--red);color:var(--red);background:var(--red-dim)}
.step-card.running .step-num{border-color:var(--accent);color:var(--accent);background:var(--accent-dim);animation:runPulse 1.5s infinite}
.step-title{flex:1;font-size:13px;font-weight:500;color:#fff}
.step-subtitle{font-family:var(--font-mono);font-size:10px;color:var(--text-3);margin-top:2px}
.step-status{font-family:var(--font-mono);font-size:10px;padding:3px 8px;border-radius:4px}
.step-status.ok{background:var(--green-dim);color:var(--green)}
.step-status.fail{background:var(--red-dim);color:var(--red)}
.step-status.run{background:var(--accent-dim);color:var(--accent)}
.step-status.wait{background:rgba(100,116,139,0.1);color:var(--text-3)}
.step-time{font-family:var(--font-mono);font-size:10px;color:var(--text-3);margin-left:8px}
.step-body{padding:0 18px 14px;display:none}
.step-card.open .step-body{display:block}
.step-json{background:#0a0e17;border:1px solid rgba(255,255,255,0.04);border-radius:6px;padding:12px 14px;font-family:var(--font-mono);font-size:11px;color:#8ec8e8;line-height:1.6;overflow-x:auto;max-height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
.step-json .k{color:#f472b6} .step-json .s{color:#a5f3c4} .step-json .n{color:#c4b5fd} .step-json .b{color:#fdba74}
/* 요약 카드 */
.summary-card{background:linear-gradient(135deg,rgba(16,185,129,0.08),rgba(0,212,255,0.05));border:1px solid rgba(16,185,129,0.2);border-radius:var(--radius);padding:24px;text-align:center}
.summary-card h3{font-family:var(--font-display);font-size:18px;color:#fff;margin-bottom:16px}
.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.summary-item{text-align:center}
.summary-val{font-family:var(--font-display);font-size:24px;font-weight:700;color:var(--green);line-height:1}
.summary-label{font-size:11px;color:var(--text-3);margin-top:4px}
.summary-saved{margin-top:16px;font-family:var(--font-mono);font-size:13px;color:var(--amber);padding:10px;background:var(--amber-dim);border-radius:6px}
/* 프리셋 */
.preset-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px}
.preset-btn{padding:5px 10px;background:var(--bg-3);border:1px solid var(--border);border-radius:4px;color:var(--text-2);font-family:var(--font-mono);font-size:10px;cursor:pointer;transition:all 0.15s}
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
.preset-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.fade-in{animation:fadeIn 0.3s ease-out forwards}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>EV Charging Simulator</h1>
<small>OCPP TEST CONSOLE</small>
</div>
<div class="header-right">
<a class="header-link" href="/dashboard">← 대시보드로 돌아가기</a>
</div>
</div>
<div class="layout">
<!-- 좌측: 파라미터 -->
<div class="params-panel">
<div class="params-header">
<div class="params-title">테스트 파라미터</div>
</div>
<div class="params-body">
<!-- 프리셋 -->
<div class="param-section">
<div class="param-section-title">프리셋</div>
<div class="preset-row">
<button class="preset-btn active" onclick="applyPreset('basic')">기본 (7kW)</button>
<button class="preset-btn" onclick="applyPreset('fast')">급속 (50kW)</button>
<button class="preset-btn" onclick="applyPreset('slow')">완속 (3kW)</button>
<button class="preset-btn" onclick="applyPreset('short')">단시간</button>
<button class="preset-btn" onclick="applyPreset('full')">완충</button>
<button class="preset-btn" onclick="applyPreset('error')">에러 테스트</button>
</div>
</div>
<!-- 충전기 설정 -->
<div class="param-section">
<div class="param-section-title">충전기</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">충전기 ID</div>
<input class="param-input" id="p-charger" value="CHARGER_001">
</div>
<div class="param-group">
<div class="param-label">커넥터 번호</div>
<input class="param-input" id="p-connector" type="number" value="1" min="1" max="4">
</div>
</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">충전기 이름</div>
<input class="param-input" id="p-name" value="A동 주차장 1번">
</div>
<div class="param-group">
<div class="param-label">출력 (kW)</div>
<input class="param-input" id="p-power" type="number" value="7" min="1" max="350" step="0.1">
</div>
</div>
<div class="param-row full">
<div class="param-group">
<div class="param-label">설치 위치</div>
<input class="param-input" id="p-location" value="수원시 영통구 테스트 아파트 지하1층">
</div>
</div>
</div>
<!-- 충전 설정 -->
<div class="param-section">
<div class="param-section-title">충전 시뮬레이션</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">미터 시작값 (Wh)</div>
<input class="param-input" id="p-meter-start" type="number" value="100000" min="0" step="1000">
</div>
<div class="param-group">
<div class="param-label">Transaction ID</div>
<input class="param-input" id="p-txn-id" type="number" value="" placeholder="자동 생성">
<div class="param-hint">비워두면 타임스탬프 기반 자동 생성</div>
</div>
</div>
<div class="param-group" style="margin-bottom:10px">
<div class="param-label">목표 충전량 (kWh)</div>
<div class="charge-slider-wrap">
<input class="charge-slider" id="p-target-kwh" type="range" min="1" max="100" value="30" oninput="updateChargePreview()">
<div class="charge-preview">
<span>1 kWh</span>
<span class="val" id="charge-val">30 kWh</span>
<span>100 kWh</span>
</div>
</div>
</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">MeterValues 횟수</div>
<input class="param-input" id="p-meter-steps" type="number" value="4" min="1" max="20">
<div class="param-hint">충전 중 보고 횟수</div>
</div>
<div class="param-group">
<div class="param-label">스텝 딜레이 (ms)</div>
<input class="param-input" id="p-delay" type="number" value="500" min="0" max="5000" step="100">
<div class="param-hint">각 단계 사이 대기</div>
</div>
</div>
</div>
<!-- 결제/종료 -->
<div class="param-section">
<div class="param-section-title">결제 / 종료</div>
<div class="param-row">
<div class="param-group">
<div class="param-label">선결제 금액 (원)</div>
<input class="param-input" id="p-amount" type="number" value="10000" min="100" step="1000">
</div>
<div class="param-group">
<div class="param-label">종료 사유</div>
<select class="param-input" id="p-stop-reason">
<option value="Local">Local (사용자 종료)</option>
<option value="Remote">Remote (서버 종료)</option>
<option value="EVDisconnected">EVDisconnected</option>
<option value="PowerLoss">PowerLoss (정전)</option>
<option value="EmergencyStop">EmergencyStop</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
<!-- 에러 시뮬레이션 -->
<div class="param-section">
<div class="param-section-title">에러 시뮬레이션</div>
<div class="param-row full">
<div class="param-group">
<div class="param-label">충전기 에러 코드</div>
<select class="param-input" id="p-error-code">
<option value="NoError">NoError (정상)</option>
<option value="ConnectorLockFailure">ConnectorLockFailure</option>
<option value="GroundFailure">GroundFailure</option>
<option value="HighTemperature">HighTemperature</option>
<option value="OverCurrentFailure">OverCurrentFailure</option>
<option value="OverVoltage">OverVoltage</option>
<option value="UnderVoltage">UnderVoltage</option>
<option value="PowerMeterFailure">PowerMeterFailure</option>
<option value="PowerSwitchFailure">PowerSwitchFailure</option>
<option value="InternalError">InternalError</option>
<option value="OtherError">OtherError</option>
</select>
</div>
</div>
</div>
<!-- 실행 버튼 -->
<button class="btn-run" id="btn-run" onclick="runFullTest()">전체 흐름 실행</button>
<div class="btn-row">
<button class="btn-step" onclick="runStepByStep()">단계별 실행</button>
<button class="btn-step" onclick="resetAll()">초기화</button>
</div>
</div>
</div>
<!-- 우측: 실행 결과 -->
<div class="results-panel" id="results"></div>
</div>
</div>
<script>
const API='/api/v1';
const STEPS=[
{id:'health',num:'0',title:'헬스체크',sub:'서버 연결 확인'},
{id:'register',num:'1',title:'충전기 등록',sub:'chargeBoxId 등록'},
{id:'reset',num:'1-1',title:'세션 정리',sub:'미완료 세션 취소'},
{id:'status',num:'2',title:'충전기 상태',sub:'Available 설정'},
{id:'session',num:'3',title:'세션 생성',sub:'QR 스캔 시뮬레이션'},
{id:'payment',num:'4',title:'결제 준비',sub:'orderId 발급'},
{id:'authorize',num:'5',title:'결제 우회',sub:'AUTHORIZED 강제 설정'},
{id:'start',num:'6',title:'StartTransaction',sub:'충전 시작'},
{id:'meter',num:'7',title:'MeterValues',sub:'실시간 전력량 보고'},
{id:'poll',num:'8',title:'세션 조회',sub:'충전 중 상태 확인'},
{id:'stop',num:'9',title:'StopTransaction',sub:'충전 종료 + 정산'},
{id:'billing',num:'10',title:'최종 정산',sub:'요금 내역'},
{id:'dashboard',num:'11',title:'대시보드',sub:'전체 요약'},
];
let running=false, stepMode=false, stepResolve=null;
let sessionUid='', idTag='', txnId=0;
function getParams(){
const targetKwh=parseFloat(document.getElementById('p-target-kwh').value);
const meterStart=parseInt(document.getElementById('p-meter-start').value);
const meterSteps=parseInt(document.getElementById('p-meter-steps').value);
const stepWh=Math.round(targetKwh*1000/meterSteps);
const txnInput=document.getElementById('p-txn-id').value;
return {
charger:document.getElementById('p-charger').value,
connector:parseInt(document.getElementById('p-connector').value),
name:document.getElementById('p-name').value,
location:document.getElementById('p-location').value,
power:parseFloat(document.getElementById('p-power').value),
meterStart,
targetKwh,
meterSteps,
stepWh,
meterStop:meterStart+targetKwh*1000,
txnId:txnInput?parseInt(txnInput):Math.floor(Date.now()/1000)%100000,
amount:parseInt(document.getElementById('p-amount').value),
stopReason:document.getElementById('p-stop-reason').value,
errorCode:document.getElementById('p-error-code').value,
delay:parseInt(document.getElementById('p-delay').value),
}
}
function updateChargePreview(){
const v=document.getElementById('p-target-kwh').value;
document.getElementById('charge-val').textContent=v+' kWh';
}
// ── 프리셋 ──
const PRESETS={
basic:{power:7,targetKwh:30,meterSteps:4,amount:10000,delay:500,errorCode:'NoError',stopReason:'Local'},
fast:{power:50,targetKwh:60,meterSteps:6,amount:30000,delay:300,errorCode:'NoError',stopReason:'Local'},
slow:{power:3,targetKwh:10,meterSteps:3,amount:5000,delay:800,errorCode:'NoError',stopReason:'Local'},
short:{power:7,targetKwh:5,meterSteps:2,amount:2000,delay:300,errorCode:'NoError',stopReason:'Local'},
full:{power:11,targetKwh:80,meterSteps:8,amount:50000,delay:400,errorCode:'NoError',stopReason:'Local'},
error:{power:7,targetKwh:15,meterSteps:3,amount:10000,delay:500,errorCode:'OverCurrentFailure',stopReason:'EmergencyStop'},
};
function applyPreset(name){
const p=PRESETS[name];
document.getElementById('p-power').value=p.power;
document.getElementById('p-target-kwh').value=p.targetKwh;
document.getElementById('p-meter-steps').value=p.meterSteps;
document.getElementById('p-amount').value=p.amount;
document.getElementById('p-delay').value=p.delay;
document.getElementById('p-error-code').value=p.errorCode;
document.getElementById('p-stop-reason').value=p.stopReason;
updateChargePreview();
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
event.target.classList.add('active');
}
// ── API 호출 ──
async function api(path,opt={}){
const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opt});
const data=await r.json().catch(()=>({error:'응답 파싱 실패',status:r.status}));
return {ok:r.ok,status:r.status,data};
}
// ── UI ──
function renderSteps(){
document.getElementById('results').innerHTML=STEPS.map(s=>`
<div class="step-card waiting" id="step-${s.id}" onclick="toggleStep('${s.id}')">
<div class="step-header">
<div class="step-num">${s.num}</div>
<div style="flex:1"><div class="step-title">${s.title}</div><div class="step-subtitle">${s.sub}</div></div>
<span class="step-status wait" id="status-${s.id}">대기</span>
<span class="step-time" id="time-${s.id}"></span>
</div>
<div class="step-body"><div class="step-json" id="json-${s.id}"></div></div>
</div>
`).join('');
}
function setStepState(id,state,data,ms){
const card=document.getElementById('step-'+id);
const status=document.getElementById('status-'+id);
const time=document.getElementById('time-'+id);
const json=document.getElementById('json-'+id);
card.className='step-card '+state+(state!=='waiting'?' open':'');
const labels={success:'성공',error:'실패',running:'실행중',waiting:'대기'};
const classes={success:'ok',error:'fail',running:'run',waiting:'wait'};
status.className='step-status '+classes[state];
status.textContent=labels[state];
if(ms!==undefined)time.textContent=ms+'ms';
if(data!==undefined)json.innerHTML=syntaxHL(JSON.stringify(data,null,2));
card.scrollIntoView({behavior:'smooth',block:'nearest'});
}
function syntaxHL(s){
return s.replace(/(".*?")\s*:/g,'<span class="k">$1</span>:')
.replace(/:\s*(".*?")/g,': <span class="s">$1</span>')
.replace(/:\s*(\d+\.?\d*)/g,': <span class="n">$1</span>')
.replace(/:\s*(true|false|null)/g,': <span class="b">$1</span>');
}
function toggleStep(id){
document.getElementById('step-'+id).classList.toggle('open');
}
function addSummary(billing){
const el=document.createElement('div');
el.className='summary-card fade-in';
el.innerHTML=`<h3>충전 완료</h3>
<div class="summary-grid">
<div class="summary-item"><div class="summary-val">${billing.charged_kwh}</div><div class="summary-label">kWh 충전</div></div>
<div class="summary-item"><div class="summary-val">${billing.total_bill.toLocaleString()}</div><div class="summary-label">원 요금</div></div>
<div class="summary-item"><div class="summary-val">${billing.saved_vs_cpo.toLocaleString()}</div><div class="summary-label">원 절감</div></div>
</div>
<div class="summary-saved">CPO 대비 ${billing.saved_vs_cpo.toLocaleString()}원 절감 (전기 ${billing.electricity_cost.toLocaleString()}원 + 서비스 ${billing.service_fee.toLocaleString()}원)</div>`;
document.getElementById('results').appendChild(el);
}
// ── 딜레이 + 스텝 ──
function wait(ms){return new Promise(r=>setTimeout(r,ms))}
function waitStep(){return stepMode?new Promise(r=>{stepResolve=r}):Promise.resolve()}
// ── 실행 ──
async function runFullTest(){
if(running)return;
stepMode=false;
await execute();
}
async function runStepByStep(){
if(running)return;
stepMode=true;
const btn=document.querySelector('.btn-step');
btn.textContent='다음 단계 ▶';
btn.onclick=()=>{if(stepResolve){stepResolve();stepResolve=null}};
await execute();
btn.textContent='단계별 실행';
btn.onclick=()=>runStepByStep();
}
async function execute(){
running=true;
const btn=document.getElementById('btn-run');
btn.disabled=true;btn.classList.add('running');btn.textContent='실행 중...';
const P=getParams();
renderSteps();
async function step(id,fn){
await waitStep();
setStepState(id,'running');
const t0=performance.now();
try{
const result=await fn();
const ms=Math.round(performance.now()-t0);
setStepState(id,'success',result,ms);
await wait(P.delay);
return result;
}catch(e){
const ms=Math.round(performance.now()-t0);
setStepState(id,'error',{error:e.message||e,detail:e.data||null},ms);
throw e;
}
}
try{
// 0. 헬스체크
await step('health',async()=>{
const r=await api('/../health');
if(!r.ok)throw{message:'서버 연결 실패',data:r.data};
return r.data;
});
// 1. 충전기 등록
await step('register',async()=>{
const r=await api('/chargers/',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,name:P.name,location:P.location,
connector_count:1,power_kw:P.power,
})});
if(r.status===409)return{message:'이미 등록됨',charge_box_id:P.charger};
if(!r.ok)throw{message:'등록 실패',data:r.data};
return r.data;
});
// 1-1. 세션 정리
await step('reset',async()=>{
const r=await api('/sessions/reset/'+P.charger,{method:'POST'});
return r.data;
});
// 2. 상태 업데이트
const statusVal=P.errorCode==='NoError'?'Available':'Faulted';
await step('status',async()=>{
const r=await api('/ocpp/status',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
status:statusVal,error_code:P.errorCode,
})});
if(!r.ok)throw{message:'상태 업데이트 실패',data:r.data};
return r.data;
});
// 에러 시뮬레이션 시 여기서 중단
if(P.errorCode!=='NoError'){
addSummary({charged_kwh:0,total_bill:0,saved_vs_cpo:0,electricity_cost:0,service_fee:0});
const remaining=['session','payment','authorize','start','meter','poll','stop','billing','dashboard'];
remaining.forEach(id=>setStepState(id,'error',{message:'에러 상태에서 충전 불가',errorCode:P.errorCode}));
return;
}
// 3. 세션 생성
const session=await step('session',async()=>{
const r=await api('/sessions/',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
})});
if(!r.ok)throw{message:'세션 생성 실패',data:r.data};
sessionUid=r.data.session_uid;
idTag=r.data.id_tag;
return r.data;
});
// 4. 결제 준비
await step('payment',async()=>{
const r=await api('/payments/prepare',{method:'POST',body:JSON.stringify({
session_uid:sessionUid,amount:P.amount,
})});
if(!r.ok)throw{message:'결제 준비 실패',data:r.data};
return r.data;
});
// 5. 결제 우회
await step('authorize',async()=>{
const r=await api('/sessions/'+sessionUid+'/force-authorize',{method:'POST'});
if(!r.ok)throw{message:'인증 실패',data:r.data};
return r.data;
});
// 6. StartTransaction
txnId=P.txnId;
await step('start',async()=>{
const r=await api('/ocpp/start-transaction',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
id_tag:idTag,meter_start:P.meterStart,transaction_id:txnId,
})});
if(!r.ok)throw{message:'StartTransaction 실패',data:r.data};
return r.data;
});
// 7. MeterValues
await step('meter',async()=>{
const results=[];
for(let i=1;i<=P.meterSteps;i++){
const wh=P.meterStart+Math.round(P.stepWh*i);
const r=await api('/ocpp/meter-values',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,connector_id:P.connector,
transaction_id:txnId,value:wh,
})});
results.push({step:i,wh,kwh:((wh-P.meterStart)/1000).toFixed(1),status:r.data?.status});
await wait(Math.max(100,P.delay/2));
}
return {meter_reports:results,total_reports:P.meterSteps};
});
// 8. 세션 조회
await step('poll',async()=>{
const r=await api('/sessions/'+sessionUid);
return r.data;
});
// 9. StopTransaction
const stopResult=await step('stop',async()=>{
const r=await api('/ocpp/stop-transaction',{method:'POST',body:JSON.stringify({
charge_box_id:P.charger,transaction_id:txnId,
meter_stop:P.meterStop,reason:P.stopReason,
})});
if(!r.ok)throw{message:'StopTransaction 실패',data:r.data};
return r.data;
});
// 10. 정산
const billing=await step('billing',async()=>{
const r=await api('/sessions/'+sessionUid+'/billing');
if(!r.ok)throw{message:'정산 조회 실패',data:r.data};
return r.data;
});
// 11. 대시보드
await step('dashboard',async()=>{
const r=await api('/dashboard/summary');
return r.data;
});
addSummary(billing);
}catch(e){
console.error('테스트 중단:',e);
}finally{
running=false;
btn.disabled=false;btn.classList.remove('running');btn.textContent='전체 흐름 실행';
}
}
function resetAll(){
running=false;stepMode=false;stepResolve=null;
sessionUid='';idTag='';txnId=0;
document.getElementById('results').innerHTML=`
<div style="text-align:center;padding:60px 20px;color:var(--text-3)">
<div style="font-size:40px;margin-bottom:16px;opacity:0.2">⚡</div>
<div style="font-size:14px">파라미터를 설정하고 실행 버튼을 누르세요</div>
<div style="font-family:var(--font-mono);font-size:11px;margin-top:8px">전체 흐름 또는 단계별 실행 가능</div>
</div>`;
}
// 초기화
document.addEventListener('DOMContentLoaded',()=>{resetAll();updateChargePreview()});
</script>
</body>
</html>