Add ERP schedule matching PoC

This commit is contained in:
2026-06-09 14:27:09 +09:00
parent c25911ca34
commit 8603f2cf31
10 changed files with 191 additions and 8 deletions

View File

@@ -28,6 +28,35 @@
"promotionText": "앱 주문 추가 적립"
}
],
"erpSchedules": [
{
"programCode": "PGM-CHOI-0900",
"programTitle": "최화정쇼",
"broadcastTime": "2026-08-17T09:00:00",
"durationMinutes": 60,
"channelName": "CJ OnStyle Live",
"merchandiserName": "MD 이수진",
"productCategory": "패션 이너웨어"
},
{
"programCode": "PGM-DONG-1100",
"programTitle": "동가게",
"broadcastTime": "2026-08-17T11:00:00",
"durationMinutes": 50,
"channelName": "CJ OnStyle Live",
"merchandiserName": "MD 박정우",
"productCategory": "신선식품"
},
{
"programCode": "PGM-STYLE-1400",
"programTitle": "스타일 라이브",
"broadcastTime": "2026-08-17T14:00:00",
"durationMinutes": 70,
"channelName": "CJ OnStyle Live",
"merchandiserName": "MD 김하린",
"productCategory": "패션 의류"
}
],
"activeTemplate": {
"templateId": "CJ_PRICE_LOWER_01",
"templateName": "가격형 하단 자막 / Tornado Scene",

View File

@@ -0,0 +1,11 @@
namespace TornadoAce_CJOnStyle.Domain
{
public sealed record ErpScheduleRecord(
string ProgramCode,
string ProgramTitle,
DateTime BroadcastTime,
int DurationMinutes,
string ChannelName,
string MerchandiserName,
string ProductCategory);
}

View File

@@ -7,11 +7,13 @@ namespace TornadoAce_CJOnStyle.Services
{
private AutomationWorkspace(
IReadOnlyList<CueSheetRecord> cueSheets,
IReadOnlyList<ErpScheduleRecord> erpSchedules,
SceneTemplateDefinition activeTemplate,
SceneDataSnapshot activeScene,
IReadOnlyList<InspectionResult> inspectionResults)
{
CueSheets = cueSheets;
ErpSchedules = erpSchedules;
ActiveTemplate = activeTemplate;
ActiveScene = activeScene;
InspectionResults = inspectionResults;
@@ -19,6 +21,8 @@ namespace TornadoAce_CJOnStyle.Services
public IReadOnlyList<CueSheetRecord> CueSheets { get; }
public IReadOnlyList<ErpScheduleRecord> ErpSchedules { get; }
public SceneTemplateDefinition ActiveTemplate { get; }
public SceneDataSnapshot ActiveScene { get; }
@@ -52,12 +56,16 @@ namespace TornadoAce_CJOnStyle.Services
var template = seed.ActiveTemplate.ToDomain();
var scene = seed.ActiveScene.ToDomain();
var schedules = seed.ErpSchedules?
.Select(schedule => schedule.ToDomain())
.ToArray() ?? [];
var inspections = seed.InspectionResults
.Select(inspection => inspection.ToDomain())
.ToArray();
return new AutomationWorkspace(
cueSheets.Select(cueSheet => cueSheet.ToDomain()).ToArray(),
schedules,
template,
scene,
inspections);
@@ -72,6 +80,13 @@ namespace TornadoAce_CJOnStyle.Services
new CueSheetRecord("CJ-20260817-003", new DateTime(2026, 8, 17, 14, 0, 0), "스타일 라이브", "썸머 린넨 셋업", "59,900원", "자켓 + 팬츠", "앱 주문 추가 적립")
};
var schedules = new[]
{
new ErpScheduleRecord("PGM-CHOI-0900", "최화정쇼", new DateTime(2026, 8, 17, 9, 0, 0), 60, "CJ OnStyle Live", "MD 이수진", "패션 이너웨어"),
new ErpScheduleRecord("PGM-DONG-1100", "동가게", new DateTime(2026, 8, 17, 11, 0, 0), 50, "CJ OnStyle Live", "MD 박정우", "신선식품"),
new ErpScheduleRecord("PGM-STYLE-1400", "스타일 라이브", new DateTime(2026, 8, 17, 14, 0, 0), 70, "CJ OnStyle Live", "MD 김하린", "패션 의류")
};
var mappings = new[]
{
new FieldMappingDefinition("programTitle", "TXT_PROGRAM", "Text", "trim"),
@@ -103,11 +118,12 @@ namespace TornadoAce_CJOnStyle.Services
new InspectionResult("CJ-20260817-003", "스타일 라이브", "CJ_PRICE_LOWER_01_1400.tscn", 98.1, "Medium", "특수문자 예외 후보")
};
return new AutomationWorkspace(cueSheets, template, scene, inspections);
return new AutomationWorkspace(cueSheets, schedules, template, scene, inspections);
}
private sealed record WorkspaceSeed(
List<CueSheetSeed> CueSheets,
List<ErpScheduleSeed>? ErpSchedules,
SceneTemplateSeed ActiveTemplate,
SceneDataSeed ActiveScene,
List<InspectionSeed> InspectionResults);
@@ -127,6 +143,28 @@ namespace TornadoAce_CJOnStyle.Services
}
}
private sealed record ErpScheduleSeed(
string ProgramCode,
string ProgramTitle,
DateTime BroadcastTime,
int DurationMinutes,
string ChannelName,
string MerchandiserName,
string ProductCategory)
{
public ErpScheduleRecord ToDomain()
{
return new ErpScheduleRecord(
ProgramCode,
ProgramTitle,
BroadcastTime,
DurationMinutes,
ChannelName,
MerchandiserName,
ProductCategory);
}
}
private sealed record SceneTemplateSeed(
string TemplateId,
string TemplateName,

View File

@@ -7,6 +7,11 @@ namespace TornadoAce_CJOnStyle.Services
Task<IReadOnlyList<CueSheetRecord>> GetCueSheetsAsync(DateOnly broadcastDate, CancellationToken cancellationToken);
}
public interface IErpScheduleClient
{
Task<IReadOnlyList<ErpScheduleRecord>> GetSchedulesAsync(DateOnly broadcastDate, CancellationToken cancellationToken);
}
public interface ITornadoSceneAdapter
{
Task<SceneDataSnapshot> CreateOrUpdateSceneAsync(CueSheetRecord cueSheet, SceneTemplateDefinition template, CancellationToken cancellationToken);

View File

@@ -28,6 +28,15 @@ namespace TornadoAce_CJOnStyle.Services
return BuildSnapshot(cueSheet, template, BuildVisibleTextObjects(cueSheet, template));
}
public ErpScheduleRecord? FindSchedule(CueSheetRecord cueSheet)
{
return workspace.ErpSchedules
.OrderBy(schedule => Math.Abs((schedule.BroadcastTime - cueSheet.BroadcastTime).TotalMinutes))
.FirstOrDefault(schedule =>
string.Equals(schedule.ProgramTitle, cueSheet.ProgramTitle, StringComparison.OrdinalIgnoreCase)
&& Math.Abs((schedule.BroadcastTime - cueSheet.BroadcastTime).TotalMinutes) <= 15);
}
public PocInspectionSummary InspectWorkspace(SceneTemplateDefinition template)
{
var results = workspace.CueSheets

View File

@@ -15,6 +15,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
private readonly PocAutomationEngine automationEngine;
private int selectedCueSheetIndex;
private CueSheetRecord activeCueSheet = null!;
private ErpScheduleRecord? activeSchedule;
private SceneDataSnapshot activeScene = null!;
public MainViewModel()
@@ -25,6 +26,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
var initialSelection = automationEngine.SelectCueSheet(selectedCueSheetIndex);
var initialInspectionSummary = automationEngine.InspectWorkspace(workspace.ActiveTemplate);
activeCueSheet = initialSelection.Record;
activeSchedule = automationEngine.FindSchedule(activeCueSheet);
activeScene = automationEngine.GenerateScene(activeCueSheet, workspace.ActiveTemplate);
BuildPhase = "INITIAL BUILD";
@@ -44,6 +46,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
LoadFieldMappings();
LoadEndpoints();
LoadRequirementCoverage();
LoadScheduleMatches();
LoadInspectionQueue(initialInspectionSummary.Results);
LoadInspectionDetails(initialInspectionSummary.Results);
}
@@ -127,6 +130,8 @@ namespace TornadoAce_CJOnStyle.ViewModels
public ObservableCollection<PocRequirementViewModel> RequirementCoverage { get; } = [];
public ObservableCollection<ScheduleMatchViewModel> ScheduleMatches { get; } = [];
public ObservableCollection<InspectionQueueItemViewModel> InspectionQueue { get; } = [];
public ObservableCollection<InspectionDetailItemViewModel> InspectionDetails { get; } = [];
@@ -182,10 +187,11 @@ namespace TornadoAce_CJOnStyle.ViewModels
selectedCueSheetIndex++;
var selection = automationEngine.SelectCueSheet(selectedCueSheetIndex);
activeCueSheet = selection.Record;
activeSchedule = automationEngine.FindSchedule(activeCueSheet);
activeScene = automationEngine.GenerateScene(activeCueSheet, workspace.ActiveTemplate);
LastCueSheetSyncText = FormatCueSheetSelection(selection);
OperatorMessage = $"{activeCueSheet.ProgramTitle} / {activeCueSheet.ProductName} 큐시트를 선택했습니다.";
OperatorMessage = $"{activeCueSheet.ProgramTitle} / {activeCueSheet.ProductName} 큐시트를 선택했습니다. {FormatScheduleMatch(activeSchedule)}";
StatusCards[0].Metric = selection.TotalCount.ToString();
StatusCards[0].Detail = $"선택 {selection.Position}/{selection.TotalCount}";
ApplySceneSnapshot(activeScene);
@@ -225,7 +231,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
private void LoadPipelineSteps()
{
PipelineSteps.Add(new PipelineStepViewModel("01", "큐시트 데이터 수신", "AI ON Studio / 자막 큐시트 API", "설계", 35, Brush(0xFF45D6D1)));
PipelineSteps.Add(new PipelineStepViewModel("01", "큐시트/ERP 데이터 수신", "AI ON Studio / CJ OnStyle ERP", "PoC", 55, Brush(0xFF45D6D1)));
PipelineSteps.Add(new PipelineStepViewModel("02", "Data Field Mapping", "큐시트 필드 -> Tornado Scene 객체", "초안", 45, Brush(0xFFB48CFF)));
PipelineSteps.Add(new PipelineStepViewModel("03", "Scene 생성/갱신", "Tornado2 우선, Tornado3 어댑터 준비", "대기", 25, Brush(0xFF37D67A)));
PipelineSteps.Add(new PipelineStepViewModel("04", "Scene Data API 추출", "Text/Image visible object snapshot", "초안", 30, Brush(0xFFFFB547)));
@@ -243,7 +249,7 @@ namespace TornadoAce_CJOnStyle.ViewModels
private void LoadEndpoints()
{
Endpoints.Add(new IntegrationEndpointViewModel("큐시트 플랫폼", "GET /api/cuesheets/{broadcastDate}", "AI ON Studio", "Mock", Brush(0xFF45D6D1)));
Endpoints.Add(new IntegrationEndpointViewModel("ERP 편성", "GET /api/schedules/{programCode}", "CJ OnStyle ERP", "대기", Brush(0xFFFFB547)));
Endpoints.Add(new IntegrationEndpointViewModel("ERP 편성", "GET /api/schedules/{programCode}", "CJ OnStyle ERP", "Mock", Brush(0xFF37D67A)));
Endpoints.Add(new IntegrationEndpointViewModel("Tornado Scene API", "POST /api/scenes/extract", "Tornado Adapter", "설계", Brush(0xFF37D67A)));
Endpoints.Add(new IntegrationEndpointViewModel("LLM 검수", "POST /api/inspection/llm", "Internal LLM", "정책 확인", Brush(0xFFB48CFF)));
}
@@ -251,13 +257,32 @@ namespace TornadoAce_CJOnStyle.ViewModels
private void LoadRequirementCoverage()
{
RequirementCoverage.Add(new PocRequirementViewModel("RFP-01", "큐시트 API 연동", "PoC", "JSON Mock 수신", Brush(0xFF45D6D1)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-02", "ERP 편성 연동", "대기", "엔드포인트 자리", Brush(0xFFFFB547)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-02", "ERP 편성 연동", "PoC", "편성 JSON 매칭", Brush(0xFF37D67A)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-03", "Data Field Mapping", "PoC", "Scene 스냅샷 생성", Brush(0xFF37D67A)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-04", "Tornado Scene API", "PoC", "Text/Image 추출 샘플", Brush(0xFF37D67A)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-05", "관리자 매핑 기능", "초안", "매핑표 UI", Brush(0xFFB48CFF)));
RequirementCoverage.Add(new PocRequirementViewModel("RFP-06", "1·2단계 자동 검수", "PoC", "필드 상세 Diff + LLM 후보", Brush(0xFF45D6D1)));
}
private void LoadScheduleMatches()
{
ScheduleMatches.Clear();
foreach (var cueSheet in workspace.CueSheets)
{
var schedule = automationEngine.FindSchedule(cueSheet);
var isMatched = schedule is not null;
ScheduleMatches.Add(new ScheduleMatchViewModel(
cueSheet.ProgramTitle,
$"{cueSheet.BroadcastTime:HH:mm} / {cueSheet.ProductName}",
schedule?.ProgramCode ?? "-",
schedule is null ? "편성 미확인" : $"{schedule.MerchandiserName} / {schedule.ProductCategory}",
isMatched ? "매칭" : "확인",
isMatched ? Brush(0xFF37D67A) : Brush(0xFFFFB547)));
}
}
private void LoadInspectionQueue(IEnumerable<InspectionResult> results)
{
InspectionQueue.Clear();
@@ -315,6 +340,13 @@ namespace TornadoAce_CJOnStyle.ViewModels
return $"통과 {summary.PassedCount}건 / 확인 {summary.ReviewCount}건 / 평균 {summary.AverageMatchRate:0.0}%";
}
private static string FormatScheduleMatch(ErpScheduleRecord? schedule)
{
return schedule is null
? "ERP 편성은 확인 대기입니다."
: $"ERP 편성 {schedule.ProgramCode}와 매칭됐습니다.";
}
private static SolidColorBrush SeverityBrush(string severity)
{
return severity switch

View File

@@ -67,6 +67,14 @@ namespace TornadoAce_CJOnStyle.ViewModels
string Evidence,
SolidColorBrush StatusBrush);
public sealed record ScheduleMatchViewModel(
string ProgramTitle,
string BroadcastSlot,
string ProgramCode,
string OwnerAndCategory,
string Status,
SolidColorBrush StatusBrush);
public sealed record InspectionQueueItemViewModel(
string ProgramTitle,
string SceneName,

View File

@@ -385,6 +385,57 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border
Height="1"
Background="{StaticResource PanelStrokeBrush}" />
<TextBlock Style="{StaticResource SectionTitleTextStyle}" Text="ERP 편성 매칭" />
<ItemsControl ItemsSource="{x:Bind ViewModel.ScheduleMatches, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ScheduleMatchViewModel">
<Grid
Margin="0,0,0,10"
ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
Foreground="{StaticResource TextPrimaryBrush}"
Text="{x:Bind ProgramTitle}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Style="{StaticResource MonoTextStyle}"
Text="{x:Bind ProgramCode}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Style="{StaticResource LabelTextStyle}"
Text="{x:Bind BroadcastSlot}"
TextTrimming="CharacterEllipsis" />
<TextBlock
Style="{StaticResource LabelTextStyle}"
Text="{x:Bind OwnerAndCategory}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<Border
Grid.Column="1"
Padding="8,3"
VerticalAlignment="Top"
Background="{x:Bind StatusBrush}"
CornerRadius="8">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="12"
Foreground="#071013"
Text="{x:Bind Status}" />
</Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border
Height="1"
Background="{StaticResource PanelStrokeBrush}" />

View File

@@ -11,7 +11,7 @@
## 현재 초기 빌드 범위
- `Domain`: 큐시트, Scene 템플릿, Scene 데이터 스냅샷, 검수 결과 모델.
- `Data`: 큐시트, Scene 템플릿, Scene 추출값을 담은 JSON 기반 PoC 샘플.
- `Data`: 큐시트, ERP 편성, Scene 템플릿, Scene 추출값을 담은 JSON 기반 PoC 샘플.
- `Services`: 실제 API/Tornado SDK 연결을 받을 인터페이스, JSON 로더, 목 워크스페이스.
- `ViewModels`: 운영 콘솔에 표시할 상태 카드, 파이프라인, 매핑표, 검수 큐.
- `Views`: WinUI 3 기반 첫 화면. 예제 프로젝트와 같은 데스크톱 앱 방향으로 유지.

View File

@@ -14,7 +14,7 @@
| ID | RFP 요구사항 | 초기 PoC 반영 상태 | 현재 구현 근거 |
| --- | --- | --- | --- |
| RFP-01 | 신규 큐시트 플랫폼 API 연동 | JSON Mock 데이터 수신 흐름 구현 | `Data/PocWorkspace.json`, `AutomationWorkspace.CueSheets`, `RefreshCueSheetCommand` |
| RFP-02 | ERP 편성데이터 연동 | 연동 엔드포인트 자리 표시 | `IntegrationEndpointViewModel` |
| RFP-02 | ERP 편성데이터 연동 | JSON Mock 편성 데이터 매칭 구현 | `Data/PocWorkspace.json`, `ErpScheduleRecord`, `ScheduleMatches` |
| RFP-03 | CG 포맷 제작 자동화, Data Field Mapping | 큐시트 필드와 Scene 객체 매핑 구현 | `SceneTemplateDefinition`, `PocAutomationEngine.GenerateScene` |
| RFP-04 | Tornado Scene API | Scene visible Text/Image 스냅샷 샘플 구현 | `SceneDataSnapshot.ToPreviewJson` |
| RFP-05 | 큐시트 데이터와 Tornado Scene 연동 관리자 기능 | 매핑표 UI 초안 구현 | `FieldMappings` 화면 |
@@ -32,7 +32,7 @@
## 다음 구현 후보
- ERP Mock 데이터를 JSON 파일로 분리하여 실제 편성 API 응답 구조와 맞춘다.
- 실제 큐시트/ERP API 응답 DTO와 현재 JSON seed 스키마를 대조한다.
- Tornado2/Tornado3 어댑터를 동일 인터페이스로 유지하고, 버전별 구현체를 분리한다.
- Scene 내부 비노출 객체를 구분할 메타데이터 규칙을 정의한다.
- 1차 검수 실패 사유를 필드 단위로 누적하고 LLM 요청 후보로 전달한다.