Files
jklee 08ea11ea37 Initial commit: WMS 교육 시뮬레이터 (입고 모듈 골격)
- Unity 6.4 URP 프로젝트 베이스
- Inbound 비즈니스 로직: Scan/Putaway/Scoring 서비스 + InboundManager
- 데이터 모델: Item/Location/Scenario (ScriptableObject) + StorageZone
- 샘플 시나리오 inbound_basic.json, 설계문서/목업 포함

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:09:29 +09:00

215 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SINO WMS — 입고 교육 (화면 목업)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Apple SD Gothic Neo", sans-serif;
background: #1f2430; color: #e6e9ef; min-height: 100vh;
display: flex; gap: 24px; padding: 24px; align-items: flex-start;
flex-wrap: wrap; justify-content: center;
}
h1 { font-size: 18px; margin-bottom: 4px; }
.sub { color: #8b93a7; font-size: 13px; margin-bottom: 16px; }
/* 탑다운 창고 */
.warehouse {
background: #272d3a; border-radius: 12px; padding: 18px;
box-shadow: 0 8px 24px rgba(0,0,0,.3);
}
.grid { display: grid; grid-template-columns: repeat(5, 56px); gap: 8px; margin-top: 12px; }
.loc {
height: 56px; border-radius: 8px; display: flex; flex-direction: column;
align-items: center; justify-content: center; font-size: 11px; cursor: pointer;
border: 2px solid transparent; transition: .15s; user-select: none;
}
.loc small { color: #aab2c5; font-size: 9px; }
.ambient { background: #3a4256; }
.chilled { background: #1e4a5f; }
.frozen { background: #1e3a6f; }
.loc:hover { border-color: #6ea8fe; }
.loc.target { outline: 2px dashed #ffd166; }
.loc.filled { opacity: .5; cursor: default; }
.legend { font-size: 11px; color: #8b93a7; margin-top: 12px; display:flex; gap:12px; }
.dot { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:4px; vertical-align:middle; }
/* PDA */
.pda {
width: 300px; background: #11151c; border-radius: 28px; padding: 16px;
border: 8px solid #0a0d12; box-shadow: 0 12px 32px rgba(0,0,0,.5);
}
.pda-screen { background: #f4f6fb; color: #1f2430; border-radius: 14px; padding: 16px; min-height: 460px; }
.step-bar { display:flex; gap:4px; margin-bottom: 14px; }
.step-bar span { flex:1; height:4px; border-radius:2px; background:#d3d9e6; }
.step-bar span.on { background:#2b6fff; }
.step-title { font-size: 13px; color:#2b6fff; font-weight:700; margin-bottom: 4px; }
.step-head { font-size: 17px; font-weight: 800; margin-bottom: 12px; }
.line-card { background:#fff; border:1px solid #e3e8f2; border-radius:10px; padding:12px; margin-bottom:10px; }
.line-card .name { font-weight:700; font-size:14px; }
.line-card .meta { font-size:12px; color:#6b7280; margin-top:2px; }
.badge { display:inline-block; font-size:10px; padding:2px 6px; border-radius:6px; margin-left:6px; }
.b-ambient{background:#e7ecfb;color:#2b4eff;} .b-chilled{background:#dff1f6;color:#0c7491;}
button {
width:100%; padding:12px; border:0; border-radius:10px; background:#2b6fff; color:#fff;
font-size:14px; font-weight:700; cursor:pointer; margin-top:6px;
}
button.ghost { background:#eef1f8; color:#2b6fff; }
button:active { transform: translateY(1px); }
input { width:100%; padding:10px; border:1px solid #cbd3e3; border-radius:8px; font-size:14px; margin:6px 0; }
.feedback { font-size:12px; padding:8px 10px; border-radius:8px; margin-top:10px; }
.fb-ok { background:#e3f7ea; color:#1a7f43; }
.fb-warn { background:#fdecec; color:#c0392b; }
.scan-anim { text-align:center; padding:20px; color:#6b7280; font-size:13px; }
.result-score { font-size:42px; font-weight:900; text-align:center; color:#2b6fff; margin:10px 0; }
.err-item { font-size:12px; color:#c0392b; padding:6px 0; border-bottom:1px dashed #eee; }
</style>
</head>
<body>
<div class="warehouse">
<h1>창고 (탑다운 뷰)</h1>
<div class="sub">로케이션을 클릭해 적치합니다</div>
<div class="grid" id="grid"></div>
<div class="legend">
<span><i class="dot" style="background:#3a4256"></i>상온</span>
<span><i class="dot" style="background:#1e4a5f"></i>냉장</span>
<span><i class="dot" style="background:#1e3a6f"></i>냉동</span>
</div>
</div>
<div>
<h1>PDA — 입고 교육</h1>
<div class="sub">기본 입고 시나리오 (INB-001)</div>
<div class="pda">
<div class="pda-screen">
<div class="step-bar" id="stepbar"></div>
<div id="content"></div>
<div id="feedback"></div>
</div>
</div>
</div>
<script>
// ── 샘플 시나리오 (Assets/Resources/Scenarios/inbound_basic.json 과 동일) ──
const scenario = {
title: "기본 입고 교육", timeLimit: 300,
lines: [
{ sku:"SKU-1001", name:"생수 500ml (24입)", barcode:"8800000010013", zone:"Ambient", unit:2, expected:10, actual:10, loc:"A-01" },
{ sku:"SKU-2002", name:"우유 1L (12입)", barcode:"8800000020026", zone:"Chilled", unit:3, expected:8, actual:6, loc:"C-03" },
{ sku:"SKU-3003", name:"라면 박스 (40입)", barcode:"8800000030039", zone:"Ambient", unit:5, expected:4, actual:4, loc:"A-08" },
]
};
const STEPS = ["입고예정 확인","바코드 스캔","검수","로케이션 선택","적치","완료"];
// 로케이션 격자 (코드/존/용량)
const locs = [];
const zones = ["ambient","ambient","ambient","chilled","frozen"];
let n=1;
for (let r=0;r<5;r++) for (let c=0;c<5;c++){
const code = String.fromCharCode(65+r)+"-"+String(c+1).padStart(2,"0");
locs.push({ code, zone: zones[r], cap:30, used:0, filled:false });
}
let step=0, lineIdx=0, errors=[], counted=null, picking=null, startT=Date.now();
const $ = id => document.getElementById(id);
function renderGrid(){
const g=$("grid"); g.innerHTML="";
locs.forEach(l=>{
const d=document.createElement("div");
d.className="loc "+l.zone+(l.filled?" filled":"")+(picking&&step===3?" ":"");
d.innerHTML=`${l.code}<small>${l.used}/${l.cap}</small>`;
if(step===3 && !l.filled) d.onclick=()=>putaway(l);
g.appendChild(d);
});
}
function renderBar(){
$("stepbar").innerHTML = STEPS.map((_,i)=>`<span class="${i<=step?'on':''}"></span>`).join("");
}
function fb(msg,ok){ $("feedback").innerHTML = msg?`<div class="feedback ${ok?'fb-ok':'fb-warn'}">${msg}</div>`:""; }
function zoneBadge(z){ return z==="Chilled"?'<span class="badge b-chilled">냉장</span>':'<span class="badge b-ambient">상온</span>'; }
function render(){
renderBar(); renderGrid();
const c=$("content"); const line=scenario.lines[lineIdx];
if(step===0){
c.innerHTML = `<div class="step-title">STEP 1 / 입고예정</div>
<div class="step-head">오늘 입고예정 ${scenario.lines.length}건</div>` +
scenario.lines.map(l=>`<div class="line-card"><div class="name">${l.name}${zoneBadge(l.zone)}</div>
<div class="meta">예정 ${l.expected} · ${l.barcode}</div></div>`).join("") +
`<button onclick="next()">확인 완료 → 스캔 시작</button>`;
}
else if(step===1){
c.innerHTML = `<div class="step-title">STEP 2 / 스캔 (${lineIdx+1}/${scenario.lines.length})</div>
<div class="step-head">바코드를 스캔하세요</div>
<div class="line-card"><div class="name">${line.name}${zoneBadge(line.zone)}</div>
<div class="meta">정답 바코드: ${line.barcode}</div></div>
<input id="bc" placeholder="바코드 입력 또는 아래 버튼" value="${line.barcode}">
<button onclick="scan(document.getElementById('bc').value)">📷 스캔</button>
<button class="ghost" onclick="scan('0000000000000')">잘못된 바코드 스캔 (오류 체험)</button>`;
}
else if(step===2){
c.innerHTML = `<div class="step-title">STEP 3 / 검수</div>
<div class="step-head">실제 수량을 세어 입력</div>
<div class="line-card"><div class="name">${line.name}</div>
<div class="meta">예정 수량: ${line.expected}</div>
<div class="meta">실제 도착: <b>${line.actual}</b> ${line.expected!==line.actual?'⚠ 예정과 다름':''}</div></div>
<input id="qty" type="number" value="${line.expected}">
<button onclick="inspect(+document.getElementById('qty').value)">검수 확정</button>`;
}
else if(step===3){
c.innerHTML = `<div class="step-title">STEP 4 / 로케이션 선택</div>
<div class="step-head">적치할 위치 클릭 →</div>
<div class="line-card"><div class="name">${line.name}${zoneBadge(line.zone)}</div>
<div class="meta">필요 용량: ${counted*line.unit} · 요구 존: ${line.zone==='Chilled'?'냉장':'상온'}</div>
<div class="meta" style="color:#2b6fff">왼쪽 격자에서 ${line.zone==='Chilled'?'냉장(청록)':'상온(회색)'} 칸 선택</div></div>`;
picking=line;
}
else if(step===5){
const secs=Math.round((Date.now()-startT)/1000);
const score=Math.max(0, scenario.lines.length*100 - errors.length*25);
c.innerHTML = `<div class="step-title">완료</div>
<div class="step-head">교육 결과</div>
<div class="result-score">${score}점</div>
<div class="line-card"><div class="meta">처리 ${scenario.lines.length}건 · 소요 ${secs}초 · 오류 ${errors.length}건</div></div>
${errors.length?'<div class="line-card">'+errors.map(e=>`<div class="err-item">⚠ ${e}</div>`).join("")+'</div>':'<div class="feedback fb-ok">완벽합니다! 오류 없음 👏</div>'}
<button onclick="restart()">다시 하기</button>`;
}
}
function next(){ step=1; fb(""); render(); }
function scan(bc){
const line=scenario.lines[lineIdx];
if(bc!==line.barcode){ errors.push(`오스캔: 입고예정에 없는 바코드(${bc})`); fb("⚠ 입고예정에 없는 바코드입니다.",false); return; }
fb("스캔 완료: "+line.name,true); step=2; render();
}
function inspect(q){
const line=scenario.lines[lineIdx]; counted=q;
if(q!==line.actual){ errors.push(`${line.name}: 검수 ${q} / 실제 ${line.actual} 불일치`); fb("⚠ 검수 수량이 실제와 다릅니다 (그래도 진행).",false); }
else if(line.expected!==line.actual){ fb(`수량 차이 정상 처리: 예정 ${line.expected} → 실입고 ${line.actual}`,true); }
else fb("검수 일치",true);
step=3; render();
}
function putaway(l){
const line=scenario.lines[lineIdx];
const needZone = line.zone==="Chilled"?"chilled":(line.zone==="Frozen"?"frozen":"ambient");
const need=counted*line.unit;
if(l.zone!==needZone){ errors.push(`${line.name}: 보관조건 위반(${l.code})`); fb("⚠ 보관 조건이 맞지 않는 로케이션입니다.",false); return; }
if(l.used+need>l.cap){ errors.push(`${l.code}: 용량 초과`); fb("⚠ 용량 초과입니다.",false); return; }
l.used+=need; l.filled=true; picking=null;
fb("적치 완료: "+l.code,true);
lineIdx++;
if(lineIdx<scenario.lines.length){ step=1; setTimeout(()=>{fb("");render();},600); render(); }
else { step=5; render(); }
}
function restart(){ step=0;lineIdx=0;errors=[];counted=null;picking=null;startT=Date.now(); locs.forEach(l=>{l.used=0;l.filled=false;}); fb(""); render(); }
render();
</script>
</body>
</html>