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:
@@ -0,0 +1,13 @@
|
||||
namespace SinoWms.Core
|
||||
{
|
||||
/// <summary>입고 미션의 단계. UI/매니저가 이 상태로 화면을 전환한다.</summary>
|
||||
public enum InboundStep
|
||||
{
|
||||
ReviewAsn, // 1. 입고예정 확인
|
||||
Scan, // 2. 바코드 스캔
|
||||
Inspect, // 3. 가입고(검수) - 수량 비교
|
||||
SelectLocation, // 4. 로케이션 선택
|
||||
Putaway, // 5. 적치
|
||||
Completed // 6. 완료/채점
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace SinoWms.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// 입고 교육 시나리오. 입고예정 라인 + 교육용 "함정"(실수량 차이 등)을 담는다.
|
||||
/// 코드 수정 없이 .asset 또는 JSON 으로 교체하여 고객사별 시나리오를 만든다.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "Scenario_", menuName = "SinoWMS/Inbound Scenario")]
|
||||
public class InboundScenario : ScriptableObject
|
||||
{
|
||||
public string scenarioId;
|
||||
public string title;
|
||||
|
||||
[Tooltip("제한 시간(초). 0이면 무제한.")]
|
||||
public float timeLimitSeconds = 0f;
|
||||
|
||||
public List<InboundLine> lines = new List<InboundLine>();
|
||||
}
|
||||
|
||||
/// <summary>입고예정 1줄 = 품목 1종.</summary>
|
||||
[System.Serializable]
|
||||
public class InboundLine
|
||||
{
|
||||
public ItemDefinition item;
|
||||
|
||||
[Tooltip("ASN상 예정 수량")]
|
||||
public int expectedQty;
|
||||
|
||||
[Tooltip("실제 도착 수량. 예정과 다르면 검수 단계에서 교육 포인트가 된다.")]
|
||||
public int actualQty;
|
||||
|
||||
[Tooltip("정답 로케이션(채점 기준). 비워두면 존/용량 규칙만으로 검증.")]
|
||||
public LocationDefinition suggestedLocation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace SinoWms.Data
|
||||
{
|
||||
/// <summary>품목 마스터. 고객사별로 .asset 파일로 만들어 교체한다.</summary>
|
||||
[CreateAssetMenu(fileName = "Item_", menuName = "SinoWMS/Item Definition")]
|
||||
public class ItemDefinition : ScriptableObject
|
||||
{
|
||||
[Tooltip("재고 식별 코드 (SKU)")]
|
||||
public string sku;
|
||||
|
||||
[Tooltip("품목명")]
|
||||
public string displayName;
|
||||
|
||||
[Tooltip("스캔 대상 바코드 값")]
|
||||
public string barcode;
|
||||
|
||||
[Tooltip("요구 보관 조건")]
|
||||
public StorageZone requiredZone = StorageZone.Ambient;
|
||||
|
||||
[Tooltip("한 개당 차지하는 부피(용량 단위)")]
|
||||
public int unitVolume = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace SinoWms.Data
|
||||
{
|
||||
/// <summary>로케이션(적치 위치) 마스터.</summary>
|
||||
[CreateAssetMenu(fileName = "Loc_", menuName = "SinoWMS/Location Definition")]
|
||||
public class LocationDefinition : ScriptableObject
|
||||
{
|
||||
[Tooltip("로케이션 코드 (예: A-01-03)")]
|
||||
public string locationCode;
|
||||
|
||||
[Tooltip("이 로케이션이 지원하는 보관 조건")]
|
||||
public StorageZone zone = StorageZone.Ambient;
|
||||
|
||||
[Tooltip("최대 수용 용량(부피 단위)")]
|
||||
public int capacity = 100;
|
||||
|
||||
[Tooltip("탑다운 씬에서의 격자 좌표")]
|
||||
public Vector2Int gridPosition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SinoWms.Data
|
||||
{
|
||||
/// <summary>보관 조건(존). 적치 규칙 검증에 사용된다.</summary>
|
||||
public enum StorageZone
|
||||
{
|
||||
Ambient, // 상온
|
||||
Chilled, // 냉장
|
||||
Frozen, // 냉동
|
||||
Hazardous // 위험물
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user