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>
This commit is contained in:
jklee
2026-06-14 19:09:29 +09:00
commit 08ea11ea37
76 changed files with 8328 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using SinoWms.Core;
using SinoWms.Data;
namespace SinoWms.Inbound
{
/// <summary>
/// 입고 미션 진행 오케스트레이터.
/// UI(PDA/리스트/결과 뷰)는 이 클래스의 public 메서드를 호출하고 이벤트를 구독한다.
/// 검증 로직 자체는 Scan/Putaway/Scoring 서비스에 위임한다.
/// </summary>
public class InboundManager : MonoBehaviour
{
[SerializeField] private InboundScenario scenario;
public event Action<InboundStep> StepChanged;
public event Action<string> Feedback; // 화면에 띄울 안내/오류 문구
public event Action<int> Completed; // 최종 점수
private readonly List<LineProgress> _progress = new List<LineProgress>();
public IReadOnlyList<LineProgress> Progress => _progress;
private ScanService _scan;
private PutawayService _putaway;
private ScoringSystem _scoring;
private float _startTime;
private InboundStep _step;
public InboundStep Step
{
get => _step;
private set { _step = value; StepChanged?.Invoke(value); }
}
public void StartMission()
{
if (scenario == null)
{
Debug.LogError("[InboundManager] 시나리오가 할당되지 않았습니다.");
return;
}
_progress.Clear();
foreach (var line in scenario.lines)
_progress.Add(new LineProgress(line));
_scan = new ScanService(_progress);
_putaway = new PutawayService();
_scoring = new ScoringSystem();
_startTime = Time.time;
Step = InboundStep.ReviewAsn;
}
/// <summary>1→2 단계 진행 (입고예정 확인 완료).</summary>
public void ConfirmAsnReviewed() => Step = InboundStep.Scan;
/// <summary>PDA 바코드 스캔.</summary>
public LineProgress HandleScan(string barcode)
{
var result = _scan.Scan(barcode, out var matched);
switch (result)
{
case ScanResult.Ok:
Feedback?.Invoke($"스캔 완료: {matched.line.item.displayName}");
Step = InboundStep.Inspect;
break;
case ScanResult.UnknownBarcode:
_scoring.Report(ErrorType.WrongScan, $"입고예정에 없는 바코드입니다: {barcode}");
Feedback?.Invoke("⚠ 입고예정에 없는 바코드입니다. 다시 확인하세요.");
break;
case ScanResult.AlreadyScanned:
Feedback?.Invoke("이미 스캔한 품목입니다.");
break;
}
return matched;
}
/// <summary>검수: 실제로 센 수량을 입력.</summary>
public void HandleInspect(LineProgress lp, int countedQty)
{
lp.countedQty = countedQty;
lp.inspected = true;
if (!lp.CountMatchesActual)
{
// 실수량과 다르게 입력 → 검수 오류
_scoring.Report(ErrorType.QtyMissed,
$"{lp.line.item.displayName}: 입력 {countedQty} / 실제 {lp.line.actualQty} 불일치");
Feedback?.Invoke("⚠ 검수 수량이 실제와 다릅니다.");
}
else if (lp.HasQtyDiscrepancy)
{
// 예정과 다르지만 실제는 정확히 셈 → 차이 정상 처리(교육 성공 케이스)
Feedback?.Invoke($"수량 차이 확인: 예정 {lp.line.expectedQty} / 실입고 {lp.line.actualQty}");
}
Step = InboundStep.SelectLocation;
}
/// <summary>로케이션 선택 + 적치.</summary>
public void HandlePutaway(LineProgress lp, LocationDefinition loc)
{
var result = _putaway.Commit(lp, loc);
switch (result)
{
case PutawayResult.Ok:
Feedback?.Invoke($"적치 완료: {loc.locationCode}");
Step = InboundStep.Putaway;
TryFinish();
break;
case PutawayResult.ZoneMismatch:
_scoring.Report(ErrorType.ZoneViolation,
$"{lp.line.item.displayName}: {lp.line.item.requiredZone} 품목을 {loc.zone} 존에 적치 시도");
Feedback?.Invoke("⚠ 보관 조건이 맞지 않는 로케이션입니다.");
break;
case PutawayResult.OverCapacity:
_scoring.Report(ErrorType.OverCapacity, $"{loc.locationCode}: 용량 초과");
Feedback?.Invoke("⚠ 로케이션 용량을 초과합니다.");
break;
case PutawayResult.NotInspected:
_scoring.Report(ErrorType.PutawayBeforeInspect, "검수 전 적치 시도");
Feedback?.Invoke("⚠ 검수 후 적치할 수 있습니다.");
break;
}
}
/// <summary>모든 라인이 적치되면 채점.</summary>
private void TryFinish()
{
foreach (var lp in _progress)
if (!lp.putaway) { Step = InboundStep.Scan; return; } // 남은 라인 계속 처리
int score = _scoring.Calculate(
_progress.Count, Time.time - _startTime, scenario.timeLimitSeconds);
Step = InboundStep.Completed;
Completed?.Invoke(score);
}
public IReadOnlyList<ScoreError> GetErrors() => _scoring?.Errors ?? new List<ScoreError>();
}
}
+27
View File
@@ -0,0 +1,27 @@
using SinoWms.Data;
namespace SinoWms.Inbound
{
/// <summary>입고예정 1라인의 런타임 진행 상태.</summary>
public class LineProgress
{
public readonly InboundLine line;
public bool scanned; // 바코드 스캔 완료
public int countedQty; // 검수 입력 수량
public bool inspected; // 검수(수량 확정) 완료
public LocationDefinition placedLocation; // 적치된 위치
public bool putaway; // 적치 완료
public LineProgress(InboundLine line)
{
this.line = line;
}
/// <summary>검수 수량이 실제 도착 수량과 일치하는가.</summary>
public bool CountMatchesActual => countedQty == line.actualQty;
/// <summary>예정 수량과 실제 수량이 다른가(교육용 차이 케이스).</summary>
public bool HasQtyDiscrepancy => line.expectedQty != line.actualQty;
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using SinoWms.Data;
namespace SinoWms.Inbound
{
public enum PutawayResult
{
Ok,
ZoneMismatch, // 보관 조건 위반 (예: 냉장품을 상온존에)
OverCapacity, // 용량 초과
NotInspected // 검수 전 적치 시도
}
/// <summary>로케이션 적치 규칙 검증 + 용량 반영.</summary>
public class PutawayService
{
// 로케이션별 현재 점유 용량
private readonly Dictionary<LocationDefinition, int> _occupied = new Dictionary<LocationDefinition, int>();
/// <summary>적치 가능 여부만 검사(상태 변경 없음).</summary>
public PutawayResult Validate(LineProgress lp, LocationDefinition loc)
{
if (!lp.inspected) return PutawayResult.NotInspected;
var item = lp.line.item;
if (item.requiredZone != loc.zone) return PutawayResult.ZoneMismatch;
int need = lp.countedQty * item.unitVolume;
int used = _occupied.TryGetValue(loc, out var v) ? v : 0;
if (used + need > loc.capacity) return PutawayResult.OverCapacity;
return PutawayResult.Ok;
}
/// <summary>검증 통과 시 실제 적치(용량 반영).</summary>
public PutawayResult Commit(LineProgress lp, LocationDefinition loc)
{
var result = Validate(lp, loc);
if (result != PutawayResult.Ok) return result;
int need = lp.countedQty * lp.line.item.unitVolume;
_occupied[loc] = (_occupied.TryGetValue(loc, out var v) ? v : 0) + need;
lp.placedLocation = loc;
lp.putaway = true;
return PutawayResult.Ok;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Collections.Generic;
namespace SinoWms.Inbound
{
public enum ScanResult
{
Ok, // 예정 품목과 일치
UnknownBarcode, // 어떤 입고예정에도 없는 바코드 (오스캔)
AlreadyScanned // 이미 스캔한 라인
}
/// <summary>바코드 스캔 검증. UI(PDA)에서 입력받은 문자열을 라인과 매칭한다.</summary>
public class ScanService
{
private readonly IReadOnlyList<LineProgress> _lines;
public ScanService(IReadOnlyList<LineProgress> lines)
{
_lines = lines;
}
/// <summary>바코드 문자열로 라인을 찾아 스캔 처리. 매칭된 라인을 out 으로 반환.</summary>
public ScanResult Scan(string barcode, out LineProgress matched)
{
matched = null;
foreach (var lp in _lines)
{
if (lp.line.item != null && lp.line.item.barcode == barcode)
{
matched = lp;
if (lp.scanned) return ScanResult.AlreadyScanned;
lp.scanned = true;
return ScanResult.Ok;
}
}
return ScanResult.UnknownBarcode;
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Collections.Generic;
namespace SinoWms.Inbound
{
public enum ErrorType
{
WrongScan, // 잘못된 바코드 스캔
QtyMissed, // 수량 차이를 놓침
ZoneViolation, // 보관 조건 위반 적치
OverCapacity, // 용량 초과 적치
PutawayBeforeInspect // 검수 전 적치 시도
}
public struct ScoreError
{
public ErrorType type;
public string message; // 교육용 피드백 문구
}
/// <summary>점수/오류 집계. 정확도와 처리시간으로 최종 점수를 낸다.</summary>
public class ScoringSystem
{
public const int BasePerLine = 100;
public const int Penalty = 25;
private readonly List<ScoreError> _errors = new List<ScoreError>();
public IReadOnlyList<ScoreError> Errors => _errors;
public void Report(ErrorType type, string message)
{
_errors.Add(new ScoreError { type = type, message = message });
}
/// <param name="lineCount">처리한 라인 수</param>
/// <param name="elapsedSeconds">소요 시간</param>
/// <param name="timeLimitSeconds">제한 시간(0이면 시간 보너스 없음)</param>
public int Calculate(int lineCount, float elapsedSeconds, float timeLimitSeconds)
{
int score = lineCount * BasePerLine - _errors.Count * Penalty;
// 제한 시간 내 완료 시 시간 보너스 (남은 비율의 최대 +50)
if (timeLimitSeconds > 0f && elapsedSeconds < timeLimitSeconds)
{
float ratio = 1f - (elapsedSeconds / timeLimitSeconds);
score += UnityEngine.Mathf.RoundToInt(ratio * 50f);
}
return UnityEngine.Mathf.Max(0, score);
}
}
}