diff --git a/Assets/Resources/Scenarios/warehouse_basic.json b/Assets/Resources/Scenarios/warehouse_basic.json
new file mode 100644
index 0000000..d892ccc
--- /dev/null
+++ b/Assets/Resources/Scenarios/warehouse_basic.json
@@ -0,0 +1,13 @@
+{
+ "warehouseId": "WH-BASIC",
+ "title": "기본 교육 창고 레이아웃",
+ "locations": [
+ { "locationCode": "A-01-01", "zone": "Ambient", "capacity": 50, "gridX": 0, "gridY": 0 },
+ { "locationCode": "A-02-01", "zone": "Ambient", "capacity": 50, "gridX": 1, "gridY": 0 },
+ { "locationCode": "A-03-02", "zone": "Ambient", "capacity": 50, "gridX": 2, "gridY": 0 },
+ { "locationCode": "A-99-01", "zone": "Ambient", "capacity": 5, "gridX": 3, "gridY": 0 },
+ { "locationCode": "C-01-01", "zone": "Chilled", "capacity": 40, "gridX": 0, "gridY": 2 },
+ { "locationCode": "C-02-03", "zone": "Chilled", "capacity": 40, "gridX": 1, "gridY": 2 },
+ { "locationCode": "F-01-01", "zone": "Frozen", "capacity": 30, "gridX": 0, "gridY": 4 }
+ ]
+}
diff --git a/Assets/Resources/Scenarios/warehouse_basic.json.meta b/Assets/Resources/Scenarios/warehouse_basic.json.meta
new file mode 100644
index 0000000..1cca0a1
--- /dev/null
+++ b/Assets/Resources/Scenarios/warehouse_basic.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 0098b9785d1154887bbf01a69d6c560d
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Core/ScenarioLoader.cs b/Assets/Scripts/Core/ScenarioLoader.cs
new file mode 100644
index 0000000..e4752be
--- /dev/null
+++ b/Assets/Scripts/Core/ScenarioLoader.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using SinoWms.Data;
+
+namespace SinoWms.Core
+{
+ ///
+ /// JSON 시나리오/창고 레이아웃을 읽어 런타임 ScriptableObject 인스턴스로 변환한다.
+ /// 교육 콘텐츠를 코드와 분리하기 위한 진입점. UI/매니저는 이 결과를 받아 미션을 시작한다.
+ ///
+ public static class ScenarioLoader
+ {
+ /// 로더 결과 묶음: 미션 시나리오 + 해당 창고의 로케이션 마스터.
+ public class LoadedScenario
+ {
+ public InboundScenario scenario;
+ public List locations = new List();
+ }
+
+ ///
+ /// Resources 폴더에서 시나리오/창고 JSON(TextAsset)을 읽어 로드한다.
+ /// 경로는 확장자 없이 Resources 기준 상대경로 (예: "Scenarios/inbound_basic").
+ ///
+ public static LoadedScenario LoadFromResources(string scenarioPath, string warehousePath)
+ {
+ var scenarioAsset = Resources.Load(scenarioPath);
+ if (scenarioAsset == null)
+ throw new InvalidOperationException($"시나리오 JSON을 찾을 수 없습니다: Resources/{scenarioPath}");
+
+ var warehouseAsset = Resources.Load(warehousePath);
+ if (warehouseAsset == null)
+ throw new InvalidOperationException($"창고 JSON을 찾을 수 없습니다: Resources/{warehousePath}");
+
+ return Load(scenarioAsset.text, warehouseAsset.text);
+ }
+
+ /// JSON 문자열로부터 직접 로드한다(테스트/외부 소스용).
+ public static LoadedScenario Load(string scenarioJson, string warehouseJson)
+ {
+ var warehouseDto = JsonUtility.FromJson(warehouseJson);
+ if (warehouseDto?.locations == null)
+ throw new InvalidOperationException("창고 JSON 파싱 실패: locations가 없습니다.");
+
+ var result = new LoadedScenario();
+ var byCode = new Dictionary();
+ foreach (var dto in warehouseDto.locations)
+ {
+ var loc = ScriptableObject.CreateInstance();
+ loc.name = dto.locationCode;
+ loc.locationCode = dto.locationCode;
+ loc.zone = ParseZone(dto.zone, dto.locationCode);
+ loc.capacity = dto.capacity;
+ loc.gridPosition = new Vector2Int(dto.gridX, dto.gridY);
+
+ result.locations.Add(loc);
+ byCode[loc.locationCode] = loc;
+ }
+
+ var scenarioDto = JsonUtility.FromJson(scenarioJson);
+ if (scenarioDto?.lines == null)
+ throw new InvalidOperationException("시나리오 JSON 파싱 실패: lines가 없습니다.");
+
+ var scenario = ScriptableObject.CreateInstance();
+ scenario.name = scenarioDto.scenarioId;
+ scenario.scenarioId = scenarioDto.scenarioId;
+ scenario.title = scenarioDto.title;
+ scenario.timeLimitSeconds = scenarioDto.timeLimitSeconds;
+
+ foreach (var lineDto in scenarioDto.lines)
+ {
+ var item = ScriptableObject.CreateInstance();
+ item.name = lineDto.sku;
+ item.sku = lineDto.sku;
+ item.displayName = lineDto.displayName;
+ item.barcode = lineDto.barcode;
+ item.requiredZone = ParseZone(lineDto.requiredZone, lineDto.sku);
+ item.unitVolume = lineDto.unitVolume;
+
+ LocationDefinition suggested = null;
+ if (!string.IsNullOrEmpty(lineDto.suggestedLocation))
+ {
+ if (!byCode.TryGetValue(lineDto.suggestedLocation, out suggested))
+ Debug.LogWarning($"[ScenarioLoader] {lineDto.sku}의 정답 로케이션 " +
+ $"'{lineDto.suggestedLocation}'이(가) 창고에 없습니다.");
+ }
+
+ scenario.lines.Add(new InboundLine
+ {
+ item = item,
+ expectedQty = lineDto.expectedQty,
+ actualQty = lineDto.actualQty,
+ suggestedLocation = suggested
+ });
+ }
+
+ result.scenario = scenario;
+ return result;
+ }
+
+ private static StorageZone ParseZone(string raw, string ownerForLog)
+ {
+ if (Enum.TryParse(raw, ignoreCase: true, out StorageZone zone))
+ return zone;
+
+ Debug.LogWarning($"[ScenarioLoader] 알 수 없는 존 '{raw}' ({ownerForLog}) → Ambient로 대체.");
+ return StorageZone.Ambient;
+ }
+
+ // ---- JSON 매핑용 DTO (JsonUtility는 public 필드 + [Serializable] 필요) ----
+
+ [Serializable]
+ private class ScenarioDto
+ {
+ public string scenarioId;
+ public string title;
+ public float timeLimitSeconds;
+ public LineDto[] lines;
+ }
+
+ [Serializable]
+ private class LineDto
+ {
+ public string sku;
+ public string displayName;
+ public string barcode;
+ public string requiredZone;
+ public int unitVolume;
+ public int expectedQty;
+ public int actualQty;
+ public string suggestedLocation;
+ }
+
+ [Serializable]
+ private class WarehouseDto
+ {
+ public string warehouseId;
+ public string title;
+ public LocationDto[] locations;
+ }
+
+ [Serializable]
+ private class LocationDto
+ {
+ public string locationCode;
+ public string zone;
+ public int capacity;
+ public int gridX;
+ public int gridY;
+ }
+ }
+}
diff --git a/Assets/Scripts/Core/ScenarioLoader.cs.meta b/Assets/Scripts/Core/ScenarioLoader.cs.meta
new file mode 100644
index 0000000..1e05db8
--- /dev/null
+++ b/Assets/Scripts/Core/ScenarioLoader.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fe252208b8d334ce181dd57a84601eb1
\ No newline at end of file
diff --git a/Assets/Scripts/Editor.meta b/Assets/Scripts/Editor.meta
new file mode 100644
index 0000000..2bfdc70
--- /dev/null
+++ b/Assets/Scripts/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d60fd71c0f2ac4481aa3dbbab31828e9
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Editor/InboundSmokeTest.cs b/Assets/Scripts/Editor/InboundSmokeTest.cs
new file mode 100644
index 0000000..b0a0315
--- /dev/null
+++ b/Assets/Scripts/Editor/InboundSmokeTest.cs
@@ -0,0 +1,132 @@
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEngine;
+using SinoWms.Core;
+using SinoWms.Data;
+using SinoWms.Inbound;
+
+namespace SinoWms.EditorTools
+{
+ ///
+ /// UI 없이 입고 로직(로더 + 서비스)을 검증하는 스모크 테스트.
+ /// 메뉴 SinoWMS/Run Inbound Smoke Test로 실행하면 콘솔에 PASS/FAIL 리포트가 찍힌다.
+ /// 정식 EditMode 테스트(asmdef 분리)로 대체되기 전까지의 임시 검증 수단.
+ ///
+ public static class InboundSmokeTest
+ {
+ private static int _pass;
+ private static int _fail;
+
+ [MenuItem("SinoWMS/Run Inbound Smoke Test")]
+ public static void Run()
+ {
+ _pass = 0;
+ _fail = 0;
+ Debug.Log("===== 입고 스모크 테스트 시작 =====");
+
+ var loaded = ScenarioLoader.LoadFromResources(
+ "Scenarios/inbound_basic", "Scenarios/warehouse_basic");
+
+ TestLoader(loaded);
+ TestHappyPath(loaded);
+ TestUnknownBarcode(loaded);
+ TestPutawayBeforeInspect(loaded);
+ TestZoneMismatch(loaded);
+ TestOverCapacity(loaded);
+
+ var summary = $"===== 결과: {_pass} PASS / {_fail} FAIL =====";
+ if (_fail == 0) Debug.Log(summary);
+ else Debug.LogError(summary);
+ }
+
+ // ---- 개별 테스트 ----
+
+ private static void TestLoader(ScenarioLoader.LoadedScenario l)
+ {
+ Check("로더: 시나리오 ID", l.scenario.scenarioId == "INB-001");
+ Check("로더: 라인 3건", l.scenario.lines.Count == 3);
+ Check("로더: 로케이션 7건", l.locations.Count == 7);
+ Check("로더: 우유 존=Chilled",
+ Line(l, "SKU-2002").item.requiredZone == StorageZone.Chilled);
+ Check("로더: 정답 로케이션 해석(생수→A-01-01)",
+ Line(l, "SKU-1001").suggestedLocation?.locationCode == "A-01-01");
+ Check("로더: 수량 차이 함정(우유 예정8/실제6)",
+ Line(l, "SKU-2002").actualQty == 6 && Line(l, "SKU-2002").expectedQty == 8);
+ }
+
+ private static void TestHappyPath(ScenarioLoader.LoadedScenario l)
+ {
+ var progress = NewProgress(l);
+ var scan = new ScanService(progress);
+ var putaway = new PutawayService();
+
+ var lp = progress.First(p => p.line.item.sku == "SKU-1001");
+ var r1 = scan.Scan(lp.line.item.barcode, out _);
+ Check("해피: 정상 스캔", r1 == ScanResult.Ok && lp.scanned);
+
+ lp.countedQty = lp.line.actualQty;
+ lp.inspected = true;
+
+ var r2 = putaway.Commit(lp, Loc(l, "A-01-01"));
+ Check("해피: 정상 적치", r2 == PutawayResult.Ok && lp.putaway);
+ }
+
+ private static void TestUnknownBarcode(ScenarioLoader.LoadedScenario l)
+ {
+ var scan = new ScanService(NewProgress(l));
+ var r = scan.Scan("0000000000000", out var matched);
+ Check("함정: 미등록 바코드 → UnknownBarcode",
+ r == ScanResult.UnknownBarcode && matched == null);
+ }
+
+ private static void TestPutawayBeforeInspect(ScenarioLoader.LoadedScenario l)
+ {
+ var progress = NewProgress(l);
+ var putaway = new PutawayService();
+ var lp = progress.First(p => p.line.item.sku == "SKU-1001");
+ lp.scanned = true; // 검수는 생략
+ var r = putaway.Validate(lp, Loc(l, "A-01-01"));
+ Check("함정: 검수 전 적치 → NotInspected", r == PutawayResult.NotInspected);
+ }
+
+ private static void TestZoneMismatch(ScenarioLoader.LoadedScenario l)
+ {
+ var progress = NewProgress(l);
+ var putaway = new PutawayService();
+ var lp = progress.First(p => p.line.item.sku == "SKU-1001"); // Ambient 품목
+ lp.countedQty = lp.line.actualQty;
+ lp.inspected = true;
+ var r = putaway.Validate(lp, Loc(l, "C-02-03")); // Chilled 존
+ Check("함정: 존 위반 → ZoneMismatch", r == PutawayResult.ZoneMismatch);
+ }
+
+ private static void TestOverCapacity(ScenarioLoader.LoadedScenario l)
+ {
+ var progress = NewProgress(l);
+ var putaway = new PutawayService();
+ var lp = progress.First(p => p.line.item.sku == "SKU-1001"); // 10개 × 부피2 = 20
+ lp.countedQty = lp.line.actualQty;
+ lp.inspected = true;
+ var r = putaway.Validate(lp, Loc(l, "A-99-01")); // 용량 5
+ Check("함정: 용량 초과 → OverCapacity", r == PutawayResult.OverCapacity);
+ }
+
+ // ---- 헬퍼 ----
+
+ private static List NewProgress(ScenarioLoader.LoadedScenario l)
+ => l.scenario.lines.Select(line => new LineProgress(line)).ToList();
+
+ private static InboundLine Line(ScenarioLoader.LoadedScenario l, string sku)
+ => l.scenario.lines.First(line => line.item.sku == sku);
+
+ private static LocationDefinition Loc(ScenarioLoader.LoadedScenario l, string code)
+ => l.locations.First(loc => loc.locationCode == code);
+
+ private static void Check(string label, bool ok)
+ {
+ if (ok) { _pass++; Debug.Log($" ✅ {label}"); }
+ else { _fail++; Debug.LogError($" ❌ {label}"); }
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/InboundSmokeTest.cs.meta b/Assets/Scripts/Editor/InboundSmokeTest.cs.meta
new file mode 100644
index 0000000..829604b
--- /dev/null
+++ b/Assets/Scripts/Editor/InboundSmokeTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 75a0eb77b8a87495cb9a80a685e0e1a8
\ No newline at end of file
diff --git a/Assets/Scripts/Inbound/InboundManager.cs b/Assets/Scripts/Inbound/InboundManager.cs
index 4debc79..602edca 100644
--- a/Assets/Scripts/Inbound/InboundManager.cs
+++ b/Assets/Scripts/Inbound/InboundManager.cs
@@ -34,6 +34,13 @@ namespace SinoWms.Inbound
private set { _step = value; StepChanged?.Invoke(value); }
}
+ /// 로더 등 외부에서 만든 시나리오로 미션 시작(인스펙터 할당 대신).
+ public void StartMission(InboundScenario scenarioToRun)
+ {
+ scenario = scenarioToRun;
+ StartMission();
+ }
+
public void StartMission()
{
if (scenario == null)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc59e83..73848d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,13 @@
버전 표기는 [유의적 버전(SemVer)](https://semver.org/lang/ko/)을 따릅니다.
## [Unreleased]
+### 추가됨 (Added)
+- `ScenarioLoader` — JSON 시나리오/창고 레이아웃을 런타임 ScriptableObject로 변환
+- 창고 레이아웃 데이터 `Assets/Resources/Scenarios/warehouse_basic.json` (로케이션 마스터)
+- `InboundManager.StartMission(InboundScenario)` 오버로드 (로더 결과 주입용)
+- 입고 로직 스모크 테스트 (`SinoWMS/Run Inbound Smoke Test` 메뉴 — 해피패스 + 함정 5종 검증)
+
### 예정
-- JSON → ScriptableObject 로더 연결
- 탑다운 카메라 + 창고 그리드 씬
- PDA UI 프리팹 (UI Toolkit 또는 uGUI)
- 채점 결과 화면