5842 lines
201 KiB
C#
5842 lines
201 KiB
C#
using S5025ManualAudience =
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience;
|
|
using CoreMovingAverageSelection =
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
|
|
using CoreMovingAverageSource =
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
|
|
using CoreSceneSelection =
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection;
|
|
using S5011DetailBranch =
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
|
|
|
public enum LegacySearchTrigger
|
|
{
|
|
Button,
|
|
Enter
|
|
}
|
|
|
|
public enum LegacyOperatorStatusKind
|
|
{
|
|
Neutral,
|
|
Information,
|
|
Warning,
|
|
Error
|
|
}
|
|
|
|
public enum LegacyPlaylistMoveDirection
|
|
{
|
|
Up,
|
|
Down
|
|
}
|
|
|
|
public enum LegacyDragDropPosition
|
|
{
|
|
Before,
|
|
After
|
|
}
|
|
|
|
public enum LegacyCutKeyboardKey
|
|
{
|
|
ArrowUp,
|
|
ArrowDown,
|
|
Home,
|
|
End,
|
|
Space
|
|
}
|
|
|
|
public enum LegacyOperatorTabId
|
|
{
|
|
Overseas,
|
|
Exchange,
|
|
Index,
|
|
KospiIndustry,
|
|
KosdaqIndustry,
|
|
Comparison,
|
|
Theme,
|
|
OverseasStocks,
|
|
Expert,
|
|
TradingHalt
|
|
}
|
|
|
|
public sealed record LegacyStockResultRow(int Index, string DisplayName);
|
|
|
|
public sealed record LegacyCutViewRow(
|
|
int PhysicalIndex,
|
|
int? DisplayOrdinal,
|
|
string RawLabel,
|
|
bool IsSeparator,
|
|
bool IsSelected,
|
|
bool IsFocused = false);
|
|
|
|
public sealed record LegacyDialogState(
|
|
string Message,
|
|
string Caption = "MmoneyCoder",
|
|
bool IsConfirmation = false);
|
|
|
|
public sealed record LegacyOperatorCommandResult(
|
|
long Sequence,
|
|
string Command,
|
|
string TargetId,
|
|
bool Succeeded);
|
|
|
|
public sealed record LegacyOperatorTabState(
|
|
LegacyOperatorTabId Id,
|
|
string Label,
|
|
bool IsActive);
|
|
|
|
public sealed record LegacyFixedActionView(
|
|
string Id,
|
|
string Label,
|
|
string Detail,
|
|
bool IsAvailable,
|
|
string Prerequisite);
|
|
|
|
public sealed record LegacyFixedSectionView(
|
|
int Index,
|
|
string Name,
|
|
IReadOnlyList<LegacyFixedActionView> Actions);
|
|
|
|
public sealed record LegacyFixedCatalogView(
|
|
LegacyFixedMarket Market,
|
|
IReadOnlyList<LegacyFixedSectionView> Sections);
|
|
|
|
/// <summary>
|
|
/// A fixed UC1 parent-node activation is staged until every modal manual child has
|
|
/// been confirmed. The playlist remains unchanged while this view is present.
|
|
/// </summary>
|
|
public sealed record LegacyFixedSectionBatchView(
|
|
int SectionIndex,
|
|
string SectionName,
|
|
int TotalActionCount,
|
|
int StagedActionCount,
|
|
int ManualStepNumber,
|
|
int ManualStepCount,
|
|
string PendingActionId,
|
|
string PendingActionLabel);
|
|
|
|
public sealed record LegacyOperatorSnapshot(
|
|
long Revision,
|
|
string SearchText,
|
|
IReadOnlyList<LegacyStockResultRow> SearchResults,
|
|
int? SelectedStockIndex,
|
|
IReadOnlyList<LegacyCutViewRow> CutRows,
|
|
IReadOnlyList<LegacyOperatorPlaylistRow> Playlist,
|
|
LegacyDialogState? Dialog,
|
|
string StatusMessage,
|
|
LegacyOperatorStatusKind StatusKind,
|
|
IReadOnlyList<LegacyOperatorTabState>? Tabs = null,
|
|
LegacyMovingAverageSelection MovingAverages = default,
|
|
LegacyFixedCatalogView? FixedCatalog = null,
|
|
LegacyIndustrySelectionSnapshot? Industry = null,
|
|
LegacyThemeWorkflowSnapshot? Theme = null,
|
|
LegacyExpertWorkflowSnapshot? Expert = null,
|
|
LegacyTradingHaltWorkflowSnapshot? TradingHalt = null,
|
|
LegacyComparisonWorkflowSnapshot? Comparison = null,
|
|
LegacyManualFinancialWorkflowSnapshot? ManualFinancial = null,
|
|
LegacyNamedPlaylistWorkflowSnapshot? NamedPlaylist = null,
|
|
LegacyOverseasWorkflowSnapshot? Overseas = null,
|
|
LegacyManualListsSnapshot? ManualLists = null,
|
|
LegacyOperatorCatalogWorkflowSnapshot? OperatorCatalog = null,
|
|
LegacyOperatorCommandResult? CommandResult = null,
|
|
LegacyFixedSectionBatchView? FixedSectionBatch = null);
|
|
|
|
/// <summary>
|
|
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
|
|
/// input intent only; it never derives stock identity, cut aliases or playlist fields.
|
|
/// </summary>
|
|
public sealed class LegacyOperatorController
|
|
{
|
|
private const long DoubleClickWindowMilliseconds = 500;
|
|
|
|
private readonly ILegacyStockLookup _stockLookup;
|
|
private readonly IReadOnlyList<LegacyCutCatalogRow> _cutRows;
|
|
private readonly LegacyFixedActionCatalog _fixedCatalog;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly LegacyIndustrySelectionWorkflow? _industryWorkflow;
|
|
private readonly LegacyThemeWorkflow? _themeWorkflow;
|
|
private readonly LegacyExpertWorkflowController? _expertWorkflow;
|
|
private readonly LegacyTradingHaltWorkflowController? _tradingHaltWorkflow;
|
|
private readonly LegacyComparisonWorkflowController? _comparisonWorkflow;
|
|
private readonly LegacyManualFinancialWorkflow? _manualFinancialWorkflow;
|
|
private readonly LegacyNamedPlaylistWorkflowController? _namedPlaylistWorkflow;
|
|
private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow;
|
|
private readonly LegacyManualListsWorkflow? _manualListsWorkflow;
|
|
private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow;
|
|
private readonly CoreMovingAverageSource _movingAverageSource = new(
|
|
new CoreMovingAverageSelection(true, true));
|
|
private readonly SemaphoreSlim _tabActivationGate = new(1, 1);
|
|
private readonly SortedSet<int> _legacyPointerRows = [];
|
|
private readonly SortedSet<int> _nativeSelectedCutRows = [];
|
|
private readonly List<LegacyOperatorPlaylistRow> _playlist = [];
|
|
private readonly HashSet<string> _selectedPlaylistRowIds = new(StringComparer.Ordinal);
|
|
private readonly List<LegacyOperatorTabId> _tabOrder =
|
|
[
|
|
LegacyOperatorTabId.Overseas,
|
|
LegacyOperatorTabId.Exchange,
|
|
LegacyOperatorTabId.Index,
|
|
LegacyOperatorTabId.KospiIndustry,
|
|
LegacyOperatorTabId.KosdaqIndustry,
|
|
LegacyOperatorTabId.Comparison,
|
|
LegacyOperatorTabId.Theme,
|
|
LegacyOperatorTabId.OverseasStocks,
|
|
LegacyOperatorTabId.Expert,
|
|
LegacyOperatorTabId.TradingHalt
|
|
];
|
|
private Queue<int>? _pendingDropCutRows;
|
|
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
|
|
private int? _selectedStockIndex;
|
|
private int? _cutAnchorIndex;
|
|
private int _nativeSelectionAnchorIndex;
|
|
private int _nativeFocusedCutIndex = -1;
|
|
private int? _previousPointerRow;
|
|
private int _previousPointerX;
|
|
private int _previousPointerY;
|
|
private long? _previousPointerTimestamp;
|
|
private string? _activePlaylistRowId;
|
|
private int _playlistCursorIndex;
|
|
private int _playlistPriorCursorIndex;
|
|
private long _revision;
|
|
private long _playlistSequence;
|
|
private string _searchText = string.Empty;
|
|
private string _statusMessage = string.Empty;
|
|
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
|
private LegacyDialogState? _dialog;
|
|
private PendingNativeConfirmation? _pendingNativeConfirmation;
|
|
private bool _closeOperatorCatalogAfterDialog;
|
|
private LegacyOperatorTabId _activeTab = LegacyOperatorTabId.Overseas;
|
|
private LegacyMovingAverageSelection _movingAverages = new(true, true);
|
|
private LegacyThemeWorkflowSnapshot? _themeSnapshot;
|
|
private LegacyManualFinancialWorkflowSnapshot? _manualFinancialSnapshot;
|
|
private bool _industryActivationListLoaded;
|
|
private bool _comparisonActivationListLoaded;
|
|
private bool _themeActivationListLoaded;
|
|
private bool _expertActivationListLoaded;
|
|
private long _tabActivationRequest;
|
|
private long _commandSequence;
|
|
private LegacyOperatorCommandResult? _commandResult;
|
|
private PendingFixedSectionBatch? _pendingFixedSectionBatch;
|
|
|
|
public LegacyOperatorController(
|
|
ILegacyStockLookup stockLookup,
|
|
LegacyFixedActionCatalog? fixedCatalog = null,
|
|
TimeProvider? timeProvider = null,
|
|
LegacyIndustrySelectionWorkflow? industryWorkflow = null,
|
|
LegacyThemeWorkflow? themeWorkflow = null,
|
|
LegacyExpertWorkflowController? expertWorkflow = null,
|
|
LegacyTradingHaltWorkflowController? tradingHaltWorkflow = null,
|
|
LegacyComparisonWorkflowController? comparisonWorkflow = null,
|
|
LegacyManualFinancialWorkflow? manualFinancialWorkflow = null,
|
|
LegacyNamedPlaylistWorkflowController? namedPlaylistWorkflow = null,
|
|
LegacyOverseasSelectionWorkflow? overseasWorkflow = null,
|
|
LegacyManualListsWorkflow? manualListsWorkflow = null,
|
|
LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null,
|
|
IReadOnlyList<LegacyCutCatalogRow>? cutRows = null)
|
|
{
|
|
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
|
|
_cutRows = CreateValidatedCutRows(cutRows);
|
|
_fixedCatalog = fixedCatalog ?? LegacyFixedActionCatalog.Default;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_industryWorkflow = industryWorkflow;
|
|
_themeWorkflow = themeWorkflow;
|
|
_themeSnapshot = themeWorkflow?.CreateInitial(CurrentThemeNxtSession());
|
|
_expertWorkflow = expertWorkflow;
|
|
_tradingHaltWorkflow = tradingHaltWorkflow;
|
|
_comparisonWorkflow = comparisonWorkflow;
|
|
_manualFinancialWorkflow = manualFinancialWorkflow;
|
|
_namedPlaylistWorkflow = namedPlaylistWorkflow;
|
|
_overseasWorkflow = overseasWorkflow;
|
|
_manualListsWorkflow = manualListsWorkflow;
|
|
_operatorCatalogWorkflow = operatorCatalogWorkflow;
|
|
_tradingHaltWorkflow?.SetMovingAverages(
|
|
_movingAverages.Ma5,
|
|
_movingAverages.Ma20);
|
|
_overseasWorkflow?.SetMovingAverages(
|
|
_movingAverages.Ma5,
|
|
_movingAverages.Ma20);
|
|
}
|
|
|
|
public LegacyOperatorSnapshot Current => CreateSnapshot();
|
|
|
|
public LegacyOperatorPlayoutRequest CreatePlayoutRequest() =>
|
|
CreatePlayoutRequestCore(globalFadeDuration: null);
|
|
|
|
public LegacyOperatorPlayoutRequest CreatePlayoutRequest(int globalFadeDuration)
|
|
{
|
|
if (globalFadeDuration is < 0 or > 19)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(globalFadeDuration),
|
|
"The original DissolveTime index must be between 0 and 19.");
|
|
}
|
|
|
|
return CreatePlayoutRequestCore(globalFadeDuration);
|
|
}
|
|
|
|
private LegacyOperatorPlayoutRequest CreatePlayoutRequestCore(
|
|
int? globalFadeDuration)
|
|
{
|
|
if (_playlist.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("플레이리스트에 송출할 항목이 없습니다.");
|
|
}
|
|
|
|
var selectedIndex = _activePlaylistRowId is null
|
|
? -1
|
|
: FindPlaylistIndex(_activePlaylistRowId);
|
|
if (selectedIndex < 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"플레이리스트의 F8 대상 행이 선택되지 않았습니다.");
|
|
}
|
|
|
|
var entries = _playlist.Select(row =>
|
|
{
|
|
var entry = row.ToPlayoutEntry();
|
|
if (MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
|
|
.Supports(entry.CutCode))
|
|
{
|
|
// Keep the row's seven persisted fields unchanged. The retained source
|
|
// is sampled again by the workflow whenever s8010 is rebuilt.
|
|
entry = entry with { MovingAverageSelectionSource = _movingAverageSource };
|
|
entry = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
|
|
.ApplyCurrent(entry);
|
|
}
|
|
|
|
return globalFadeDuration.HasValue
|
|
? entry with { FadeDuration = globalFadeDuration.Value }
|
|
: entry;
|
|
}).ToArray();
|
|
return new LegacyOperatorPlayoutRequest(
|
|
Array.AsReadOnly(entries),
|
|
selectedIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reflects a successfully prepared or played cue back into the operator grid.
|
|
/// MainForm stored the current PageN value in column 5 instead of keeping it only
|
|
/// in the Tornado workflow. TAKE/PREPARE may move the F8 candidate when a disabled
|
|
/// row is skipped, but never rewrites bufdel/m_delcnt or the Shift anchors. NEXT
|
|
/// deliberately leaves even that independent candidate alone.
|
|
/// </summary>
|
|
public LegacyOperatorSnapshot ReflectSuccessfulPlayout(
|
|
string entryId,
|
|
int pageIndexZeroBased,
|
|
int pageCount,
|
|
bool synchronizeOperatorSelection)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
|
|
if (pageCount <= 0 || pageIndexZeroBased < 0 || pageIndexZeroBased >= pageCount)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(pageIndexZeroBased),
|
|
"The playout page position is outside the playlist page range.");
|
|
}
|
|
|
|
var index = FindPlaylistIndex(entryId);
|
|
if (index < 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var changed = false;
|
|
var pageText = $"{pageIndexZeroBased + 1}/{pageCount}";
|
|
if (!string.Equals(_playlist[index].PageText, pageText, StringComparison.Ordinal))
|
|
{
|
|
_playlist[index] = _playlist[index] with { PageText = pageText };
|
|
changed = true;
|
|
}
|
|
|
|
if (synchronizeOperatorSelection)
|
|
{
|
|
var rowId = _playlist[index].RowId;
|
|
changed |= !string.Equals(_activePlaylistRowId, rowId, StringComparison.Ordinal);
|
|
_activePlaylistRowId = rowId;
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
Touch();
|
|
}
|
|
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchStocksAsync(
|
|
string text,
|
|
LegacySearchTrigger trigger,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(text);
|
|
_searchText = text;
|
|
|
|
// button9_Click returned before calling searchStock only for an exactly empty string.
|
|
// txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows.
|
|
if (trigger == LegacySearchTrigger.Button && text.Length == 0)
|
|
{
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false);
|
|
_selectedStockIndex = null;
|
|
_statusMessage = string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Neutral;
|
|
_dialog = null;
|
|
_pendingDropCutRows = null;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectStock(int resultIndex)
|
|
{
|
|
if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_selectedStockIndex = resultIndex;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot CutPointerDown(
|
|
int physicalIndex,
|
|
bool control,
|
|
bool shift,
|
|
int x,
|
|
int y,
|
|
long timestampMilliseconds,
|
|
bool allowAppend = true)
|
|
{
|
|
if (!allowAppend)
|
|
{
|
|
// Selection remains available while a scene is prepared or on air, but
|
|
// an on-air click must neither activate a cut nor prime the next IDLE
|
|
// click as the second half of a legacy pointer double-click.
|
|
_previousPointerTimestamp = null;
|
|
}
|
|
|
|
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var gap = _previousPointerTimestamp is long previousTimestamp
|
|
? timestampMilliseconds - previousTimestamp
|
|
: long.MaxValue;
|
|
if (allowAppend &&
|
|
_previousPointerTimestamp is not null &&
|
|
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
|
|
_previousPointerRow == physicalIndex &&
|
|
_previousPointerX == x &&
|
|
_previousPointerY == y &&
|
|
_legacyPointerRows.Count == 1)
|
|
{
|
|
TryAppendCut(_legacyPointerRows.Min);
|
|
_previousPointerTimestamp = null;
|
|
}
|
|
else
|
|
{
|
|
_previousPointerTimestamp = allowAppend ? timestampMilliseconds : null;
|
|
}
|
|
|
|
_previousPointerRow = physicalIndex;
|
|
_previousPointerX = x;
|
|
_previousPointerY = y;
|
|
|
|
ApplyLegacyPointerSelection(physicalIndex, control, shift);
|
|
ApplyNativeListSelection(physicalIndex, control, shift);
|
|
|
|
_cutAnchorIndex = physicalIndex;
|
|
_nativeFocusedCutIndex = physicalIndex;
|
|
// Win32 ListView's selection mark remains the fixed anchor across
|
|
// consecutive Shift/Ctrl+Shift gestures. Only a non-Shift click moves it.
|
|
if (!shift)
|
|
{
|
|
_nativeSelectionAnchorIndex = physicalIndex;
|
|
}
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot CutKeyDown(
|
|
LegacyCutKeyboardKey key,
|
|
bool control,
|
|
bool shift)
|
|
{
|
|
if (_selectedStockIndex is null || _cutRows.Count == 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var focusedIndex = IsCutIndex(_nativeFocusedCutIndex)
|
|
? _nativeFocusedCutIndex
|
|
: 0;
|
|
var targetIndex = key switch
|
|
{
|
|
LegacyCutKeyboardKey.ArrowUp => Math.Max(0, focusedIndex - 1),
|
|
LegacyCutKeyboardKey.ArrowDown => Math.Min(_cutRows.Count - 1, focusedIndex + 1),
|
|
LegacyCutKeyboardKey.Home => 0,
|
|
LegacyCutKeyboardKey.End => _cutRows.Count - 1,
|
|
LegacyCutKeyboardKey.Space => focusedIndex,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
|
|
};
|
|
|
|
_nativeFocusedCutIndex = targetIndex;
|
|
if (key == LegacyCutKeyboardKey.Space)
|
|
{
|
|
ApplyNativeSpaceSelection(targetIndex, control, shift);
|
|
}
|
|
else
|
|
{
|
|
ApplyNativeKeyboardNavigationSelection(targetIndex, control, shift);
|
|
}
|
|
|
|
// MainForm's selectedRowsList was populated only by listView1_MouseDown.
|
|
// Keyboard selection belongs exclusively to the native ListView state used
|
|
// by owner drawing and PlayList_DragDrop, so do not alter the legacy list.
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist()
|
|
{
|
|
if (_selectedStockIndex is null || _dialog is not null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_pendingDropCutRows = new Queue<int>(_nativeSelectedCutRows);
|
|
ContinuePendingDrop();
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ClearCutSelection()
|
|
{
|
|
if (_selectedStockIndex is null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var changed = _legacyPointerRows.Count != 0 ||
|
|
_nativeSelectedCutRows.Count != 0 ||
|
|
_cutAnchorIndex != -1 ||
|
|
_previousPointerTimestamp is not null;
|
|
_legacyPointerRows.Clear();
|
|
_nativeSelectedCutRows.Clear();
|
|
_cutAnchorIndex = -1;
|
|
// A Win32 ListView blank-area click clears Selected items but preserves
|
|
// its selection mark, so a following Shift gesture keeps the old anchor.
|
|
_previousPointerTimestamp = null;
|
|
_previousPointerRow = -1;
|
|
if (changed)
|
|
{
|
|
Touch();
|
|
}
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rowId);
|
|
var index = _playlist.FindIndex(row =>
|
|
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
|
|
if (index >= 0)
|
|
{
|
|
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
|
|
Touch();
|
|
}
|
|
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot TogglePlaylistRowEnabled(string rowId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rowId);
|
|
var index = FindPlaylistIndex(rowId);
|
|
if (index >= 0)
|
|
{
|
|
_playlist[index] = _playlist[index] with
|
|
{
|
|
IsEnabled = !_playlist[index].IsEnabled
|
|
};
|
|
Touch();
|
|
}
|
|
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SetAllPlaylistRowsEnabled(bool enabled)
|
|
{
|
|
if (_playlist.Count == 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
for (var index = 0; index < _playlist.Count; index++)
|
|
{
|
|
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectTab(LegacyOperatorTabId tabId)
|
|
{
|
|
if (_pendingFixedSectionBatch is not null && _activeTab != tabId)
|
|
{
|
|
return ReportError(
|
|
"고정 컷 섹션의 수동 입력은 원본 모달 창과 같이 완료하거나 취소하기 전까지 탭을 바꿀 수 없습니다.");
|
|
}
|
|
|
|
if (!_tabOrder.Contains(tabId) || _activeTab == tabId)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
BeginTabActivation(tabId);
|
|
_activeTab = tabId;
|
|
Interlocked.Increment(ref _tabActivationRequest);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SelectTabAsync(
|
|
LegacyOperatorTabId tabId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
SelectTab(tabId);
|
|
var activationRequest = Volatile.Read(ref _tabActivationRequest);
|
|
await _tabActivationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (!IsCurrentTabActivation(tabId, activationRequest))
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var market = GetActiveIndustryMarket();
|
|
var touched = false;
|
|
if (_industryWorkflow is not null && market is not null &&
|
|
!_industryActivationListLoaded)
|
|
{
|
|
await _industryWorkflow.LoadMarketAsync(market.Value, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!IsCurrentTabActivation(tabId, activationRequest))
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_industryActivationListLoaded = true;
|
|
touched = true;
|
|
}
|
|
|
|
if (IsComparisonActive() && _comparisonWorkflow is not null &&
|
|
!_comparisonActivationListLoaded)
|
|
{
|
|
await _comparisonWorkflow.LoadSavedPairsAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!IsCurrentTabActivation(tabId, activationRequest))
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_comparisonActivationListLoaded = true;
|
|
touched = true;
|
|
}
|
|
|
|
// Legacy UC4_Load starts a one-shot timer whose first tick calls
|
|
// GetThemeByKeyword with the initial empty query. Keep that load in
|
|
// native authority so Web code cannot decide when or what to query.
|
|
if (IsThemeActive() && _themeWorkflow is not null &&
|
|
_themeSnapshot is not null && !_themeActivationListLoaded)
|
|
{
|
|
var loaded = await _themeWorkflow.SearchAsync(
|
|
_themeSnapshot,
|
|
string.Empty,
|
|
CurrentThemeNxtSession(),
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (!IsCurrentTabActivation(tabId, activationRequest))
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_themeSnapshot = loaded;
|
|
_themeActivationListLoaded = true;
|
|
touched = true;
|
|
}
|
|
|
|
// Legacy UC6_Load calls Data_Load immediately, which loads the full
|
|
// EXPERT_LIST. An empty native search is its parameterized equivalent.
|
|
if (IsExpertActive() && _expertWorkflow is not null &&
|
|
!_expertActivationListLoaded)
|
|
{
|
|
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!IsCurrentTabActivation(tabId, activationRequest))
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_expertActivationListLoaded = true;
|
|
touched = true;
|
|
}
|
|
|
|
if (touched)
|
|
{
|
|
Touch();
|
|
}
|
|
|
|
return CreateSnapshot();
|
|
}
|
|
finally
|
|
{
|
|
_tabActivationGate.Release();
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SwapTabs(
|
|
LegacyOperatorTabId source,
|
|
LegacyOperatorTabId target)
|
|
{
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"고정 컷 섹션의 수동 입력을 완료하거나 취소하기 전에는 탭 순서를 바꿀 수 없습니다.");
|
|
}
|
|
|
|
var sourceIndex = _tabOrder.IndexOf(source);
|
|
var targetIndex = _tabOrder.IndexOf(target);
|
|
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
(_tabOrder[sourceIndex], _tabOrder[targetIndex]) =
|
|
(_tabOrder[targetIndex], _tabOrder[sourceIndex]);
|
|
if (_activeTab != source)
|
|
{
|
|
Interlocked.Increment(ref _tabActivationRequest);
|
|
BeginTabActivation(source);
|
|
}
|
|
|
|
_activeTab = source;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SwapTabsAsync(
|
|
LegacyOperatorTabId source,
|
|
LegacyOperatorTabId target,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!_tabOrder.Contains(source) || !_tabOrder.Contains(target) || source == target)
|
|
{
|
|
return SwapTabs(source, target);
|
|
}
|
|
|
|
SwapTabs(source, target);
|
|
return await SelectTabAsync(source, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public Task<LegacyOperatorSnapshot> SwapTabsAtExpectedPositionsAsync(
|
|
LegacyOperatorTabId source,
|
|
LegacyOperatorTabId target,
|
|
int expectedSourceIndex,
|
|
int expectedTargetIndex,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (source == target ||
|
|
expectedSourceIndex < 0 || expectedSourceIndex >= _tabOrder.Count ||
|
|
expectedTargetIndex < 0 || expectedTargetIndex >= _tabOrder.Count ||
|
|
expectedSourceIndex == expectedTargetIndex ||
|
|
_tabOrder[expectedSourceIndex] != source ||
|
|
_tabOrder[expectedTargetIndex] != target)
|
|
{
|
|
return Task.FromResult(CreateSnapshot());
|
|
}
|
|
|
|
// The expected positions make a repeated dragover message idempotent. Once
|
|
// the first message swaps the two tabs, an identical delivery no longer
|
|
// matches the C#-owned order and therefore cannot swap them back.
|
|
return SwapTabsAsync(source, target, cancellationToken);
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SetMovingAverages(bool ma5, bool ma20)
|
|
{
|
|
var next = new LegacyMovingAverageSelection(ma5, ma20);
|
|
if (_movingAverages == next)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_movingAverageSource.Update(new CoreMovingAverageSelection(ma5, ma20));
|
|
_movingAverages = next;
|
|
_tradingHaltWorkflow?.SetMovingAverages(ma5, ma20);
|
|
_overseasWorkflow?.SetMovingAverages(ma5, ma20);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> OpenOperatorCatalogAsync(
|
|
LegacyOperatorCatalogEntity entity,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.OpenAsync(entity, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> BeginThemeCatalogFromUc4Async(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.OpenAsync(
|
|
LegacyOperatorCatalogEntity.Theme,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> EditSelectedThemeCatalogAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
var identity = _themeSnapshot?.SelectedIdentity;
|
|
if (identity is null)
|
|
{
|
|
return ReportError("편집할 테마를 먼저 선택하세요.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public Task<LegacyOperatorSnapshot> DeleteSelectedThemeCatalogAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return Task.FromResult(ReportError("ThemeA 관리 기능이 구성되지 않았습니다."));
|
|
}
|
|
|
|
var identity = _themeSnapshot?.SelectedIdentity;
|
|
if (identity is null)
|
|
{
|
|
return Task.FromResult(ReportError("삭제할 테마를 먼저 선택하세요."));
|
|
}
|
|
|
|
if (_dialog is not null)
|
|
{
|
|
return Task.FromResult(CreateSnapshot());
|
|
}
|
|
|
|
_pendingNativeConfirmation = PendingNativeConfirmation.ForTheme(identity);
|
|
_dialog = new LegacyDialogState(
|
|
"선택한 테마를 삭제하겠습니까?",
|
|
IsConfirmation: true);
|
|
Touch();
|
|
return Task.FromResult(CreateSnapshot());
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> BeginExpertCatalogFromUc6Async(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.OpenAsync(
|
|
LegacyOperatorCatalogEntity.Expert,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (_operatorCatalogWorkflow.Current.CanBeginCreate)
|
|
{
|
|
await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> EditSelectedExpertCatalogAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
var selected = _expertWorkflow?.Current.SelectedExpert;
|
|
if (selected is null)
|
|
{
|
|
return ReportError("편집할 전문가를 먼저 선택하세요.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.OpenExpertForEditAsync(
|
|
new MMoneyCoderSharp.Data.ExpertSelectionIdentity(
|
|
selected.ExpertCode,
|
|
selected.ExpertName),
|
|
cancellationToken).ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public Task<LegacyOperatorSnapshot> DeleteSelectedExpertCatalogAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return Task.FromResult(ReportError("EList 관리 기능이 구성되지 않았습니다."));
|
|
}
|
|
|
|
var selected = _expertWorkflow?.Current.SelectedExpert;
|
|
if (selected is null)
|
|
{
|
|
return Task.FromResult(ReportError("삭제할 전문가를 먼저 선택하세요."));
|
|
}
|
|
|
|
if (_dialog is not null)
|
|
{
|
|
return Task.FromResult(CreateSnapshot());
|
|
}
|
|
|
|
_pendingNativeConfirmation = PendingNativeConfirmation.ForExpert(
|
|
new MMoneyCoderSharp.Data.ExpertSelectionIdentity(
|
|
selected.ExpertCode,
|
|
selected.ExpertName));
|
|
_dialog = new LegacyDialogState(
|
|
"선택한 전문가를 삭제하겠습니까?",
|
|
IsConfirmation: true);
|
|
Touch();
|
|
return Task.FromResult(CreateSnapshot());
|
|
}
|
|
|
|
public LegacyOperatorSnapshot CloseOperatorCatalog()
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_operatorCatalogWorkflow.Close();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> CloseOperatorCatalogAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var closing = _operatorCatalogWorkflow.Current;
|
|
_operatorCatalogWorkflow.Close();
|
|
_statusMessage = string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Neutral;
|
|
if (closing.IsOpen &&
|
|
closing.Entity == LegacyOperatorCatalogEntity.Expert &&
|
|
closing.Mode == LegacyOperatorCatalogMode.Create &&
|
|
_expertWorkflow is not null)
|
|
{
|
|
try
|
|
{
|
|
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch
|
|
{
|
|
_statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요.";
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchOperatorCatalogAsync(
|
|
string query,
|
|
MMoneyCoderSharp.Data.ThemeSession nxtSession,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.SearchAsync(query, nxtSession, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SelectOperatorCatalogResultAsync(
|
|
string resultId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.SelectAsync(resultId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> BeginCreateThemeCatalogAsync(
|
|
MMoneyCoderSharp.Data.ThemeMarket market,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.BeginCreateThemeAsync(market, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> BeginCreateExpertCatalogAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchOperatorCatalogStocksAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.SearchStocksAsync(query, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectOperatorCatalogStock(string resultId)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.SelectStock(resultId);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot AddOperatorCatalogStock(decimal? buyAmount)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.AddSelectedStock(buyAmount);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot MoveOperatorCatalogDraftRow(
|
|
string rowId,
|
|
LegacyOperatorCatalogMoveDirection direction)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.MoveDraftRow(rowId, direction);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ReorderOperatorCatalogDraftRow(
|
|
string sourceRowId,
|
|
string targetRowId,
|
|
LegacyDragDropPosition position)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
var beforeRevision = _operatorCatalogWorkflow.Current.Revision;
|
|
var reordered = _operatorCatalogWorkflow.ReorderDraftRow(
|
|
sourceRowId,
|
|
targetRowId,
|
|
position);
|
|
if (reordered.Revision == beforeRevision)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount(
|
|
string rowId,
|
|
decimal? buyAmount)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.SetDraftBuyAmount(rowId, buyAmount);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateOperatorCatalogStock(
|
|
string resultId,
|
|
decimal? buyAmount)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.SelectStock(resultId);
|
|
if (string.Equals(
|
|
_operatorCatalogWorkflow.Current.SelectedStockResultId,
|
|
resultId,
|
|
StringComparison.Ordinal))
|
|
{
|
|
_operatorCatalogWorkflow.AddSelectedStock(buyAmount);
|
|
}
|
|
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot RemoveOperatorCatalogDraftRow(string rowId)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.RemoveDraftRow(rowId);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectOperatorCatalogDraftRow(string rowId)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.SelectDraftRow(rowId);
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot RemoveActiveOperatorCatalogDraftRow()
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.RemoveActiveDraftRow();
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ClearOperatorCatalogDraftRows()
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
var catalog = _operatorCatalogWorkflow.Current;
|
|
if (!catalog.IsOpen || catalog.Entity != LegacyOperatorCatalogEntity.Theme ||
|
|
catalog.Mode is not (LegacyOperatorCatalogMode.Create or
|
|
LegacyOperatorCatalogMode.Edit))
|
|
{
|
|
return ReportError("ThemeA 편집 목록이 열려 있지 않습니다.");
|
|
}
|
|
|
|
if (catalog.DraftRows.Count == 0 || _dialog is not null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_pendingNativeConfirmation = PendingNativeConfirmation.ForThemeDraftClear(
|
|
catalog.Revision);
|
|
_dialog = new LegacyDialogState(
|
|
"모두 삭제하겠습니까?",
|
|
IsConfirmation: true);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> CheckOperatorCatalogThemeNameAsync(
|
|
string editorName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.CheckThemeNameAvailabilityAsync(
|
|
editorName,
|
|
cancellationToken).ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
var availability = _operatorCatalogWorkflow.Current.ThemeNameAvailability;
|
|
if (availability is LegacyThemeNameAvailability.Available or
|
|
LegacyThemeNameAvailability.Duplicate)
|
|
{
|
|
_dialog = new LegacyDialogState(_operatorCatalogWorkflow.Current.StatusMessage);
|
|
}
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SaveOperatorCatalogAsync(
|
|
string editorName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
var before = _operatorCatalogWorkflow.Current;
|
|
var selectedExpertId = before.Entity == LegacyOperatorCatalogEntity.Expert &&
|
|
before.Mode == LegacyOperatorCatalogMode.Edit
|
|
? _expertWorkflow?.Current.SelectedExpert?.SelectionId
|
|
: null;
|
|
if (before.Entity == LegacyOperatorCatalogEntity.Expert &&
|
|
before.Mode == LegacyOperatorCatalogMode.Create &&
|
|
string.IsNullOrWhiteSpace(editorName))
|
|
{
|
|
_dialog = new LegacyDialogState("이름을 입력하세요");
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.SaveAsync(editorName, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyOperatorCatalogStatus();
|
|
var after = _operatorCatalogWorkflow.Current;
|
|
|
|
if (before.Entity == LegacyOperatorCatalogEntity.Theme)
|
|
{
|
|
if (after.Mode == LegacyOperatorCatalogMode.List)
|
|
{
|
|
_operatorCatalogWorkflow.Close();
|
|
_statusMessage = string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Neutral;
|
|
}
|
|
else if (string.Equals(
|
|
after.StatusMessage,
|
|
LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage,
|
|
StringComparison.Ordinal))
|
|
{
|
|
_dialog = new LegacyDialogState(
|
|
LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage);
|
|
}
|
|
}
|
|
else if (after.Mode == LegacyOperatorCatalogMode.List)
|
|
{
|
|
var refreshFailed = false;
|
|
if (before.Mode == LegacyOperatorCatalogMode.Create)
|
|
{
|
|
_operatorCatalogWorkflow.Close();
|
|
try
|
|
{
|
|
if (_expertWorkflow is not null)
|
|
{
|
|
await _expertWorkflow.SearchAsync(string.Empty, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// The mutation receipt is already known-committed. A UC6 list refresh
|
|
// failure must not be reclassified as an unknown DB-write outcome.
|
|
_statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요.";
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
refreshFailed = true;
|
|
}
|
|
}
|
|
else if (before.Mode == LegacyOperatorCatalogMode.Edit)
|
|
{
|
|
try
|
|
{
|
|
if (_expertWorkflow is not null && selectedExpertId is not null)
|
|
{
|
|
await _expertWorkflow.SelectExpertAsync(
|
|
selectedExpertId,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// The expert transaction is known-committed. Preview refresh failure
|
|
// is a read failure and must never turn into an unknown write outcome.
|
|
refreshFailed = true;
|
|
_statusMessage = "저장된 추천 종목을 다시 읽지 못했습니다. 전문가를 다시 선택하세요.";
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
_dialog = new LegacyDialogState("저장되었습니다");
|
|
_closeOperatorCatalogAfterDialog = true;
|
|
}
|
|
|
|
if (!refreshFailed)
|
|
{
|
|
_statusMessage = string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Neutral;
|
|
}
|
|
}
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot InvalidateOperatorCatalogBrowserCorrelation()
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_operatorCatalogWorkflow.InvalidateBrowserCorrelation();
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> OpenManualFinancialAsync(
|
|
MMoneyCoderSharp.Data.ManualFinancialScreenKind screen,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_manualFinancialWorkflow is null)
|
|
{
|
|
return ReportError("수동 재무 입력 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
_manualFinancialSnapshot = _manualFinancialWorkflow.CreateInitial(screen);
|
|
_manualFinancialSnapshot = await _manualFinancialWorkflow.SearchAsync(
|
|
_manualFinancialSnapshot,
|
|
string.Empty,
|
|
cancellationToken).ConfigureAwait(false);
|
|
_statusMessage = $"{_manualFinancialSnapshot.Definition.Label} 목록을 불러왔습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot CloseManualFinancial()
|
|
{
|
|
if (_manualFinancialSnapshot is null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_manualFinancialSnapshot = null;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchManualFinancialAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
_manualFinancialSnapshot = await workflow.SearchAsync(
|
|
snapshot,
|
|
query,
|
|
cancellationToken).ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ToggleManualFinancialNameSort(long expectedRevision)
|
|
{
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("Open a manual-financial screen before sorting its list.");
|
|
}
|
|
|
|
try
|
|
{
|
|
_manualFinancialSnapshot = workflow.ToggleNameSort(
|
|
snapshot,
|
|
expectedRevision);
|
|
_statusMessage = "The GraphE stock-name order was changed.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or
|
|
InvalidOperationException)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
_statusMessage = exception.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> FindManualFinancialAsync(
|
|
string query,
|
|
LegacyManualFinancialFindDirection direction,
|
|
long expectedRevision,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("Open a manual-financial screen before finding a stock.");
|
|
}
|
|
|
|
try
|
|
{
|
|
_manualFinancialSnapshot = workflow.FindByName(
|
|
snapshot,
|
|
query,
|
|
direction,
|
|
expectedRevision);
|
|
if (_manualFinancialSnapshot.LastMessage is not null)
|
|
{
|
|
_statusMessage = _manualFinancialSnapshot.LastMessage;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
return await LoadManualFinancialAsync(
|
|
_manualFinancialSnapshot.SelectedRowId!,
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or
|
|
InvalidOperationException or KeyNotFoundException or
|
|
LegacyManualFinancialWorkflowDataException)
|
|
{
|
|
// A stale browser generation or a failed fresh detail read leaves the
|
|
// GraphE modal open. The operator may inspect the failure; no read is retried.
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
_statusMessage = exception.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> LoadManualFinancialAsync(
|
|
string resultId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(resultId);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
_manualFinancialSnapshot = await workflow.LoadAsync(
|
|
snapshot,
|
|
resultId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
// GraphE identities contain STOCK_NAME only. Resolve the exact stock master
|
|
// immediately after the fresh detail read when it is unambiguous; otherwise
|
|
// leave the candidates visible so the operator can resolve them explicitly.
|
|
var stockName = _manualFinancialSnapshot.SelectedRecord!.Identity.StockName;
|
|
_manualFinancialSnapshot = await workflow.SearchStocksAsync(
|
|
_manualFinancialSnapshot,
|
|
stockName,
|
|
cancellationToken).ConfigureAwait(false);
|
|
var exact = _manualFinancialSnapshot.StockCandidates
|
|
.Where(row => string.Equals(row.Name, stockName, StringComparison.Ordinal))
|
|
.ToArray();
|
|
if (exact.Length == 1)
|
|
{
|
|
try
|
|
{
|
|
_manualFinancialSnapshot = workflow.SelectStock(
|
|
_manualFinancialSnapshot,
|
|
exact[0].ResultId);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
// Ambiguous provider identities remain unverified and visible.
|
|
}
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> ActivateManualFinancialResultAsync(
|
|
string resultId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(resultId);
|
|
const string command = "activate-manual-financial-result";
|
|
if (!TryGetManualFinancial(out _, out _))
|
|
{
|
|
CompleteCommand(command, resultId, succeeded: false);
|
|
return ReportError("Open a manual-financial screen before activating a result.");
|
|
}
|
|
|
|
try
|
|
{
|
|
// A GraphE list identity is only an opaque handle. Re-read and validate the
|
|
// exact record and stock-master identity before deriving the playlist row.
|
|
await LoadManualFinancialAsync(resultId, cancellationToken).ConfigureAwait(false);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The manual-financial result is no longer available.");
|
|
}
|
|
|
|
var draft = workflow.MaterializePlaylistDraft(
|
|
snapshot,
|
|
snapshot.Definition.ActionId);
|
|
AppendManualFinancialRow(draft);
|
|
_manualFinancialSnapshot = null;
|
|
_statusMessage = $"{draft.Title} {draft.Detail} cut was added.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
CompleteCommand(command, resultId, succeeded: true);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or
|
|
InvalidOperationException or KeyNotFoundException or
|
|
LegacyManualFinancialWorkflowDataException)
|
|
{
|
|
// Keep the GraphE modal open and never append a partial row.
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
_statusMessage = exception.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
CompleteCommand(command, resultId, succeeded: false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot BeginManualFinancialCreate()
|
|
{
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
_manualFinancialSnapshot = workflow.BeginCreate(snapshot);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchManualFinancialStocksAsync(
|
|
string stockName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(stockName);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
_manualFinancialSnapshot = await workflow.SearchStocksAsync(
|
|
snapshot,
|
|
stockName,
|
|
cancellationToken).ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectManualFinancialStock(string resultId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(resultId);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
_manualFinancialSnapshot = workflow.SelectStock(snapshot, resultId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SaveManualFinancialAsync(
|
|
MMoneyCoderSharp.Data.ManualFinancialRecord record,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
LegacyManualFinancialWriteRequest request = snapshot.SelectedRecord is null
|
|
? workflow.CreateCreateRequest(snapshot, record)
|
|
: workflow.CreateUpdateRequest(snapshot, record);
|
|
_manualFinancialSnapshot = await workflow.ExecuteAsync(
|
|
snapshot,
|
|
request,
|
|
cancellationToken).ConfigureAwait(false);
|
|
ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장");
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> DeleteManualFinancialAsync(
|
|
bool all,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
LegacyManualFinancialWriteRequest request = all
|
|
? workflow.CreateDeleteAllRequest(
|
|
snapshot,
|
|
snapshot.Definition.DeleteAllConfirmationToken)
|
|
: workflow.CreateDeleteRequest(snapshot);
|
|
_manualFinancialSnapshot = await workflow.ExecuteAsync(
|
|
snapshot,
|
|
request,
|
|
cancellationToken).ConfigureAwait(false);
|
|
ApplyManualFinancialResultStatus(
|
|
_manualFinancialSnapshot,
|
|
all ? "전체 삭제" : "삭제");
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateManualFinancialAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (!TryGetManualFinancial(out var workflow, out var snapshot))
|
|
{
|
|
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = workflow.MaterializePlaylistDraft(snapshot, actionId);
|
|
AppendManualFinancialRow(draft);
|
|
_statusMessage = $"{draft.Title} {draft.Detail} 컷을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> OpenManualNetSellAsync(
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
|
|
}
|
|
|
|
return await OpenManualNetSellCoreAsync(audience, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> OpenManualNetSellCoreAsync(
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
await _manualListsWorkflow.OpenNetSellAsync(audience, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> OpenManualViAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요.");
|
|
}
|
|
|
|
return await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> OpenManualViCoreAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
await _manualListsWorkflow.OpenViAsync(cancellationToken).ConfigureAwait(false);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot CloseManualLists()
|
|
{
|
|
_manualListsWorkflow?.Close();
|
|
if (_pendingFixedSectionBatch is { } pending)
|
|
{
|
|
_pendingFixedSectionBatch = null;
|
|
_statusMessage =
|
|
$"{pending.SectionName} 섹션 추가를 취소했습니다. 편성표에는 아무 항목도 추가하지 않았습니다. 이미 확인한 수동 저장 내용은 유지됩니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> RefreshManualListsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
await _manualListsWorkflow.RefreshAsync(cancellationToken).ConfigureAwait(false);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SetManualNetSellCell(
|
|
string rowId,
|
|
LegacyManualNetSellField field,
|
|
string value)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
_manualListsWorkflow.SetNetSellCell(rowId, field, value);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SaveManualNetSellAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
|
|
}
|
|
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
await _manualListsWorkflow.SaveNetSellAsync(cancellationToken).ConfigureAwait(false);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot AddManualNetSellPlaylistRow()
|
|
{
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
|
|
}
|
|
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
AppendManualListRow(_manualListsWorkflow.CreateNetSellPlaylistDraft());
|
|
_statusMessage = "검증된 수동 순매도 컷을 편성표에 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchManualViAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
await _manualListsWorkflow.SearchViAsync(query, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectManualViSearchResult(string resultId)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("The manual VI list feature is unavailable.");
|
|
}
|
|
|
|
_manualListsWorkflow.SelectViSearchResult(resultId);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot AddManualViSearchResult(string resultId)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
_manualListsWorkflow.AddViSearchResult(resultId);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot MoveManualViItem(
|
|
string itemId,
|
|
LegacyManualListMoveDirection direction)
|
|
{
|
|
_manualListsWorkflow?.MoveViItem(itemId, direction);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot DeleteManualViItem(string itemId)
|
|
{
|
|
_manualListsWorkflow?.DeleteViItem(itemId);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot DeleteAllManualViItems()
|
|
{
|
|
_manualListsWorkflow?.DeleteAllViItems();
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SaveManualViAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다.");
|
|
}
|
|
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
await _manualListsWorkflow.SaveViAsync(cancellationToken).ConfigureAwait(false);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot AddManualViPlaylistRow()
|
|
{
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다.");
|
|
}
|
|
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
AppendManualListRow(_manualListsWorkflow.CreateViPlaylistDraft());
|
|
_statusMessage = "검증된 VI 발동 컷을 편성표에 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces the original FSell/VI OK button: persist once, verify the fresh
|
|
/// readback, append the corresponding cut, and close the editor as one operator
|
|
/// action. A failed or outcome-unknown save leaves the editor open and never
|
|
/// attempts playlist materialization or an automatic retry.
|
|
/// </summary>
|
|
public async Task<LegacyOperatorSnapshot> ConfirmManualListsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
var screen = _manualListsWorkflow.Current.Screen;
|
|
if (screen is null)
|
|
{
|
|
return ReportError("열려 있는 수동 목록 입력창이 없습니다.");
|
|
}
|
|
|
|
if (_pendingFixedSectionBatch is { } pending &&
|
|
(GetActiveFixedMarket() != pending.Market ||
|
|
!pending.IsCurrentManual(
|
|
screen.Value,
|
|
_manualListsWorkflow.Current.Audience)))
|
|
{
|
|
return ReportError(
|
|
"고정 컷 섹션의 현재 수동 입력 단계와 열린 입력창이 일치하지 않아 저장하지 않았습니다.");
|
|
}
|
|
|
|
if (screen == LegacyManualListScreen.NetSell)
|
|
{
|
|
await _manualListsWorkflow.SaveNetSellAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await _manualListsWorkflow.SaveViAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
var saved = _manualListsWorkflow.Current;
|
|
if (!saved.CanMaterializePlaylist)
|
|
{
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = screen == LegacyManualListScreen.NetSell
|
|
? _manualListsWorkflow.CreateNetSellPlaylistDraft()
|
|
: _manualListsWorkflow.CreateViPlaylistDraft();
|
|
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
_pendingFixedSectionBatch.StageCurrentManual(draft);
|
|
_manualListsWorkflow.Close();
|
|
return await ContinuePendingFixedSectionAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
AppendManualListRow(draft);
|
|
_manualListsWorkflow.Close();
|
|
_statusMessage = screen == LegacyManualListScreen.NetSell
|
|
? "수동 순매도 목록을 저장하고 편성표에 추가했습니다."
|
|
: "VI 발동 목록을 저장하고 편성표에 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> ImportLegacyManualListsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError("원본 수동 데이터 가져오기 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
await _manualListsWorkflow.ImportAsync(cancellationToken).ConfigureAwait(false);
|
|
ApplyManualListsStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateFixedAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
|
|
}
|
|
|
|
var market = GetActiveFixedMarket();
|
|
if (market is null)
|
|
{
|
|
return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var materialized = _fixedCatalog.Materialize(
|
|
actionId,
|
|
_timeProvider.GetLocalNow());
|
|
if (materialized.Action.Market != market.Value)
|
|
{
|
|
return ReportError("현재 탭에 속하지 않는 항목입니다.");
|
|
}
|
|
|
|
AppendFixedRow(materialized);
|
|
_statusMessage = $"{materialized.Action.Label} 항목을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return ReportError("등록되지 않은 고정 컷 항목입니다.");
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> ActivateFixedActionAsync(
|
|
string actionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요.");
|
|
}
|
|
|
|
var manualTarget = GetFixedManualTarget(actionId);
|
|
|
|
if (manualTarget is null)
|
|
{
|
|
return ActivateFixedAction(actionId);
|
|
}
|
|
|
|
var market = GetActiveFixedMarket();
|
|
if (market != LegacyFixedMarket.Index)
|
|
{
|
|
return ReportError("현재 탭에 속하지 않는 수동 입력 항목입니다.");
|
|
}
|
|
|
|
return manualTarget.Value.Screen == LegacyManualListScreen.NetSell
|
|
? await OpenManualNetSellAsync(
|
|
manualTarget.Value.Audience,
|
|
cancellationToken).ConfigureAwait(false)
|
|
: await OpenManualViAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Preserves UC1 parent-node direct-child order while closing its manual-child gap:
|
|
/// the original parent path removed FSell rows and returned at VI, because the
|
|
/// ShowDialog calls lived only in the leaf path. Automatic children are materialized
|
|
/// into a native staging area first; FSell/VI children are opened one at a time and
|
|
/// the playlist is committed only after every manual child has been confirmed.
|
|
/// </summary>
|
|
public async Task<LegacyOperatorSnapshot> ActivateFixedSection(
|
|
int sectionIndex,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var market = GetActiveFixedMarket();
|
|
if (market is null)
|
|
{
|
|
return ReportError("현재 탭에서는 고정 컷 섹션을 선택할 수 없습니다.");
|
|
}
|
|
|
|
var sections = _fixedCatalog.GetSections(market.Value);
|
|
if (sectionIndex < 0 || sectionIndex >= sections.Count)
|
|
{
|
|
return ReportError("등록되지 않은 고정 컷 섹션입니다.");
|
|
}
|
|
|
|
if (_pendingFixedSectionBatch is not null)
|
|
{
|
|
return ReportError(
|
|
"이미 고정 컷 섹션의 수동 입력을 진행하고 있습니다. 먼저 완료하거나 취소하세요.");
|
|
}
|
|
|
|
if (_manualListsWorkflow?.Current.IsOpen == true)
|
|
{
|
|
return ReportError("열려 있는 수동 입력창을 먼저 완료하거나 닫으세요.");
|
|
}
|
|
|
|
var section = sections[sectionIndex];
|
|
try
|
|
{
|
|
var localNow = _timeProvider.GetLocalNow();
|
|
var items = new List<PendingFixedSectionItem>(section.Actions.Count);
|
|
foreach (var action in section.Actions)
|
|
{
|
|
var manualTarget = GetFixedManualTarget(action.Id);
|
|
if (manualTarget is not null)
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return ReportError(
|
|
"이 섹션에는 수동 입력 항목이 있지만 수동 목록 기능을 사용할 수 없습니다.");
|
|
}
|
|
|
|
items.Add(new PendingFixedSectionItem(action, manualTarget.Value));
|
|
continue;
|
|
}
|
|
|
|
// Every non-manual child is validated before the first playlist row
|
|
// is appended or the first modal editor is opened.
|
|
items.Add(new PendingFixedSectionItem(
|
|
action,
|
|
_fixedCatalog.Materialize(
|
|
action.Id,
|
|
localNow)));
|
|
}
|
|
|
|
var manualCount = items.Count(item => item.ManualTarget is not null);
|
|
if (manualCount == 0)
|
|
{
|
|
CommitFixedSectionBatch(items);
|
|
_statusMessage = $"{section.Name} 항목 {items.Count}개를 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_pendingFixedSectionBatch = new PendingFixedSectionBatch(
|
|
market.Value,
|
|
sectionIndex,
|
|
section.Name,
|
|
items,
|
|
manualCount);
|
|
return await OpenPendingFixedSectionManualAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
_pendingFixedSectionBatch = null;
|
|
return ReportError("섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectIndustry(int index, bool doubleClick)
|
|
{
|
|
if (!IsActiveIndustryReady())
|
|
{
|
|
return ReportError("업종 목록을 먼저 불러오세요.");
|
|
}
|
|
|
|
if (doubleClick)
|
|
{
|
|
_industryWorkflow!.DoubleClickIndustry(index);
|
|
}
|
|
else
|
|
{
|
|
_industryWorkflow!.SelectIndustry(index);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SwapIndustries()
|
|
{
|
|
if (!IsActiveIndustryReady())
|
|
{
|
|
return ReportError("업종 목록을 먼저 불러오세요.");
|
|
}
|
|
|
|
_industryWorkflow!.SwapIndustries();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SetIndustryComparisonText(
|
|
LegacyIndustryComparisonSlot slot,
|
|
string text)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(text);
|
|
if (!IsActiveIndustryReady())
|
|
{
|
|
return ReportError("업종 목록을 먼저 불러오세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
_industryWorkflow!.SetComparisonText(slot, text);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return ReportError("등록되지 않은 업종 비교 입력칸입니다.");
|
|
}
|
|
catch (LegacyIndustryWorkflowException exception)
|
|
{
|
|
return ReportError(exception.Message);
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateIndustryAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (!IsActiveIndustryReady())
|
|
{
|
|
return ReportError("업종 목록을 먼저 불러오세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = _industryWorkflow!.MaterializePlaylistDraft(actionId);
|
|
AppendIndustryRow(draft, _industryWorkflow.Current.Industries.Count);
|
|
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (LegacyIndustryWorkflowException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateIndustrySection(int sectionIndex)
|
|
{
|
|
if (!IsActiveIndustryReady())
|
|
{
|
|
return ReportError("업종 목록을 먼저 불러오세요.");
|
|
}
|
|
|
|
// MainForm constructs both industry UC1 instances with UC3 == null. The
|
|
// original root double-click path dereferences UC3 before its 22-child loop,
|
|
// then its outer catch silently consumes the exception. Preserve that
|
|
// observable no-op; only child double-clicks add industry playlist rows.
|
|
return sectionIndex == 0
|
|
? CreateSnapshot()
|
|
: ReportError("등록되지 않은 업종 컷 섹션입니다.");
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectOverseasFixedIndex(string targetId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(targetId);
|
|
if (!IsOverseasActive() || _overseasWorkflow is null)
|
|
{
|
|
return ReportError("해외종목 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var selected = _overseasWorkflow.SelectFixedIndex(targetId).SelectedFixedIndex!;
|
|
_statusMessage = $"{selected.DisplayName} 지수를 선택했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return ReportError("등록되지 않은 해외지수입니다.");
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchOverseasIndustriesAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!IsOverseasActive() || _overseasWorkflow is null)
|
|
{
|
|
return ReportError("해외종목 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
var overseas = await _overseasWorkflow.SearchIndustriesAsync(
|
|
query,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
_statusMessage = $"해외업종 {overseas.Industry.ResultCount}건을 찾았습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchOverseasStocksAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!IsOverseasActive() || _overseasWorkflow is null)
|
|
{
|
|
return ReportError("해외종목 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
var overseas = await _overseasWorkflow.SearchStocksAsync(
|
|
query,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
_statusMessage = $"해외종목 {overseas.Stock.ResultCount}건을 찾았습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectOverseasIndustry(string selectionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsOverseasActive() || _overseasWorkflow is null)
|
|
{
|
|
return ReportError("해외종목 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var selected = _overseasWorkflow.SelectIndustry(selectionId).Industry.Selected!;
|
|
_statusMessage = $"{selected.KoreanName} 업종을 선택했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return ReportError("현재 검색 결과에 없는 해외업종입니다.");
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectOverseasStock(string selectionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsOverseasActive() || _overseasWorkflow is null)
|
|
{
|
|
return ReportError("해외종목 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var selected = _overseasWorkflow.SelectStock(selectionId).Stock.Selected!;
|
|
_statusMessage = $"{selected.InputName} 종목을 선택했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return ReportError("현재 검색 결과에 없는 해외종목입니다.");
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateOverseasAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (!IsOverseasActive() || _overseasWorkflow is null)
|
|
{
|
|
return ReportError("해외종목 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = _overseasWorkflow.MaterializePlaylistDraft(actionId);
|
|
AppendOverseasRow(draft);
|
|
_statusMessage = $"{draft.Title} 항목을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (LegacyOverseasWorkflowException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchComparisonDomesticAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
await _comparisonWorkflow.SearchDomesticAsync(query, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchComparisonWorldAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
await _comparisonWorkflow.SearchWorldAsync(query, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectComparisonDomesticResult(string selectionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("Select the comparison tab first.");
|
|
}
|
|
|
|
_comparisonWorkflow.SelectDomesticResult(selectionId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectComparisonWorldResult(string selectionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("Select the comparison tab first.");
|
|
}
|
|
|
|
_comparisonWorkflow.SelectWorldResult(selectionId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectComparisonFixedTarget(string targetId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(targetId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("Select the comparison tab first.");
|
|
}
|
|
|
|
_comparisonWorkflow.SelectFixedTarget(targetId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ChooseComparisonDomesticResult(string selectionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_comparisonWorkflow.ChooseDomesticResult(selectionId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ChooseComparisonWorldResult(string selectionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_comparisonWorkflow.ChooseWorldResult(selectionId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ChooseComparisonFixedTarget(string targetId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(targetId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_comparisonWorkflow.ChooseFixedTarget(targetId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SwapComparisonTargets()
|
|
{
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_comparisonWorkflow.SwapDraftTargets();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ClearComparisonTargets()
|
|
{
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_comparisonWorkflow.ClearDraftTargets();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> AddComparisonPairAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
await _comparisonWorkflow.AddDraftPairAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectComparisonPair(string rowId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rowId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_comparisonWorkflow.SelectSavedPair(rowId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> MoveComparisonPairAsync(
|
|
LegacyComparisonPairMoveDirection direction,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
await _comparisonWorkflow.MoveSelectedPairAsync(direction, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> DeleteComparisonPairAsync(
|
|
bool all,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
if (all)
|
|
{
|
|
if (_dialog is not null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonDeleteAll(
|
|
_comparisonWorkflow.Current.Revision);
|
|
_dialog = new LegacyDialogState(
|
|
"모두 삭제하겠습니까?",
|
|
"MmoneyCoder",
|
|
IsConfirmation: true);
|
|
}
|
|
else
|
|
{
|
|
await _comparisonWorkflow.DeleteSelectedPairAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateComparisonAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = _comparisonWorkflow.MaterializeSelectedAction(actionId);
|
|
AppendComparisonRow(draft);
|
|
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return ReportError("등록되지 않은 비교 컷 항목입니다.");
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateComparisonSection(int sectionIndex)
|
|
{
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null)
|
|
{
|
|
return ReportError("비교 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
var snapshot = _comparisonWorkflow.Current;
|
|
var sections = snapshot.Actions
|
|
.Select(static action => action.Section)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (sectionIndex < 0 || sectionIndex >= sections.Length)
|
|
{
|
|
return ReportError("등록되지 않은 비교 컷 섹션입니다.");
|
|
}
|
|
|
|
var section = sections[sectionIndex];
|
|
var selectedPair = snapshot.SavedPairs.FirstOrDefault(static pair => pair.IsSelected);
|
|
if (string.Equals(section, "종목별 수익률 비교", StringComparison.Ordinal) &&
|
|
selectedPair is not null &&
|
|
(selectedPair.First.Kind == LegacyComparisonTargetKind.NxtStock ||
|
|
selectedPair.Second.Kind == LegacyComparisonTargetKind.NxtStock))
|
|
{
|
|
_dialog = new LegacyDialogState(
|
|
"NXT 종목이 포함된 비교쌍에는 종목별 수익률 비교 섹션을 추가할 수 없습니다.");
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var actions = snapshot.Actions
|
|
.Where(action => string.Equals(
|
|
action.Section,
|
|
section,
|
|
StringComparison.Ordinal))
|
|
.ToArray();
|
|
if (actions.Length == 0 || actions.Any(static action => !action.IsAvailable))
|
|
{
|
|
_dialog = new LegacyDialogState(
|
|
"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다.");
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
try
|
|
{
|
|
var drafts = actions
|
|
.Select(action => _comparisonWorkflow.MaterializeSelectedAction(action.ActionId))
|
|
.ToArray();
|
|
foreach (var draft in drafts)
|
|
{
|
|
AppendComparisonRow(draft);
|
|
}
|
|
|
|
_statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(
|
|
$"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}");
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return ReportError("등록되지 않은 비교 컷 항목입니다.");
|
|
}
|
|
}
|
|
|
|
public Task<LegacyOperatorSnapshot> SearchThemesAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default) =>
|
|
SearchThemesCoreAsync(query, CurrentThemeNxtSession(), cancellationToken);
|
|
|
|
// Compatibility overload for native callers. UC4 does not allow a WebView
|
|
// payload to select the NXT session; TimeProvider remains authoritative.
|
|
public Task<LegacyOperatorSnapshot> SearchThemesAsync(
|
|
string query,
|
|
MMoneyCoderSharp.Data.ThemeSession ignoredNxtSession,
|
|
CancellationToken cancellationToken = default) =>
|
|
SearchThemesAsync(query, cancellationToken);
|
|
|
|
private async Task<LegacyOperatorSnapshot> SearchThemesCoreAsync(
|
|
string query,
|
|
MMoneyCoderSharp.Data.ThemeSession nxtSession,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
|
|
{
|
|
return ReportError("테마 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_themeSnapshot = await _themeWorkflow.SearchAsync(
|
|
_themeSnapshot,
|
|
query,
|
|
nxtSession,
|
|
cancellationToken).ConfigureAwait(false);
|
|
_themeActivationListLoaded = true;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SelectThemeAsync(
|
|
string resultId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(resultId);
|
|
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
|
|
{
|
|
return ReportError("테마 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
_themeSnapshot = await _themeWorkflow.SelectAsync(
|
|
_themeSnapshot,
|
|
resultId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return ReportError("등록되지 않은 테마 검색 결과입니다.");
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> ActivateThemeResultAsync(
|
|
string resultId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(resultId);
|
|
const string command = "activate-theme-result";
|
|
const string originalDoubleClickAction = "five-row-current";
|
|
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
|
|
{
|
|
CompleteCommand(command, resultId, succeeded: false);
|
|
return ReportError("Select the theme tab before activating a result.");
|
|
}
|
|
|
|
try
|
|
{
|
|
// The original row double-click first selected the row, then emitted the
|
|
// fixed five-row current-price cut. Await preview loading so those stages
|
|
// cannot race each other.
|
|
_themeSnapshot = await _themeWorkflow.SelectAsync(
|
|
_themeSnapshot,
|
|
resultId,
|
|
cancellationToken).ConfigureAwait(false);
|
|
var draft = _themeWorkflow.MaterializePlaylistDraft(
|
|
_themeSnapshot,
|
|
originalDoubleClickAction);
|
|
AppendThemeRow(draft);
|
|
_statusMessage = $"{draft.Detail} item was added.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
CompleteCommand(command, resultId, succeeded: true);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or
|
|
InvalidOperationException or KeyNotFoundException or
|
|
LegacyThemeWorkflowDataException)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
_statusMessage = exception.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
CompleteCommand(command, resultId, succeeded: false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SetThemeSort(string sortId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(sortId);
|
|
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
|
|
{
|
|
return ReportError("테마 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_themeSnapshot = _themeWorkflow.SelectSort(_themeSnapshot, sortId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateThemeAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
|
|
{
|
|
return ReportError("테마 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = _themeWorkflow.MaterializePlaylistDraft(_themeSnapshot, actionId);
|
|
AppendThemeRow(draft);
|
|
_statusMessage = $"{draft.Detail} 항목을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return ReportError("등록되지 않은 테마 컷 항목입니다.");
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchExpertsAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!IsExpertActive() || _expertWorkflow is null)
|
|
{
|
|
return ReportError("전문가 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
await _expertWorkflow.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
|
_expertActivationListLoaded = true;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SelectExpertAsync(
|
|
string selectionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsExpertActive() || _expertWorkflow is null)
|
|
{
|
|
return ReportError("전문가 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
await _expertWorkflow.SelectExpertAsync(selectionId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return ReportError("등록되지 않은 전문가 검색 결과입니다.");
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateExpertAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (!IsExpertActive() || _expertWorkflow is null)
|
|
{
|
|
return ReportError("전문가 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = _expertWorkflow.MaterializeSelectedAction(actionId);
|
|
AppendExpertRow(draft);
|
|
_statusMessage = $"{draft.Title} 추천 항목을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return ReportError("등록되지 않은 전문가 컷 항목입니다.");
|
|
}
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SearchTradingHaltsAsync(
|
|
string query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
|
|
{
|
|
return ReportError("정지 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
await _tradingHaltWorkflow.SearchAsync(query, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectTradingHalt(string selectionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selectionId);
|
|
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
|
|
{
|
|
return ReportError("정지 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
_tradingHaltWorkflow.SelectResult(selectionId);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return ReportError("등록되지 않은 거래정지 검색 결과입니다.");
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ToggleTradingHaltSort()
|
|
{
|
|
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
|
|
{
|
|
return ReportError("정지 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
_tradingHaltWorkflow.ToggleNameSort();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ActivateTradingHaltAction(string actionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(actionId);
|
|
if (!IsTradingHaltActive() || _tradingHaltWorkflow is null)
|
|
{
|
|
return ReportError("정지 탭을 먼저 선택하세요.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = _tradingHaltWorkflow.MaterializeSelectedAction(actionId);
|
|
AppendTradingHaltRow(draft);
|
|
_statusMessage = $"{draft.Title} 항목을 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
_dialog = new LegacyDialogState(exception.Message);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return ReportError("등록되지 않은 거래정지 컷 항목입니다.");
|
|
}
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectPlaylistRow(
|
|
string rowId,
|
|
bool control,
|
|
bool shift)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rowId);
|
|
var index = FindPlaylistIndex(rowId);
|
|
if (index < 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_activePlaylistRowId = rowId;
|
|
|
|
// MainForm handled Control before Shift. Consequently Ctrl+Shift added only
|
|
// the clicked row, and clicking an already selected row did not remove it.
|
|
if (control)
|
|
{
|
|
_selectedPlaylistRowIds.Add(rowId);
|
|
}
|
|
else
|
|
{
|
|
if (_playlistCursorIndex != index)
|
|
{
|
|
_playlistPriorCursorIndex = _playlistCursorIndex;
|
|
_playlistCursorIndex = index;
|
|
}
|
|
|
|
_selectedPlaylistRowIds.Clear();
|
|
if (shift)
|
|
{
|
|
AddPlaylistRange(_playlistPriorCursorIndex, _playlistCursorIndex);
|
|
}
|
|
else
|
|
{
|
|
_selectedPlaylistRowIds.Add(rowId);
|
|
}
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves only MainForm's m_rowidx equivalent. A click in the checkbox column
|
|
/// selected the next F8 candidate before its separate enabled-state handling,
|
|
/// but did not replace the rows accumulated for Ctrl/Shift deletion.
|
|
/// </summary>
|
|
public LegacyOperatorSnapshot ActivatePlaylistRow(string rowId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rowId);
|
|
var index = FindPlaylistIndex(rowId);
|
|
if (index < 0 || string.Equals(
|
|
_activePlaylistRowId,
|
|
rowId,
|
|
StringComparison.Ordinal))
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_activePlaylistRowId = rowId;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ToggleActivePlaylistRowEnabled()
|
|
{
|
|
return _activePlaylistRowId is null
|
|
? CreateSnapshot()
|
|
: TogglePlaylistRowEnabled(_activePlaylistRowId);
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectPlaylistBoundary(bool last)
|
|
{
|
|
if (_playlist.Count == 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var index = last ? _playlist.Count - 1 : 0;
|
|
var rowId = _playlist[index].RowId;
|
|
_activePlaylistRowId = rowId;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot DeleteSelectedPlaylistRows()
|
|
{
|
|
if (_selectedPlaylistRowIds.Count == 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var originalRows = _playlist.ToArray();
|
|
var removedIds = new HashSet<string>(
|
|
_selectedPlaylistRowIds,
|
|
StringComparer.Ordinal);
|
|
var activeIndex = FindOriginalPlaylistIndex(originalRows, _activePlaylistRowId);
|
|
var cursorIndex = Math.Clamp(_playlistCursorIndex, 0, originalRows.Length - 1);
|
|
var priorCursorIndex = Math.Clamp(
|
|
_playlistPriorCursorIndex,
|
|
0,
|
|
originalRows.Length - 1);
|
|
var cursorRowId = originalRows[cursorIndex].RowId;
|
|
var priorCursorRowId = originalRows[priorCursorIndex].RowId;
|
|
|
|
_playlist.RemoveAll(row => removedIds.Contains(row.RowId));
|
|
_selectedPlaylistRowIds.Clear();
|
|
if (_playlist.Count == 0)
|
|
{
|
|
ResetPlaylistSelection();
|
|
}
|
|
else
|
|
{
|
|
_activePlaylistRowId = ResolveSurvivingPlaylistRowId(
|
|
originalRows,
|
|
removedIds,
|
|
_activePlaylistRowId,
|
|
activeIndex);
|
|
_playlistCursorIndex = ResolveSurvivingPlaylistIndex(
|
|
originalRows,
|
|
removedIds,
|
|
cursorRowId,
|
|
cursorIndex);
|
|
_playlistPriorCursorIndex = ResolveSurvivingPlaylistIndex(
|
|
originalRows,
|
|
removedIds,
|
|
priorCursorRowId,
|
|
priorCursorIndex);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot DeleteAllPlaylistRows()
|
|
{
|
|
if (_playlist.Count == 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_playlist.Clear();
|
|
ResetPlaylistSelection();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot MoveSelectedPlaylistRows(
|
|
LegacyPlaylistMoveDirection direction)
|
|
{
|
|
var activeIndex = _activePlaylistRowId is null
|
|
? -1
|
|
: FindPlaylistIndex(_activePlaylistRowId);
|
|
if (activeIndex < 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var targetIndex = direction switch
|
|
{
|
|
LegacyPlaylistMoveDirection.Up => activeIndex - 1,
|
|
LegacyPlaylistMoveDirection.Down => activeIndex + 1,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null)
|
|
};
|
|
if (targetIndex < 0 || targetIndex >= _playlist.Count)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
// Spd_UpDown exchanged all seven cell values for the active row only. It did
|
|
// not move an entire multi-selection as a block.
|
|
(_playlist[activeIndex], _playlist[targetIndex]) =
|
|
(_playlist[targetIndex], _playlist[activeIndex]);
|
|
|
|
_selectedPlaylistRowIds.Clear();
|
|
_selectedPlaylistRowIds.Add(_activePlaylistRowId!);
|
|
_playlistPriorCursorIndex = targetIndex;
|
|
_playlistCursorIndex = targetIndex;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ReorderPlaylistRows(
|
|
string sourceRowId,
|
|
string targetRowId,
|
|
LegacyDragDropPosition position)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(sourceRowId);
|
|
ArgumentNullException.ThrowIfNull(targetRowId);
|
|
|
|
var sourceIndex = FindPlaylistIndex(sourceRowId);
|
|
var targetIndex = FindPlaylistIndex(targetRowId);
|
|
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
// FarPoint's native row move uses the row being dragged as its source.
|
|
// MainForm's manually painted Ctrl/Shift deletion selection was not a
|
|
// native multi-row drag selection, so it must never turn this into a
|
|
// block move.
|
|
var movingRow = _playlist[sourceIndex];
|
|
var remainingRows = _playlist
|
|
.Where(row => !string.Equals(
|
|
row.RowId,
|
|
sourceRowId,
|
|
StringComparison.Ordinal))
|
|
.ToList();
|
|
var remainingTargetIndex = remainingRows.FindIndex(row =>
|
|
string.Equals(row.RowId, targetRowId, StringComparison.Ordinal));
|
|
if (remainingTargetIndex < 0)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var insertionIndex = position switch
|
|
{
|
|
LegacyDragDropPosition.Before => remainingTargetIndex,
|
|
LegacyDragDropPosition.After => remainingTargetIndex + 1,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null)
|
|
};
|
|
remainingRows.Insert(insertionIndex, movingRow);
|
|
|
|
var orderChanged = !_playlist
|
|
.Select(static row => row.RowId)
|
|
.SequenceEqual(
|
|
remainingRows.Select(static row => row.RowId),
|
|
StringComparer.Ordinal);
|
|
if (!orderChanged)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
_playlist.Clear();
|
|
_playlist.AddRange(remainingRows);
|
|
_selectedPlaylistRowIds.Clear();
|
|
_selectedPlaylistRowIds.Add(sourceRowId);
|
|
_activePlaylistRowId = sourceRowId;
|
|
_playlistCursorIndex = FindPlaylistIndex(sourceRowId);
|
|
_playlistPriorCursorIndex = _playlistCursorIndex;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> RefreshNamedPlaylistsAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_namedPlaylistWorkflow is null)
|
|
{
|
|
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var namedPlaylist = await _namedPlaylistWorkflow.RefreshAsync(
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
ApplyNamedPlaylistStatus(namedPlaylist);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch
|
|
{
|
|
ApplyNamedPlaylistStatus(
|
|
_namedPlaylistWorkflow.Current,
|
|
"DB 재생목록 목록을 불러오지 못했습니다.",
|
|
LegacyOperatorStatusKind.Error);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot SelectNamedPlaylist(string definitionId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(definitionId);
|
|
if (_namedPlaylistWorkflow is null)
|
|
{
|
|
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
ApplyNamedPlaylistStatus(
|
|
_namedPlaylistWorkflow.SelectDefinition(definitionId));
|
|
}
|
|
catch (Exception exception) when (exception is KeyNotFoundException or
|
|
InvalidOperationException or ArgumentException)
|
|
{
|
|
_dialog = new LegacyDialogState(
|
|
"선택한 DB 재생목록이 더 이상 유효하지 않습니다. 목록을 새로 고치세요.");
|
|
_statusMessage = _dialog.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> LoadSelectedNamedPlaylistAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_namedPlaylistWorkflow is null)
|
|
{
|
|
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
|
|
cancellationToken).ConfigureAwait(false);
|
|
var loadedRows = namedPlaylist.Rows
|
|
.Select(row =>
|
|
{
|
|
try
|
|
{
|
|
return CreateNamedPlaylistOperatorRow(row);
|
|
}
|
|
catch (InvalidDataException exception)
|
|
{
|
|
exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex;
|
|
throw;
|
|
}
|
|
})
|
|
.ToArray();
|
|
|
|
_playlist.Clear();
|
|
_playlist.AddRange(loadedRows);
|
|
ResetPlaylistSelection();
|
|
SelectInitialPlaylistCandidate();
|
|
ApplyNamedPlaylistStatus(namedPlaylist);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (InvalidDataException exception)
|
|
{
|
|
var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex
|
|
? $" {itemIndex + 1}행에"
|
|
: string.Empty;
|
|
_dialog = new LegacyDialogState(
|
|
$"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
|
|
_statusMessage = _dialog.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Error;
|
|
}
|
|
catch (Exception exception) when (exception is KeyNotFoundException or
|
|
InvalidOperationException or ArgumentException)
|
|
{
|
|
_dialog = new LegacyDialogState("불러올 DB 재생목록을 먼저 선택하세요.");
|
|
_statusMessage = _dialog.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
catch
|
|
{
|
|
ApplyNamedPlaylistStatus(
|
|
_namedPlaylistWorkflow.Current,
|
|
"선택한 DB 재생목록을 불러오지 못했습니다.",
|
|
LegacyOperatorStatusKind.Error);
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> LoadNamedPlaylistByIdAsync(
|
|
string definitionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(definitionId);
|
|
const string command = "load-named-playlist-by-id";
|
|
var selected = SelectNamedPlaylist(definitionId);
|
|
if (!string.Equals(
|
|
selected.NamedPlaylist?.SelectedDefinitionId,
|
|
definitionId,
|
|
StringComparison.Ordinal))
|
|
{
|
|
CompleteCommand(command, definitionId, succeeded: false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
var loaded = await LoadSelectedNamedPlaylistAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
var succeeded =
|
|
string.Equals(
|
|
loaded.NamedPlaylist?.SelectedDefinitionId,
|
|
definitionId,
|
|
StringComparison.Ordinal) &&
|
|
loaded.NamedPlaylist?.ReadStatus == LegacyNamedPlaylistReadStatus.Ready &&
|
|
loaded.NamedPlaylist?.DocumentFreshness == LegacyNamedPlaylistFreshness.Fresh &&
|
|
loaded.StatusKind == LegacyOperatorStatusKind.Information;
|
|
CompleteCommand(command, definitionId, succeeded);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> CreateNamedPlaylistAsync(
|
|
string title,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(title);
|
|
if (_namedPlaylistWorkflow is null)
|
|
{
|
|
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = _namedPlaylistWorkflow.CreateProgramDraft(title);
|
|
var result = await _namedPlaylistWorkflow.CreateProgramAsync(
|
|
draft,
|
|
cancellationToken).ConfigureAwait(false);
|
|
ApplyNamedPlaylistStatus(result.Snapshot);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or
|
|
InvalidOperationException)
|
|
{
|
|
_dialog = new LegacyDialogState(
|
|
"새 DB 재생목록을 만들 수 없습니다. 목록을 새로 고친 뒤 이름을 확인하세요.");
|
|
_statusMessage = _dialog.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SaveCurrentNamedPlaylistAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_namedPlaylistWorkflow is null)
|
|
{
|
|
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_playlist.Count > LegacyNamedPlaylistWorkflowController.MaximumItems)
|
|
{
|
|
throw new InvalidOperationException("The playlist is too large to save.");
|
|
}
|
|
|
|
// DC_LIST/PLAY_LIST compatibility is exactly the original seven visible/hidden
|
|
// fields. Do not persist the newer internal scene selection or scene alias.
|
|
var rows = _playlist.Select(row =>
|
|
{
|
|
var persistedSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
|
row.MarketText,
|
|
row.StockName,
|
|
row.GraphicType,
|
|
row.Subtype,
|
|
row.StockCode);
|
|
var entry = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry(
|
|
row.RowId,
|
|
row.SceneAlias,
|
|
row.IsEnabled,
|
|
row.FadeDuration,
|
|
persistedSelection);
|
|
return _namedPlaylistWorkflow.CreateRowDraft(
|
|
entry,
|
|
ParseNamedPlaylistPage(row.PageText));
|
|
}).ToArray();
|
|
|
|
var result = await _namedPlaylistWorkflow.SaveCurrentAsync(
|
|
rows,
|
|
cancellationToken).ConfigureAwait(false);
|
|
ApplyNamedPlaylistStatus(result.Snapshot);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or
|
|
InvalidOperationException or FormatException or OverflowException)
|
|
{
|
|
_dialog = new LegacyDialogState(
|
|
"현재 송출 목록을 저장할 수 없습니다. DB 재생목록 선택과 Page 값을 확인하세요.");
|
|
_statusMessage = _dialog.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> SaveCurrentNamedPlaylistToAsync(
|
|
string definitionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(definitionId);
|
|
const string command = "save-current-named-playlist-to";
|
|
var selected = SelectNamedPlaylist(definitionId);
|
|
if (!string.Equals(
|
|
selected.NamedPlaylist?.SelectedDefinitionId,
|
|
definitionId,
|
|
StringComparison.Ordinal))
|
|
{
|
|
CompleteCommand(command, definitionId, succeeded: false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
var selectedRevision = selected.NamedPlaylist?.Revision ?? -1;
|
|
|
|
var saved = await SaveCurrentNamedPlaylistAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
var succeeded =
|
|
(saved.NamedPlaylist?.Revision ?? -1) > selectedRevision &&
|
|
string.Equals(
|
|
saved.NamedPlaylist?.SelectedDefinitionId,
|
|
definitionId,
|
|
StringComparison.Ordinal) &&
|
|
saved.NamedPlaylist?.LastMutationOutcome is
|
|
LegacyNamedPlaylistMutationOutcome.CommittedFresh or
|
|
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
|
|
CompleteCommand(command, definitionId, succeeded);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> DeleteSelectedNamedPlaylistAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_namedPlaylistWorkflow is null)
|
|
{
|
|
return ReportError("DB 재생목록 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await _namedPlaylistWorkflow.DeleteSelectedAsync(
|
|
cancellationToken).ConfigureAwait(false);
|
|
ApplyNamedPlaylistStatus(result.Snapshot);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is ArgumentException or
|
|
InvalidOperationException)
|
|
{
|
|
_dialog = new LegacyDialogState("삭제할 DB 재생목록을 먼저 선택하세요.");
|
|
_statusMessage = _dialog.Message;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public async Task<LegacyOperatorSnapshot> ConfirmDialogAsync(
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var pending = _pendingNativeConfirmation;
|
|
if (_dialog?.IsConfirmation != true || pending is null)
|
|
{
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
// Consume the confirmation before crossing a database-write boundary. A
|
|
// timeout or unknown outcome must never leave a replayable Yes action.
|
|
_pendingNativeConfirmation = null;
|
|
_dialog = null;
|
|
|
|
return pending.Kind switch
|
|
{
|
|
PendingNativeConfirmationKind.DeleteTheme =>
|
|
await ExecuteConfirmedThemeDeleteAsync(
|
|
pending.ThemeIdentity!,
|
|
cancellationToken).ConfigureAwait(false),
|
|
PendingNativeConfirmationKind.DeleteExpert =>
|
|
await ExecuteConfirmedExpertDeleteAsync(
|
|
pending.ExpertIdentity!,
|
|
cancellationToken).ConfigureAwait(false),
|
|
PendingNativeConfirmationKind.ClearThemeDraft =>
|
|
ExecuteConfirmedThemeDraftClear(pending.CatalogRevision),
|
|
PendingNativeConfirmationKind.DeleteAllComparisonPairs =>
|
|
await ExecuteConfirmedComparisonDeleteAllAsync(
|
|
pending.ComparisonRevision,
|
|
cancellationToken)
|
|
.ConfigureAwait(false),
|
|
_ => throw new InvalidOperationException("Unknown native confirmation kind.")
|
|
};
|
|
}
|
|
|
|
public LegacyOperatorSnapshot CancelDialog()
|
|
{
|
|
if (_dialog?.IsConfirmation == true)
|
|
{
|
|
_pendingNativeConfirmation = null;
|
|
_dialog = null;
|
|
Touch();
|
|
}
|
|
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot DismissDialog()
|
|
{
|
|
if (_dialog is not null && !_dialog.IsConfirmation)
|
|
{
|
|
_dialog = null;
|
|
if (_closeOperatorCatalogAfterDialog)
|
|
{
|
|
_operatorCatalogWorkflow?.Close();
|
|
_closeOperatorCatalogAfterDialog = false;
|
|
}
|
|
ContinuePendingDrop();
|
|
Touch();
|
|
}
|
|
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedThemeDeleteAsync(
|
|
MMoneyCoderSharp.Data.ThemeSelectionIdentity identity,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (_operatorCatalogWorkflow is null || _themeWorkflow is null ||
|
|
_themeSnapshot?.SelectedIdentity != identity)
|
|
{
|
|
return ReportError("삭제 대상 테마가 변경되어 DB 삭제를 시작하지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!_operatorCatalogWorkflow.Current.CanDelete)
|
|
{
|
|
ApplyOperatorCatalogStatus();
|
|
_operatorCatalogWorkflow.Close();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
|
|
if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List)
|
|
{
|
|
_themeSnapshot = _themeWorkflow.RemoveSelectedResult(_themeSnapshot);
|
|
_operatorCatalogWorkflow.Close();
|
|
_statusMessage = string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Neutral;
|
|
}
|
|
else
|
|
{
|
|
ApplyOperatorCatalogStatus();
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedExpertDeleteAsync(
|
|
MMoneyCoderSharp.Data.ExpertSelectionIdentity identity,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var selected = _expertWorkflow?.Current.SelectedExpert;
|
|
if (_operatorCatalogWorkflow is null || selected is null ||
|
|
!string.Equals(selected.ExpertCode, identity.ExpertCode, StringComparison.Ordinal) ||
|
|
!string.Equals(selected.ExpertName, identity.ExpertName, StringComparison.Ordinal))
|
|
{
|
|
return ReportError("삭제 대상 전문가가 변경되어 DB 삭제를 시작하지 않았습니다.");
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.OpenExpertForEditAsync(identity, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!_operatorCatalogWorkflow.Current.CanDelete)
|
|
{
|
|
ApplyOperatorCatalogStatus();
|
|
_operatorCatalogWorkflow.Close();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
|
|
if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List)
|
|
{
|
|
_expertWorkflow?.RemoveSelectedResultPreservingPreview();
|
|
_operatorCatalogWorkflow.Close();
|
|
_statusMessage = string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Neutral;
|
|
}
|
|
else
|
|
{
|
|
ApplyOperatorCatalogStatus();
|
|
}
|
|
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
private LegacyOperatorSnapshot ExecuteConfirmedThemeDraftClear(long catalogRevision)
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
|
|
}
|
|
|
|
var catalog = _operatorCatalogWorkflow.Current;
|
|
if (catalog.Revision != catalogRevision || !catalog.IsOpen ||
|
|
catalog.Entity != LegacyOperatorCatalogEntity.Theme ||
|
|
catalog.Mode is not (LegacyOperatorCatalogMode.Create or
|
|
LegacyOperatorCatalogMode.Edit))
|
|
{
|
|
return ReportError("ThemeA 편집 목록이 변경되어 전체 삭제를 실행하지 않았습니다.");
|
|
}
|
|
|
|
_operatorCatalogWorkflow.ClearThemeDraftRows();
|
|
ApplyOperatorCatalogStatus();
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ExecuteConfirmedComparisonDeleteAllAsync(
|
|
long comparisonRevision,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!IsComparisonActive() || _comparisonWorkflow is null ||
|
|
_comparisonWorkflow.Current.Revision != comparisonRevision)
|
|
{
|
|
return ReportError(
|
|
"삭제 대상 종목 비교 목록이 변경되어 전체 삭제를 실행하지 않았습니다.");
|
|
}
|
|
|
|
await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ReportError(string message)
|
|
{
|
|
_statusMessage = message ?? string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Error;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Completes a correlated named-playlist command when the native dispatch
|
|
/// boundary rejects the request or encounters an unexpected exception.
|
|
/// The terminal receipt prevents the WebView modal from waiting indefinitely;
|
|
/// the named-playlist workflow remains the authority for any write quarantine
|
|
/// that was already latched before the exception escaped.
|
|
/// </summary>
|
|
public LegacyOperatorSnapshot ReportNamedPlaylistCommandFailure(
|
|
string command,
|
|
string targetId,
|
|
string message)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
|
|
if (command is not "refresh-named-playlists" and
|
|
not "select-named-playlist" and
|
|
not "create-named-playlist" and
|
|
not "delete-selected-named-playlist" and
|
|
not "load-named-playlist-by-id" and
|
|
not "save-current-named-playlist-to")
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(command),
|
|
command,
|
|
"Only correlated named-playlist commands can be completed here.");
|
|
}
|
|
|
|
CompleteCommand(command, targetId, succeeded: false);
|
|
return ReportError(message);
|
|
}
|
|
|
|
public LegacyOperatorSnapshot CompleteNamedPlaylistDispatch(
|
|
string command,
|
|
string requestId,
|
|
bool succeeded)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(requestId);
|
|
if (command is not "refresh-named-playlists" and
|
|
not "select-named-playlist" and
|
|
not "create-named-playlist" and
|
|
not "delete-selected-named-playlist")
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(command),
|
|
command,
|
|
"The command does not use a named-playlist dispatch id.");
|
|
}
|
|
|
|
CompleteCommand(command, requestId, succeeded);
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Terminates an accepted save command without making its database outcome
|
|
/// retryable. This is intentionally separate from a known pre-dispatch
|
|
/// rejection: an escaped post-dispatch exception permanently quarantines every
|
|
/// later named-playlist write in this process.
|
|
/// </summary>
|
|
public LegacyOperatorSnapshot ReportNamedPlaylistWriteOutcomeUnknown(
|
|
string command,
|
|
string targetId,
|
|
string message)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
|
|
if (command is not "create-named-playlist" and
|
|
not "delete-selected-named-playlist" and
|
|
not "save-current-named-playlist-to")
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(command),
|
|
command,
|
|
"The command is not a named-playlist write.");
|
|
}
|
|
|
|
_namedPlaylistWorkflow?.QuarantineAmbiguousWriteOutcome();
|
|
CompleteCommand(command, targetId, succeeded: false);
|
|
return ReportError(message);
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ReportInformation(string message)
|
|
{
|
|
_statusMessage = message ?? string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
public LegacyOperatorSnapshot ReportWarning(string message)
|
|
{
|
|
_statusMessage = message ?? string.Empty;
|
|
_statusKind = LegacyOperatorStatusKind.Warning;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
private void ContinuePendingDrop()
|
|
{
|
|
while (_pendingDropCutRows is { Count: > 0 })
|
|
{
|
|
var physicalIndex = _pendingDropCutRows.Dequeue();
|
|
if (!TryAppendCut(physicalIndex))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
_pendingDropCutRows = null;
|
|
}
|
|
|
|
private bool TryAppendCut(int physicalIndex)
|
|
{
|
|
if (_selectedStockIndex is not int stockIndex ||
|
|
stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count ||
|
|
!IsCutIndex(physicalIndex))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var cut = _cutRows[physicalIndex];
|
|
var stockName = _searchData.DisplayNames[stockIndex];
|
|
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
|
|
if (isNxt && !string.Equals(
|
|
cut.RawLabel,
|
|
LegacyStockCutCatalog.NxtAllowedLabel,
|
|
StringComparison.Ordinal))
|
|
{
|
|
_dialog = new LegacyDialogState("NXT는 제공하지 않습니다.");
|
|
return false;
|
|
}
|
|
|
|
if (cut.IsSeparator)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var identity = _searchData.ResolveLastByDisplayName(stockName);
|
|
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
|
|
AppendPlaylistRow(LegacyStockCutCatalog.CreatePlaylistRow(
|
|
rowId,
|
|
stockName,
|
|
identity,
|
|
cut));
|
|
return true;
|
|
}
|
|
|
|
private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift)
|
|
{
|
|
if (control && shift)
|
|
{
|
|
AddLegacyRangeFromAnchor(physicalIndex);
|
|
}
|
|
else if (control)
|
|
{
|
|
if (!_legacyPointerRows.Add(physicalIndex))
|
|
{
|
|
_legacyPointerRows.Remove(physicalIndex);
|
|
}
|
|
}
|
|
else if (shift)
|
|
{
|
|
_legacyPointerRows.Clear();
|
|
AddLegacyRangeFromAnchor(physicalIndex);
|
|
}
|
|
else
|
|
{
|
|
_legacyPointerRows.Clear();
|
|
_legacyPointerRows.Add(physicalIndex);
|
|
}
|
|
}
|
|
|
|
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
|
|
{
|
|
var anchor = _nativeSelectionAnchorIndex >= 0
|
|
? _nativeSelectionAnchorIndex
|
|
: physicalIndex;
|
|
if (control && shift)
|
|
{
|
|
AddInclusiveRange(
|
|
_nativeSelectedCutRows,
|
|
anchor,
|
|
physicalIndex);
|
|
}
|
|
else if (control)
|
|
{
|
|
if (!_nativeSelectedCutRows.Add(physicalIndex))
|
|
{
|
|
_nativeSelectedCutRows.Remove(physicalIndex);
|
|
}
|
|
}
|
|
else if (shift)
|
|
{
|
|
_nativeSelectedCutRows.Clear();
|
|
AddInclusiveRange(
|
|
_nativeSelectedCutRows,
|
|
anchor,
|
|
physicalIndex);
|
|
}
|
|
else
|
|
{
|
|
_nativeSelectedCutRows.Clear();
|
|
_nativeSelectedCutRows.Add(physicalIndex);
|
|
}
|
|
}
|
|
|
|
private void ApplyNativeKeyboardNavigationSelection(
|
|
int physicalIndex,
|
|
bool control,
|
|
bool shift)
|
|
{
|
|
var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
|
|
? _nativeSelectionAnchorIndex
|
|
: physicalIndex;
|
|
if (control && !shift)
|
|
{
|
|
// Ctrl+navigation moves the focus rectangle without changing either
|
|
// selection or the Win32 ListView selection mark.
|
|
return;
|
|
}
|
|
|
|
if (control && shift)
|
|
{
|
|
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
|
|
return;
|
|
}
|
|
|
|
_nativeSelectedCutRows.Clear();
|
|
if (shift)
|
|
{
|
|
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
|
|
return;
|
|
}
|
|
|
|
_nativeSelectedCutRows.Add(physicalIndex);
|
|
_nativeSelectionAnchorIndex = physicalIndex;
|
|
}
|
|
|
|
private void ApplyNativeSpaceSelection(int physicalIndex, bool control, bool shift)
|
|
{
|
|
var anchor = IsCutIndex(_nativeSelectionAnchorIndex)
|
|
? _nativeSelectionAnchorIndex
|
|
: physicalIndex;
|
|
if (control && shift)
|
|
{
|
|
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
|
|
return;
|
|
}
|
|
|
|
if (control)
|
|
{
|
|
if (!_nativeSelectedCutRows.Add(physicalIndex))
|
|
{
|
|
_nativeSelectedCutRows.Remove(physicalIndex);
|
|
}
|
|
|
|
_nativeSelectionAnchorIndex = physicalIndex;
|
|
return;
|
|
}
|
|
|
|
_nativeSelectedCutRows.Clear();
|
|
if (shift)
|
|
{
|
|
AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex);
|
|
return;
|
|
}
|
|
|
|
_nativeSelectedCutRows.Add(physicalIndex);
|
|
_nativeSelectionAnchorIndex = physicalIndex;
|
|
}
|
|
|
|
private void AddLegacyRangeFromAnchor(int physicalIndex)
|
|
{
|
|
// MainForm used beforeRow = -1 and skipped the range when the previous
|
|
// and current rows were equal. The sentinel also affected its double-click count.
|
|
var anchor = _cutAnchorIndex ?? -1;
|
|
if (anchor == physicalIndex)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var first = Math.Min(anchor, physicalIndex);
|
|
var last = Math.Max(anchor, physicalIndex);
|
|
for (var index = first; index <= last; index++)
|
|
{
|
|
_legacyPointerRows.Add(index);
|
|
}
|
|
}
|
|
|
|
private static void AddInclusiveRange(
|
|
ISet<int> target,
|
|
int anchor,
|
|
int physicalIndex)
|
|
{
|
|
var first = Math.Min(anchor, physicalIndex);
|
|
var last = Math.Max(anchor, physicalIndex);
|
|
for (var index = first; index <= last; index++)
|
|
{
|
|
target.Add(index);
|
|
}
|
|
}
|
|
|
|
private void ApplyNamedPlaylistStatus(
|
|
LegacyNamedPlaylistWorkflowSnapshot snapshot,
|
|
string? fallbackMessage = null,
|
|
LegacyOperatorStatusKind? fallbackKind = null)
|
|
{
|
|
_statusMessage = string.IsNullOrWhiteSpace(snapshot.StatusMessage)
|
|
? fallbackMessage ?? string.Empty
|
|
: snapshot.StatusMessage;
|
|
_statusKind = fallbackKind ?? snapshot.StatusKind;
|
|
}
|
|
|
|
private static MMoneyCoderSharp.Data.NamedPlaylistPageState? ParseNamedPlaylistPage(
|
|
string pageText)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pageText);
|
|
if (pageText.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!string.Equals(pageText, pageText.Trim(), StringComparison.Ordinal))
|
|
{
|
|
throw new FormatException("A named-playlist page must be canonical.");
|
|
}
|
|
|
|
var parts = pageText.Split('/');
|
|
if (parts.Length != 2 ||
|
|
!int.TryParse(
|
|
parts[0],
|
|
System.Globalization.NumberStyles.None,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out var currentPage) ||
|
|
!int.TryParse(
|
|
parts[1],
|
|
System.Globalization.NumberStyles.None,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out var totalPages))
|
|
{
|
|
throw new FormatException("A named-playlist page must use current/total form.");
|
|
}
|
|
|
|
return new MMoneyCoderSharp.Data.NamedPlaylistPageState(currentPage, totalPages);
|
|
}
|
|
|
|
private LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow(
|
|
LegacyNamedPlaylistRowView row)
|
|
{
|
|
var selection = row.Selection;
|
|
var sceneAlias = ResolveNamedPlaylistSceneAlias(selection);
|
|
var playoutSelection = TryCreateNamedFixedSelection(
|
|
selection,
|
|
out var fixedSelection,
|
|
out _)
|
|
? fixedSelection
|
|
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
|
|
? stockDetailSelection
|
|
: TryCreateNamedIndustrySelection(selection, out var industrySelection)
|
|
? industrySelection
|
|
: TryCreateNamedComparisonSelection(selection, out var comparisonSelection)
|
|
? comparisonSelection
|
|
: TryCreateNamedManualFinancialSelection(
|
|
selection,
|
|
out var manualFinancialSelection)
|
|
? manualFinancialSelection
|
|
: TryCreateNamedManualNetSellSelection(
|
|
selection,
|
|
out var manualNetSellSelection)
|
|
? manualNetSellSelection
|
|
: TryCreateNamedVersionedViSelection(selection, out var viSelection)
|
|
? viSelection
|
|
: new CoreSceneSelection(
|
|
selection.GroupCode,
|
|
selection.Subject,
|
|
selection.GraphicType,
|
|
selection.Subtype,
|
|
selection.DataCode);
|
|
var rawCutLabel = selection.Subtype.Length == 0
|
|
? selection.GraphicType
|
|
: $"{selection.GraphicType}_{selection.Subtype}";
|
|
return NormalizeMovingAverageIdentity(new LegacyOperatorPlaylistRow(
|
|
$"legacy-named-{++_playlistSequence:D8}",
|
|
row.IsEnabled,
|
|
selection.GroupCode,
|
|
selection.Subject,
|
|
selection.GraphicType,
|
|
selection.Subtype,
|
|
row.Page?.PageText ?? string.Empty,
|
|
selection.DataCode,
|
|
rawCutLabel,
|
|
6,
|
|
SceneAlias: sceneAlias,
|
|
PlayoutSelection: playoutSelection));
|
|
}
|
|
|
|
private string ResolveNamedPlaylistSceneAlias(
|
|
LegacyNamedPlaylistSelectionView selection)
|
|
{
|
|
var rawLabel = selection.Subtype.Length == 0
|
|
? selection.GraphicType
|
|
: $"{selection.GraphicType}_{selection.Subtype}";
|
|
|
|
// Stock and trading-halt rows retain the original stock-market and cut-label
|
|
// columns, so the closed stock catalog remains the authority for their alias.
|
|
if (selection.GroupCode is "코스피" or "코스닥" or
|
|
"코스피_NXT" or "코스닥_NXT")
|
|
{
|
|
try
|
|
{
|
|
return LegacyStockCutCatalog.ResolveCutAlias(
|
|
rawLabel,
|
|
selection.GroupCode.EndsWith("_NXT", StringComparison.Ordinal) ||
|
|
selection.Subject.Contains("(NXT)", StringComparison.Ordinal));
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// Continue through the other closed registries. This also permits
|
|
// manual-financial rows whose market column resembles a stock market.
|
|
}
|
|
}
|
|
|
|
if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
|
|
{
|
|
return fixedAction!.Code;
|
|
}
|
|
|
|
var industryMarket = selection.GroupCode switch
|
|
{
|
|
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
|
|
"업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
|
|
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
|
|
};
|
|
if (industryMarket is { } industry)
|
|
{
|
|
var actions = LegacyIndustryActionCatalog.GetActions(industry)
|
|
.Where(action => string.Equals(
|
|
action.Label,
|
|
rawLabel,
|
|
StringComparison.Ordinal))
|
|
.Take(2)
|
|
.ToArray();
|
|
if (actions.Length == 1)
|
|
{
|
|
return actions[0].SceneAlias;
|
|
}
|
|
}
|
|
|
|
if (selection.GroupCode is "테마" or "테마_NXT")
|
|
{
|
|
var detail = selection.Subtype.Length == 0
|
|
? selection.GraphicType
|
|
: $"{selection.GraphicType} · {selection.Subtype}";
|
|
var actions = LegacyThemeWorkflow.Actions
|
|
.Where(action => LegacyThemeWorkflow.Sorts.Any(sort => string.Equals(
|
|
$"{action.Label} · {sort.Label}",
|
|
detail,
|
|
StringComparison.Ordinal)))
|
|
.Take(2)
|
|
.ToArray();
|
|
if (actions.Length == 1)
|
|
{
|
|
return actions[0].Code;
|
|
}
|
|
}
|
|
|
|
if (string.Equals(selection.GroupCode, "전문가", StringComparison.Ordinal) &&
|
|
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
|
|
string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal))
|
|
{
|
|
return "5074";
|
|
}
|
|
|
|
// FSell rows are saved with the closed canonical selection emitted by the
|
|
// native manual-list workflow. Older PList rows use the original visible
|
|
// seven-field identity; the scene resolver accepts both shapes and performs
|
|
// the fresh trusted-file read again before any cue can reach Tornado2.
|
|
if (TryCreateNamedManualNetSellSelection(selection, out _))
|
|
{
|
|
return "5025";
|
|
}
|
|
|
|
// A versioned VI row carries no path or operator-controlled scene name. The
|
|
// native cue provider reopens the trusted snapshot and requires this exact
|
|
// version at PREPARE, so alias restoration remains fail-closed.
|
|
if (TryCreateNamedVersionedViSelection(selection, out _))
|
|
{
|
|
return "5074";
|
|
}
|
|
|
|
if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
|
|
{
|
|
var alias = selection.GraphicType switch
|
|
{
|
|
"s5032" => "5032",
|
|
"s8018" => "8032",
|
|
"s5029" => "5029",
|
|
"s5026" => "5026",
|
|
"s5087" => "5087",
|
|
_ => null
|
|
};
|
|
if (alias is not null)
|
|
{
|
|
return alias;
|
|
}
|
|
}
|
|
|
|
var manualFinancial = LegacyManualFinancialWorkflow.Screens
|
|
.Where(definition =>
|
|
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
|
|
(selection.GroupCode is "KOSPI" or "KOSDAQ" or
|
|
"NXT_KOSPI" or "NXT_KOSDAQ"))
|
|
.Take(2)
|
|
.ToArray();
|
|
if (manualFinancial.Length == 1)
|
|
{
|
|
return manualFinancial[0].Code;
|
|
}
|
|
|
|
throw new InvalidDataException(
|
|
"The named-playlist row does not map to a registered scene alias.");
|
|
}
|
|
|
|
private bool TryCreateNamedFixedSelection(
|
|
LegacyNamedPlaylistSelectionView selection,
|
|
out CoreSceneSelection normalized,
|
|
out LegacyFixedAction? action)
|
|
{
|
|
normalized = null!;
|
|
action = null;
|
|
var fixedMarket = selection.GroupCode switch
|
|
{
|
|
"해외지수" => LegacyFixedMarket.Overseas,
|
|
"환율" => LegacyFixedMarket.Exchange,
|
|
"주요지수" => LegacyFixedMarket.Index,
|
|
_ => (LegacyFixedMarket?)null
|
|
};
|
|
if (fixedMarket is not { } market)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var matches = _fixedCatalog.GetActions(market)
|
|
.Where(candidate =>
|
|
string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) &&
|
|
string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) &&
|
|
string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal))
|
|
.Take(2)
|
|
.ToArray();
|
|
if (matches.Length != 1 || !matches[0].Available)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
action = matches[0];
|
|
var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection;
|
|
normalized = new CoreSceneSelection(
|
|
materialized.GroupCode,
|
|
materialized.Subject,
|
|
materialized.GraphicType,
|
|
materialized.Subtype,
|
|
selection.DataCode);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryCreateNamedStockDetailSelection(
|
|
LegacyNamedPlaylistSelectionView selection,
|
|
out CoreSceneSelection normalized)
|
|
{
|
|
normalized = null!;
|
|
if (selection.GroupCode is not ("코스피" or "코스닥") ||
|
|
!string.Equals(selection.GraphicType, "1열판상세", StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var branch = selection.Subtype switch
|
|
{
|
|
"액면가" => nameof(S5011DetailBranch.FaceValue),
|
|
"PBR" => nameof(S5011DetailBranch.Valuation),
|
|
"거래량" => nameof(S5011DetailBranch.Volume),
|
|
_ => null
|
|
};
|
|
if (branch is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
LegacyComparisonValueGuard.RequireText(
|
|
selection.Subject,
|
|
200,
|
|
nameof(selection.Subject));
|
|
LegacyComparisonValueGuard.RequireStockCode(
|
|
selection.DataCode,
|
|
nameof(selection.DataCode));
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
normalized = new CoreSceneSelection(
|
|
selection.GroupCode,
|
|
selection.Subject,
|
|
selection.GraphicType,
|
|
branch,
|
|
selection.DataCode);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryCreateNamedIndustrySelection(
|
|
LegacyNamedPlaylistSelectionView selection,
|
|
out CoreSceneSelection normalized)
|
|
{
|
|
normalized = null!;
|
|
var market = selection.GroupCode switch
|
|
{
|
|
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
|
|
"업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
|
|
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
|
|
};
|
|
if (market is not { } exactMarket)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var rawLabel = selection.Subtype.Length == 0
|
|
? selection.GraphicType
|
|
: $"{selection.GraphicType}_{selection.Subtype}";
|
|
var actions = LegacyIndustryActionCatalog.GetActions(exactMarket)
|
|
.Where(action => string.Equals(action.Label, rawLabel, StringComparison.Ordinal))
|
|
.Take(2)
|
|
.ToArray();
|
|
if (actions.Length != 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
LegacyComparisonValueGuard.RequireText(
|
|
selection.Subject,
|
|
256,
|
|
nameof(selection.Subject));
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var action = actions[0];
|
|
var industryGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
|
? "INDUSTRY_KOSPI"
|
|
: "INDUSTRY_KOSDAQ";
|
|
var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
|
? "KOSPI"
|
|
: "KOSDAQ";
|
|
normalized = action.Kind switch
|
|
{
|
|
LegacyIndustryActionKind.Single => new CoreSceneSelection(
|
|
industryGroup,
|
|
selection.Subject,
|
|
"1열판",
|
|
"CURRENT",
|
|
string.Empty),
|
|
LegacyIndustryActionKind.Pair => new CoreSceneSelection(
|
|
industryGroup,
|
|
selection.Subject,
|
|
"2열판",
|
|
string.Empty,
|
|
string.Empty),
|
|
LegacyIndustryActionKind.Yield => new CoreSceneSelection(
|
|
industryGroup,
|
|
selection.Subject,
|
|
"YIELD_GRAPH",
|
|
action.YieldPeriod!,
|
|
string.Empty),
|
|
LegacyIndustryActionKind.Candle => new CoreSceneSelection(
|
|
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
|
? "KOSPI_INDUSTRY"
|
|
: "KOSDAQ_INDUSTRY",
|
|
selection.Subject,
|
|
string.Empty,
|
|
action.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE",
|
|
string.Empty),
|
|
LegacyIndustryActionKind.FiveRow when
|
|
DateOnly.TryParseExact(
|
|
selection.DataCode,
|
|
"yyyy-MM-dd",
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.None,
|
|
out _) => new CoreSceneSelection(
|
|
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
|
? "PAGED_DOMESTIC_KOSPI"
|
|
: "PAGED_DOMESTIC_KOSDAQ",
|
|
"INDEX",
|
|
string.Empty,
|
|
"INDUSTRY",
|
|
selection.DataCode),
|
|
LegacyIndustryActionKind.Square => new CoreSceneSelection(
|
|
industryGroup,
|
|
marketName,
|
|
"네모그래프",
|
|
string.Empty,
|
|
string.Empty),
|
|
LegacyIndustryActionKind.Sector => new CoreSceneSelection(
|
|
industryGroup,
|
|
marketName,
|
|
"SECTOR_INDEX",
|
|
string.Empty,
|
|
string.Empty),
|
|
_ => null!
|
|
};
|
|
return normalized is not null;
|
|
}
|
|
|
|
private static bool TryCreateNamedComparisonSelection(
|
|
LegacyNamedPlaylistSelectionView selection,
|
|
out CoreSceneSelection normalized)
|
|
{
|
|
normalized = null!;
|
|
if (!string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var names = selection.Subject.Split(" ↔ ", StringSplitOptions.None);
|
|
if (names.Length != 2 || names.Any(string.IsNullOrWhiteSpace) ||
|
|
names.Any(name => !string.Equals(name, name.Trim(), StringComparison.Ordinal)))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
LegacyComparisonTarget first;
|
|
LegacyComparisonTarget second;
|
|
if (selection.DataCode.Length == 0)
|
|
{
|
|
first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
|
|
new LegacyComparisonStoredTargetReference(names[0], string.Empty))!;
|
|
second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
|
|
new LegacyComparisonStoredTargetReference(names[1], string.Empty))!;
|
|
if (first is null || second is null)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else if (!TryCreateNamedComparisonStocks(
|
|
names,
|
|
selection.DataCode,
|
|
out first,
|
|
out second))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var actions = LegacyComparisonWorkflowCatalog.Actions
|
|
.Where(action => string.Equals(
|
|
action.Label,
|
|
selection.Subtype,
|
|
StringComparison.Ordinal))
|
|
.Take(2)
|
|
.ToArray();
|
|
if (actions.Length != 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var draft = LegacyComparisonWorkflowCatalog.Materialize(
|
|
actions[0].ActionId,
|
|
first,
|
|
second,
|
|
"named-restore");
|
|
if (!string.Equals(draft.BuilderKey, selection.GraphicType, StringComparison.Ordinal) ||
|
|
!string.Equals(draft.Detail, selection.Subtype, StringComparison.Ordinal) ||
|
|
!string.Equals(
|
|
draft.Selection.DataCode,
|
|
selection.DataCode,
|
|
StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
normalized = draft.Selection;
|
|
return true;
|
|
}
|
|
catch (Exception exception) when (exception is
|
|
ArgumentException or InvalidOperationException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryCreateNamedComparisonStocks(
|
|
IReadOnlyList<string> names,
|
|
string dataCode,
|
|
out LegacyComparisonTarget first,
|
|
out LegacyComparisonTarget second)
|
|
{
|
|
first = null!;
|
|
second = null!;
|
|
var identities = dataCode.Split('|', StringSplitOptions.None);
|
|
if (identities.Length != 2)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var targets = new LegacyComparisonTarget[2];
|
|
for (var index = 0; index < targets.Length; index++)
|
|
{
|
|
var separator = identities[index].IndexOf(':');
|
|
if (separator <= 0 ||
|
|
separator != identities[index].LastIndexOf(':') ||
|
|
separator == identities[index].Length - 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var market = identities[index][..separator] switch
|
|
{
|
|
"KOSPI" => LegacyComparisonDomesticMarket.Kospi,
|
|
"KOSDAQ" => LegacyComparisonDomesticMarket.Kosdaq,
|
|
_ => (LegacyComparisonDomesticMarket?)null
|
|
};
|
|
if (market is not { } exactMarket)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
targets[index] = LegacyComparisonTarget.CreateKrxStock(
|
|
exactMarket,
|
|
names[index],
|
|
identities[index][(separator + 1)..]);
|
|
}
|
|
|
|
first = targets[0];
|
|
second = targets[1];
|
|
return true;
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryCreateNamedManualFinancialSelection(
|
|
LegacyNamedPlaylistSelectionView selection,
|
|
out CoreSceneSelection normalized)
|
|
{
|
|
normalized = null!;
|
|
if (selection.GroupCode is not
|
|
("KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ"))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var definitions = LegacyManualFinancialWorkflow.Screens
|
|
.Where(definition =>
|
|
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
definition.CanonicalGraphicType,
|
|
selection.Subtype,
|
|
StringComparison.Ordinal))
|
|
.Take(2)
|
|
.ToArray();
|
|
if (definitions.Length != 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
LegacyComparisonValueGuard.RequireText(
|
|
selection.Subject,
|
|
200,
|
|
nameof(selection.Subject));
|
|
LegacyComparisonValueGuard.RequireStockCode(
|
|
selection.DataCode,
|
|
nameof(selection.DataCode));
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
normalized = new CoreSceneSelection(
|
|
selection.GroupCode,
|
|
selection.Subject,
|
|
definitions[0].CanonicalGraphicType,
|
|
string.Empty,
|
|
string.Empty);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryCreateNamedManualNetSellSelection(
|
|
LegacyNamedPlaylistSelectionView selection,
|
|
out CoreSceneSelection normalized)
|
|
{
|
|
normalized = null!;
|
|
if (selection.DataCode.Length != 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string? audience = null;
|
|
if (selection.GroupCode is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION" &&
|
|
string.Equals(selection.Subject, "INDEX", StringComparison.Ordinal) &&
|
|
string.Equals(selection.GraphicType, "MANUAL_NET_SELL", StringComparison.Ordinal) &&
|
|
string.Equals(selection.Subtype, "MANUAL_NET_SELL", StringComparison.Ordinal))
|
|
{
|
|
audience = selection.GroupCode;
|
|
}
|
|
else if (string.Equals(selection.Subject, "지수", StringComparison.Ordinal) &&
|
|
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
|
|
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
|
|
{
|
|
audience = selection.GroupCode switch
|
|
{
|
|
"개인 순매도 상위(수동)" => "INDIVIDUAL",
|
|
"외국인 순매도 상위(수동)" => "FOREIGN",
|
|
"기관 순매도 상위(수동)" => "INSTITUTION",
|
|
_ => null
|
|
};
|
|
}
|
|
else if (string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) &&
|
|
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
|
|
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
|
|
{
|
|
audience = selection.Subject switch
|
|
{
|
|
"개인 순매도 상위(수동)" => "INDIVIDUAL",
|
|
"외국인 순매도 상위(수동)" => "FOREIGN",
|
|
"기관 순매도 상위(수동)" => "INSTITUTION",
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
if (audience is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
normalized = new CoreSceneSelection(
|
|
audience,
|
|
"INDEX",
|
|
"MANUAL_NET_SELL",
|
|
"MANUAL_NET_SELL",
|
|
string.Empty);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryCreateNamedVersionedViSelection(
|
|
LegacyNamedPlaylistSelectionView selection,
|
|
out CoreSceneSelection normalized)
|
|
{
|
|
normalized = null!;
|
|
var canonical = string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
selection.Subject,
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(selection.GraphicType, "INPUT_ORDER", StringComparison.Ordinal) &&
|
|
string.Equals(selection.Subtype, "CURRENT", StringComparison.Ordinal);
|
|
var savedVisibleFields = string.Equals(
|
|
selection.GroupCode,
|
|
"지수",
|
|
StringComparison.Ordinal) &&
|
|
selection.Subject.Length > 0 &&
|
|
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
|
|
string.Equals(selection.Subtype, "VI 발동(수동)", StringComparison.Ordinal);
|
|
if ((!canonical && !savedVisibleFields) ||
|
|
!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.IsRowVersion(
|
|
selection.DataCode))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
normalized = new CoreSceneSelection(
|
|
"PAGED_VI",
|
|
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
|
|
"INPUT_ORDER",
|
|
"CURRENT",
|
|
selection.DataCode);
|
|
return true;
|
|
}
|
|
|
|
private LegacyOperatorSnapshot CreateSnapshot()
|
|
{
|
|
var searchRows = _searchData.DisplayNames
|
|
.Select((name, index) => new LegacyStockResultRow(index, name))
|
|
.ToArray();
|
|
var cutRows = _cutRows
|
|
.Select(row => new LegacyCutViewRow(
|
|
row.PhysicalIndex,
|
|
row.DisplayOrdinal,
|
|
row.RawLabel,
|
|
row.IsSeparator,
|
|
_nativeSelectedCutRows.Contains(row.PhysicalIndex),
|
|
row.PhysicalIndex == _nativeFocusedCutIndex))
|
|
.ToArray();
|
|
|
|
var playlistRows = _playlist
|
|
.Select(row => row with
|
|
{
|
|
IsSelected = _selectedPlaylistRowIds.Contains(row.RowId),
|
|
IsActive = string.Equals(
|
|
_activePlaylistRowId,
|
|
row.RowId,
|
|
StringComparison.Ordinal)
|
|
})
|
|
.ToArray();
|
|
var tabs = _tabOrder
|
|
.Select(tab => new LegacyOperatorTabState(
|
|
tab,
|
|
GetTabLabel(tab),
|
|
tab == _activeTab))
|
|
.ToArray();
|
|
var fixedCatalog = CreateFixedCatalogView();
|
|
var operatorCatalog = _operatorCatalogWorkflow?.Current;
|
|
|
|
return new LegacyOperatorSnapshot(
|
|
_revision,
|
|
_searchText,
|
|
Array.AsReadOnly(searchRows),
|
|
_selectedStockIndex,
|
|
Array.AsReadOnly(cutRows),
|
|
Array.AsReadOnly(playlistRows),
|
|
_dialog,
|
|
_statusMessage,
|
|
_statusKind,
|
|
Array.AsReadOnly(tabs),
|
|
_movingAverages,
|
|
fixedCatalog,
|
|
GetActiveIndustrySnapshot(),
|
|
IsThemeActive() ? _themeSnapshot : null,
|
|
IsExpertActive() ? _expertWorkflow?.Current : null,
|
|
IsTradingHaltActive() ? _tradingHaltWorkflow?.Current : null,
|
|
IsComparisonActive() ? _comparisonWorkflow?.Current : null,
|
|
_manualFinancialSnapshot,
|
|
_namedPlaylistWorkflow?.Current,
|
|
IsOverseasActive() ? _overseasWorkflow?.Current : null,
|
|
_manualListsWorkflow?.Current,
|
|
operatorCatalog?.IsOpen == true ? operatorCatalog : null,
|
|
_commandResult,
|
|
_pendingFixedSectionBatch?.CreateView());
|
|
}
|
|
|
|
private LegacyFixedCatalogView? CreateFixedCatalogView()
|
|
{
|
|
var market = GetActiveFixedMarket();
|
|
if (market is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var sections = _fixedCatalog.GetSections(market.Value)
|
|
.Select((section, index) => new LegacyFixedSectionView(
|
|
index,
|
|
section.Name,
|
|
Array.AsReadOnly(section.Actions
|
|
.Select(action => new LegacyFixedActionView(
|
|
action.Id,
|
|
action.Label,
|
|
action.Detail,
|
|
action.Available,
|
|
action.Prerequisite))
|
|
.ToArray())))
|
|
.ToArray();
|
|
return new LegacyFixedCatalogView(market.Value, Array.AsReadOnly(sections));
|
|
}
|
|
|
|
private LegacyFixedMarket? GetActiveFixedMarket() => _activeTab switch
|
|
{
|
|
LegacyOperatorTabId.Overseas => LegacyFixedMarket.Overseas,
|
|
LegacyOperatorTabId.Exchange => LegacyFixedMarket.Exchange,
|
|
LegacyOperatorTabId.Index => LegacyFixedMarket.Index,
|
|
_ => null
|
|
};
|
|
|
|
private MMoneyCoderSharp.Data.IndustryMarket? GetActiveIndustryMarket() => _activeTab switch
|
|
{
|
|
LegacyOperatorTabId.KospiIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
|
|
LegacyOperatorTabId.KosdaqIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
|
|
_ => null
|
|
};
|
|
|
|
private LegacyIndustrySelectionSnapshot? GetActiveIndustrySnapshot() =>
|
|
GetActiveIndustryMarket() is { } market &&
|
|
_industryWorkflow?.Current.Market == market
|
|
? _industryWorkflow.Current
|
|
: null;
|
|
|
|
private bool IsActiveIndustryReady() => GetActiveIndustrySnapshot() is not null;
|
|
|
|
private bool IsThemeActive() => _activeTab == LegacyOperatorTabId.Theme;
|
|
|
|
private MMoneyCoderSharp.Data.ThemeSession CurrentThemeNxtSession() =>
|
|
_timeProvider.GetLocalNow().Hour <= 12
|
|
? MMoneyCoderSharp.Data.ThemeSession.PreMarket
|
|
: MMoneyCoderSharp.Data.ThemeSession.AfterMarket;
|
|
|
|
private void BeginTabActivation(LegacyOperatorTabId tabId)
|
|
{
|
|
if (tabId == LegacyOperatorTabId.KospiIndustry)
|
|
{
|
|
_industryActivationListLoaded = false;
|
|
_industryWorkflow?.ResetForTabActivation(
|
|
MMoneyCoderSharp.Data.IndustryMarket.Kospi);
|
|
}
|
|
else if (tabId == LegacyOperatorTabId.KosdaqIndustry)
|
|
{
|
|
_industryActivationListLoaded = false;
|
|
_industryWorkflow?.ResetForTabActivation(
|
|
MMoneyCoderSharp.Data.IndustryMarket.Kosdaq);
|
|
}
|
|
else if (tabId == LegacyOperatorTabId.Comparison)
|
|
{
|
|
_comparisonActivationListLoaded = false;
|
|
_comparisonWorkflow?.ResetForTabActivation();
|
|
}
|
|
else if (tabId == LegacyOperatorTabId.Theme)
|
|
{
|
|
_themeActivationListLoaded = false;
|
|
if (_themeWorkflow is not null && _themeSnapshot is not null)
|
|
{
|
|
_themeSnapshot = _themeWorkflow.ResetForTabActivation(
|
|
_themeSnapshot,
|
|
CurrentThemeNxtSession());
|
|
}
|
|
}
|
|
else if (tabId == LegacyOperatorTabId.OverseasStocks)
|
|
{
|
|
_overseasWorkflow?.ResetForTabActivation();
|
|
}
|
|
else if (tabId == LegacyOperatorTabId.Expert)
|
|
{
|
|
_expertActivationListLoaded = false;
|
|
}
|
|
else if (tabId == LegacyOperatorTabId.TradingHalt)
|
|
{
|
|
_tradingHaltWorkflow?.ResetForTabActivation();
|
|
}
|
|
}
|
|
|
|
private bool IsCurrentTabActivation(
|
|
LegacyOperatorTabId tabId,
|
|
long activationRequest) =>
|
|
_activeTab == tabId &&
|
|
Volatile.Read(ref _tabActivationRequest) == activationRequest;
|
|
|
|
private bool IsComparisonActive() => _activeTab == LegacyOperatorTabId.Comparison;
|
|
|
|
private bool IsExpertActive() => _activeTab == LegacyOperatorTabId.Expert;
|
|
|
|
private bool IsTradingHaltActive() => _activeTab == LegacyOperatorTabId.TradingHalt;
|
|
|
|
private bool IsOverseasActive() => _activeTab == LegacyOperatorTabId.OverseasStocks;
|
|
|
|
private bool TryGetManualFinancial(
|
|
out LegacyManualFinancialWorkflow workflow,
|
|
out LegacyManualFinancialWorkflowSnapshot snapshot)
|
|
{
|
|
workflow = _manualFinancialWorkflow!;
|
|
snapshot = _manualFinancialSnapshot!;
|
|
return workflow is not null && snapshot is not null;
|
|
}
|
|
|
|
private void ApplyManualFinancialResultStatus(
|
|
LegacyManualFinancialWorkflowSnapshot snapshot,
|
|
string operation)
|
|
{
|
|
_statusMessage = snapshot.Status switch
|
|
{
|
|
LegacyManualFinancialWorkflowStatus.WriteCommitted =>
|
|
$"수동 재무 {operation} 작업이 완료되었습니다.",
|
|
LegacyManualFinancialWorkflowStatus.WriteRejected =>
|
|
snapshot.LastMessage ?? $"수동 재무 {operation} 작업이 거부되었습니다.",
|
|
LegacyManualFinancialWorkflowStatus.ReadBackRequired =>
|
|
snapshot.LastMessage ?? "저장은 완료됐지만 새로 읽기가 필요합니다.",
|
|
LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
|
|
snapshot.LastMessage ?? "결과가 불명확하여 수동 재무 쓰기를 차단했습니다.",
|
|
_ => snapshot.LastMessage ?? string.Empty
|
|
};
|
|
_statusKind = snapshot.Status switch
|
|
{
|
|
LegacyManualFinancialWorkflowStatus.WriteCommitted =>
|
|
LegacyOperatorStatusKind.Information,
|
|
LegacyManualFinancialWorkflowStatus.WriteRejected =>
|
|
LegacyOperatorStatusKind.Warning,
|
|
LegacyManualFinancialWorkflowStatus.ReadBackRequired or
|
|
LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
|
|
LegacyOperatorStatusKind.Error,
|
|
_ => LegacyOperatorStatusKind.Neutral
|
|
};
|
|
}
|
|
|
|
private void ApplyManualListsStatus()
|
|
{
|
|
if (_manualListsWorkflow is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_statusMessage = _manualListsWorkflow.Current.StatusMessage;
|
|
_statusKind = _manualListsWorkflow.Current.StatusKind;
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> OpenPendingFixedSectionManualAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var pending = _pendingFixedSectionBatch
|
|
?? throw new InvalidOperationException("A fixed-section batch is not active.");
|
|
var item = pending.CurrentManualItem
|
|
?? throw new InvalidOperationException("The fixed-section batch has no pending manual item.");
|
|
var target = item.ManualTarget!.Value;
|
|
|
|
if (target.Screen == LegacyManualListScreen.NetSell)
|
|
{
|
|
await OpenManualNetSellCoreAsync(target.Audience, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var manual = _manualListsWorkflow!.Current;
|
|
var view = pending.CreateView();
|
|
var progress =
|
|
$"{pending.SectionName} 섹션 수동 입력 {view.ManualStepNumber}/{view.ManualStepCount}: {item.Action.Label}. 확인하면 다음 직계 자식으로 계속합니다.";
|
|
_statusMessage = string.IsNullOrWhiteSpace(manual.StatusMessage)
|
|
? progress
|
|
: $"{progress} {manual.StatusMessage}";
|
|
_statusKind = manual.StatusKind == LegacyOperatorStatusKind.Neutral
|
|
? LegacyOperatorStatusKind.Information
|
|
: manual.StatusKind;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
private async Task<LegacyOperatorSnapshot> ContinuePendingFixedSectionAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var pending = _pendingFixedSectionBatch
|
|
?? throw new InvalidOperationException("A fixed-section batch is not active.");
|
|
if (pending.CurrentManualItem is not null)
|
|
{
|
|
return await OpenPendingFixedSectionManualAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
try
|
|
{
|
|
CommitFixedSectionBatch(pending.Items);
|
|
}
|
|
catch
|
|
{
|
|
_pendingFixedSectionBatch = null;
|
|
throw;
|
|
}
|
|
|
|
_pendingFixedSectionBatch = null;
|
|
_statusMessage =
|
|
$"{pending.SectionName} 항목 {pending.Items.Count}개를 원본 순서로 추가했습니다.";
|
|
_statusKind = LegacyOperatorStatusKind.Information;
|
|
Touch();
|
|
return CreateSnapshot();
|
|
}
|
|
|
|
private void CommitFixedSectionBatch(IReadOnlyList<PendingFixedSectionItem> items)
|
|
{
|
|
var originalCount = _playlist.Count;
|
|
var originalSequence = _playlistSequence;
|
|
var originalActiveRowId = _activePlaylistRowId;
|
|
var originalSelectedRowIds = _selectedPlaylistRowIds.ToArray();
|
|
var originalCursorIndex = _playlistCursorIndex;
|
|
var originalPriorCursorIndex = _playlistPriorCursorIndex;
|
|
try
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
if (item.Materialized is not null)
|
|
{
|
|
AppendFixedRow(item.Materialized);
|
|
}
|
|
else if (item.ManualDraft is not null)
|
|
{
|
|
AppendManualListRow(item.ManualDraft);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A fixed-section batch item was not materialized.");
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
if (_playlist.Count > originalCount)
|
|
{
|
|
_playlist.RemoveRange(originalCount, _playlist.Count - originalCount);
|
|
}
|
|
|
|
_playlistSequence = originalSequence;
|
|
_activePlaylistRowId = originalActiveRowId;
|
|
_selectedPlaylistRowIds.Clear();
|
|
_selectedPlaylistRowIds.UnionWith(originalSelectedRowIds);
|
|
_playlistCursorIndex = originalCursorIndex;
|
|
_playlistPriorCursorIndex = originalPriorCursorIndex;
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static FixedManualTarget? GetFixedManualTarget(string actionId) => actionId switch
|
|
{
|
|
"fixed-245" => new FixedManualTarget(
|
|
LegacyManualListScreen.NetSell,
|
|
S5025ManualAudience.Individual),
|
|
"fixed-246" => new FixedManualTarget(
|
|
LegacyManualListScreen.NetSell,
|
|
S5025ManualAudience.Foreign),
|
|
"fixed-247" => new FixedManualTarget(
|
|
LegacyManualListScreen.NetSell,
|
|
S5025ManualAudience.Institution),
|
|
"fixed-262" or "fixed-293" or "fixed-320" => new FixedManualTarget(
|
|
LegacyManualListScreen.Vi,
|
|
S5025ManualAudience.Individual),
|
|
_ => null
|
|
};
|
|
|
|
private void ApplyOperatorCatalogStatus()
|
|
{
|
|
if (_operatorCatalogWorkflow is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var snapshot = _operatorCatalogWorkflow.Current;
|
|
_statusMessage = snapshot.StatusMessage;
|
|
_statusKind = snapshot.StatusKind switch
|
|
{
|
|
LegacyOperatorCatalogStatusKind.Information => LegacyOperatorStatusKind.Information,
|
|
LegacyOperatorCatalogStatusKind.Warning => LegacyOperatorStatusKind.Warning,
|
|
LegacyOperatorCatalogStatusKind.Error => LegacyOperatorStatusKind.Error,
|
|
_ => LegacyOperatorStatusKind.Neutral
|
|
};
|
|
}
|
|
|
|
private void AppendManualListRow(LegacyManualListPlaylistDraft draft)
|
|
{
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-manual-list-{++_playlistSequence:D8}",
|
|
true,
|
|
draft.MarketText,
|
|
draft.StockName,
|
|
draft.GraphicType,
|
|
draft.Subtype,
|
|
draft.PageText,
|
|
draft.Selection.DataCode,
|
|
draft.RawCutLabel,
|
|
draft.FadeDuration,
|
|
SceneAlias: draft.SceneAlias,
|
|
PlayoutSelection: draft.Selection));
|
|
}
|
|
|
|
private void AppendFixedRow(LegacyMaterializedFixedAction materialized)
|
|
{
|
|
var action = materialized.Action;
|
|
var selection = materialized.Selection;
|
|
var marketText = action.Market switch
|
|
{
|
|
LegacyFixedMarket.Overseas => "해외지수",
|
|
LegacyFixedMarket.Exchange => "환율",
|
|
LegacyFixedMarket.Index => "주요지수",
|
|
_ => throw new ArgumentOutOfRangeException()
|
|
};
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-fixed-{++_playlistSequence:D8}",
|
|
true,
|
|
marketText,
|
|
action.Label,
|
|
action.Section,
|
|
action.Detail,
|
|
"1/1",
|
|
selection.DataCode,
|
|
action.Label,
|
|
6,
|
|
SceneAlias: action.Code,
|
|
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
|
selection.GroupCode,
|
|
selection.Subject,
|
|
selection.GraphicType,
|
|
selection.Subtype,
|
|
selection.DataCode)));
|
|
}
|
|
|
|
private void AppendIndustryRow(LegacyIndustryPlaylistDraft draft, int itemCount)
|
|
{
|
|
var split = draft.Detail.IndexOf('_');
|
|
var graphicType = split > 0 ? draft.Detail[..split] : draft.Detail;
|
|
var subtype = split > 0 && split < draft.Detail.Length - 1
|
|
? draft.Detail[(split + 1)..]
|
|
: string.Empty;
|
|
var pageCount = draft.PageSize > 0
|
|
? Math.Clamp(
|
|
(int)Math.Ceiling(itemCount / (double)draft.PageSize),
|
|
1,
|
|
20)
|
|
: 1;
|
|
var marketText = draft.Market == MMoneyCoderSharp.Data.IndustryMarket.Kospi
|
|
? "업종_코스피"
|
|
: "업종_코스닥";
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-industry-{++_playlistSequence:D8}",
|
|
draft.IsEnabled,
|
|
marketText,
|
|
draft.Title,
|
|
graphicType,
|
|
subtype,
|
|
$"1/{pageCount}",
|
|
draft.Selection.DataCode,
|
|
draft.Detail,
|
|
draft.FadeDuration,
|
|
SceneAlias: draft.SceneAlias,
|
|
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
|
draft.Selection.GroupCode,
|
|
draft.Selection.Subject,
|
|
draft.Selection.GraphicType,
|
|
draft.Selection.Subtype,
|
|
draft.Selection.DataCode)));
|
|
}
|
|
|
|
private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft)
|
|
{
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-overseas-{++_playlistSequence:D8}",
|
|
draft.IsEnabled,
|
|
draft.VisibleFields.Market,
|
|
draft.VisibleFields.StockName,
|
|
draft.VisibleFields.GraphicType,
|
|
draft.VisibleFields.Subtype,
|
|
draft.VisibleFields.PageText,
|
|
draft.Selection.DataCode,
|
|
draft.Detail,
|
|
draft.FadeDuration,
|
|
SceneAlias: draft.SceneAlias,
|
|
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
|
draft.Selection.GroupCode,
|
|
draft.Selection.Subject,
|
|
draft.Selection.GraphicType,
|
|
draft.Selection.Subtype,
|
|
draft.Selection.DataCode)));
|
|
}
|
|
|
|
private void AppendThemeRow(LegacyThemePlaylistDraft draft)
|
|
{
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-theme-{++_playlistSequence:D8}",
|
|
true,
|
|
draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT",
|
|
draft.Title,
|
|
draft.PlaylistGraphicType,
|
|
draft.PlaylistSubtype,
|
|
draft.PagePlan.InitialPageText,
|
|
draft.Selection.DataCode,
|
|
draft.Detail,
|
|
6,
|
|
SceneAlias: draft.Code,
|
|
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
|
draft.Selection.GroupCode,
|
|
draft.Selection.Subject,
|
|
draft.Selection.GraphicType,
|
|
draft.Selection.Subtype,
|
|
draft.Selection.DataCode)));
|
|
}
|
|
|
|
private void AppendComparisonRow(LegacyGenericPlaylistDraft draft)
|
|
{
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-comparison-{++_playlistSequence:D8}",
|
|
draft.IsEnabled,
|
|
"비교",
|
|
draft.Title,
|
|
draft.BuilderKey,
|
|
draft.Detail,
|
|
"1/1",
|
|
draft.Selection.DataCode,
|
|
draft.Source,
|
|
draft.FadeDuration,
|
|
SceneAlias: draft.CutCode,
|
|
PlayoutSelection: draft.Selection));
|
|
}
|
|
|
|
private void AppendExpertRow(LegacyExpertPlaylistDraft draft)
|
|
{
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-expert-{++_playlistSequence:D8}",
|
|
draft.IsEnabled,
|
|
"전문가",
|
|
draft.Title,
|
|
"5단 표그래프",
|
|
"현재가",
|
|
draft.PageText,
|
|
draft.ExpertCode,
|
|
draft.Detail,
|
|
draft.FadeDuration,
|
|
SceneAlias: draft.CutCode,
|
|
PlayoutSelection: draft.Selection));
|
|
}
|
|
|
|
private void AppendTradingHaltRow(LegacyTradingHaltPlaylistDraft draft)
|
|
{
|
|
var isCandle = draft.CandlePeriodDays.HasValue;
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-halt-{++_playlistSequence:D8}",
|
|
draft.IsEnabled,
|
|
draft.Market == "kospi" ? "코스피" : "코스닥",
|
|
draft.Title,
|
|
isCandle ? "캔들그래프" : "1열판기본",
|
|
isCandle ? "거래정지_120일" : "현재가",
|
|
draft.PageText,
|
|
draft.StockCode,
|
|
draft.Detail,
|
|
draft.FadeDuration,
|
|
SceneAlias: draft.CutCode,
|
|
PlayoutSelection: draft.Selection));
|
|
}
|
|
|
|
private void AppendManualFinancialRow(LegacyManualFinancialPlaylistDraft draft)
|
|
{
|
|
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
|
$"legacy-manual-financial-{++_playlistSequence:D8}",
|
|
true,
|
|
draft.Market,
|
|
draft.Title,
|
|
draft.Detail,
|
|
draft.CutSelection.Selection.GraphicType,
|
|
"1/1",
|
|
draft.StockCode,
|
|
draft.Detail,
|
|
6,
|
|
SceneAlias: draft.Code,
|
|
PlayoutSelection: draft.CutSelection.Selection));
|
|
}
|
|
|
|
private static string GetTabLabel(LegacyOperatorTabId tabId) => tabId switch
|
|
{
|
|
LegacyOperatorTabId.Overseas => "해외",
|
|
LegacyOperatorTabId.Exchange => "환율",
|
|
LegacyOperatorTabId.Index => "지수",
|
|
LegacyOperatorTabId.KospiIndustry => "코스피",
|
|
LegacyOperatorTabId.KosdaqIndustry => "코스닥",
|
|
LegacyOperatorTabId.Comparison => "비교",
|
|
LegacyOperatorTabId.Theme => "테마",
|
|
LegacyOperatorTabId.OverseasStocks => "해외종목",
|
|
LegacyOperatorTabId.Expert => "전문가",
|
|
LegacyOperatorTabId.TradingHalt => "정지",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null)
|
|
};
|
|
|
|
private bool IsCutIndex(int physicalIndex) =>
|
|
physicalIndex >= 0 && physicalIndex < _cutRows.Count;
|
|
|
|
private static IReadOnlyList<LegacyCutCatalogRow> CreateValidatedCutRows(
|
|
IReadOnlyList<LegacyCutCatalogRow>? cutRows)
|
|
{
|
|
var source = cutRows ?? LegacyStockCutCatalog.Rows;
|
|
if (source.Count is < 1 or > LegacyRuntimeStockCutMenuLoader.MaximumRows)
|
|
{
|
|
throw new ArgumentException(
|
|
"The stock cut menu must contain from 1 through 50 physical rows.",
|
|
nameof(cutRows));
|
|
}
|
|
|
|
var copiedRows = new LegacyCutCatalogRow[source.Count];
|
|
var displayOrdinal = 0;
|
|
for (var index = 0; index < source.Count; index++)
|
|
{
|
|
var row = source[index] ?? throw new ArgumentException(
|
|
"The stock cut menu cannot contain a null row.",
|
|
nameof(cutRows));
|
|
if (row.PhysicalIndex != index)
|
|
{
|
|
throw new ArgumentException(
|
|
"Stock cut physical indices must be contiguous and zero based.",
|
|
nameof(cutRows));
|
|
}
|
|
|
|
if (row.IsSeparator)
|
|
{
|
|
if (row.DisplayOrdinal is not null ||
|
|
!string.IsNullOrEmpty(row.RawLabel))
|
|
{
|
|
throw new ArgumentException(
|
|
"A stock cut separator must have no display ordinal or label.",
|
|
nameof(cutRows));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
displayOrdinal++;
|
|
if (row.DisplayOrdinal != displayOrdinal)
|
|
{
|
|
throw new ArgumentException(
|
|
"Stock cut display ordinals must be contiguous across active rows.",
|
|
nameof(cutRows));
|
|
}
|
|
|
|
var isKnown = LegacyStockCutCatalog.Rows.Any(candidate =>
|
|
!candidate.IsSeparator && string.Equals(
|
|
candidate.RawLabel,
|
|
row.RawLabel,
|
|
StringComparison.Ordinal));
|
|
if (!isKnown)
|
|
{
|
|
throw new ArgumentException(
|
|
"The stock cut menu contains an unknown cut label.",
|
|
nameof(cutRows));
|
|
}
|
|
}
|
|
|
|
copiedRows[index] = row;
|
|
}
|
|
|
|
if (displayOrdinal == 0)
|
|
{
|
|
throw new ArgumentException(
|
|
"The stock cut menu must contain at least one active cut.",
|
|
nameof(cutRows));
|
|
}
|
|
|
|
return Array.AsReadOnly(copiedRows);
|
|
}
|
|
|
|
private int FindPlaylistIndex(string rowId) => _playlist.FindIndex(row =>
|
|
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
|
|
|
|
private static int FindOriginalPlaylistIndex(
|
|
IReadOnlyList<LegacyOperatorPlaylistRow> rows,
|
|
string? rowId)
|
|
{
|
|
if (rowId is null)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
for (var index = 0; index < rows.Count; index++)
|
|
{
|
|
if (string.Equals(rows[index].RowId, rowId, StringComparison.Ordinal))
|
|
{
|
|
return index;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
private void AppendPlaylistRow(LegacyOperatorPlaylistRow row)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(row);
|
|
row = NormalizeMovingAverageIdentity(row);
|
|
var wasEmpty = _playlist.Count == 0;
|
|
_playlist.Add(row);
|
|
if (wasEmpty)
|
|
{
|
|
SelectInitialPlaylistCandidate();
|
|
}
|
|
}
|
|
|
|
private static LegacyOperatorPlaylistRow NormalizeMovingAverageIdentity(
|
|
LegacyOperatorPlaylistRow row)
|
|
{
|
|
var entry = row.ToPlayoutEntry();
|
|
if (!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
|
|
.Supports(entry.CutCode))
|
|
{
|
|
return row;
|
|
}
|
|
|
|
var graphicType = RemoveCapturedMovingAverageFlags(row.GraphicType);
|
|
var selection = row.PlayoutSelection;
|
|
var normalizedSelection = selection is null
|
|
? null
|
|
: selection with
|
|
{
|
|
GraphicType = RemoveCapturedMovingAverageFlags(selection.GraphicType)
|
|
};
|
|
return string.Equals(graphicType, row.GraphicType, StringComparison.Ordinal) &&
|
|
Equals(normalizedSelection, selection)
|
|
? row
|
|
: row with
|
|
{
|
|
GraphicType = graphicType,
|
|
PlayoutSelection = normalizedSelection
|
|
};
|
|
}
|
|
|
|
private static string RemoveCapturedMovingAverageFlags(string graphicType) =>
|
|
graphicType is "MA5" or "MA20" or "MA5,MA20"
|
|
? string.Empty
|
|
: graphicType;
|
|
|
|
private void SelectInitialPlaylistCandidate()
|
|
{
|
|
if (_playlist.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_activePlaylistRowId = _playlist[0].RowId;
|
|
// MainForm corrected m_rowidx to zero for the first F8 candidate, but it
|
|
// did not populate bufdel until an actual playlist row click occurred.
|
|
_selectedPlaylistRowIds.Clear();
|
|
_playlistCursorIndex = 0;
|
|
_playlistPriorCursorIndex = 0;
|
|
}
|
|
|
|
private string ResolveSurvivingPlaylistRowId(
|
|
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
|
|
ISet<string> removedIds,
|
|
string? preferredRowId,
|
|
int preferredIndex)
|
|
{
|
|
if (preferredRowId is not null && FindPlaylistIndex(preferredRowId) >= 0)
|
|
{
|
|
return preferredRowId;
|
|
}
|
|
|
|
var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
|
|
originalRows,
|
|
removedIds,
|
|
preferredIndex);
|
|
return originalRows[resolvedOriginalIndex].RowId;
|
|
}
|
|
|
|
private int ResolveSurvivingPlaylistIndex(
|
|
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
|
|
ISet<string> removedIds,
|
|
string preferredRowId,
|
|
int preferredIndex)
|
|
{
|
|
var survivingIndex = FindPlaylistIndex(preferredRowId);
|
|
if (survivingIndex >= 0)
|
|
{
|
|
return survivingIndex;
|
|
}
|
|
|
|
var resolvedOriginalIndex = FindNextThenPreviousSurvivor(
|
|
originalRows,
|
|
removedIds,
|
|
preferredIndex);
|
|
return FindPlaylistIndex(originalRows[resolvedOriginalIndex].RowId);
|
|
}
|
|
|
|
private static int FindNextThenPreviousSurvivor(
|
|
IReadOnlyList<LegacyOperatorPlaylistRow> originalRows,
|
|
ISet<string> removedIds,
|
|
int preferredIndex)
|
|
{
|
|
if (preferredIndex < 0)
|
|
{
|
|
for (var index = 0; index < originalRows.Count; index++)
|
|
{
|
|
if (!removedIds.Contains(originalRows[index].RowId))
|
|
{
|
|
return index;
|
|
}
|
|
}
|
|
}
|
|
|
|
var start = Math.Clamp(preferredIndex, 0, originalRows.Count - 1);
|
|
for (var index = start + 1; index < originalRows.Count; index++)
|
|
{
|
|
if (!removedIds.Contains(originalRows[index].RowId))
|
|
{
|
|
return index;
|
|
}
|
|
}
|
|
|
|
for (var index = start - 1; index >= 0; index--)
|
|
{
|
|
if (!removedIds.Contains(originalRows[index].RowId))
|
|
{
|
|
return index;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException("삭제 후 남은 플레이리스트 행을 찾지 못했습니다.");
|
|
}
|
|
|
|
private void AddPlaylistRange(int firstIndex, int lastIndex)
|
|
{
|
|
var first = Math.Max(0, Math.Min(firstIndex, lastIndex));
|
|
var last = Math.Min(_playlist.Count - 1, Math.Max(firstIndex, lastIndex));
|
|
for (var index = first; index <= last; index++)
|
|
{
|
|
_selectedPlaylistRowIds.Add(_playlist[index].RowId);
|
|
}
|
|
}
|
|
|
|
private void ResetPlaylistSelection()
|
|
{
|
|
_selectedPlaylistRowIds.Clear();
|
|
_activePlaylistRowId = null;
|
|
_playlistCursorIndex = 0;
|
|
_playlistPriorCursorIndex = 0;
|
|
}
|
|
|
|
private void CompleteCommand(string command, string targetId, bool succeeded)
|
|
{
|
|
_commandResult = new LegacyOperatorCommandResult(
|
|
checked(++_commandSequence),
|
|
command,
|
|
targetId,
|
|
succeeded);
|
|
}
|
|
|
|
private readonly record struct FixedManualTarget(
|
|
LegacyManualListScreen Screen,
|
|
S5025ManualAudience Audience);
|
|
|
|
private sealed class PendingFixedSectionItem
|
|
{
|
|
internal PendingFixedSectionItem(
|
|
LegacyFixedAction action,
|
|
LegacyMaterializedFixedAction materialized)
|
|
{
|
|
Action = action;
|
|
Materialized = materialized;
|
|
}
|
|
|
|
internal PendingFixedSectionItem(
|
|
LegacyFixedAction action,
|
|
FixedManualTarget manualTarget)
|
|
{
|
|
Action = action;
|
|
ManualTarget = manualTarget;
|
|
}
|
|
|
|
internal LegacyFixedAction Action { get; }
|
|
|
|
internal LegacyMaterializedFixedAction? Materialized { get; }
|
|
|
|
internal FixedManualTarget? ManualTarget { get; }
|
|
|
|
internal LegacyManualListPlaylistDraft? ManualDraft { get; private set; }
|
|
|
|
internal bool IsStaged => Materialized is not null || ManualDraft is not null;
|
|
|
|
internal void StageManual(LegacyManualListPlaylistDraft draft)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(draft);
|
|
if (ManualTarget is null || ManualDraft is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The fixed-section manual item cannot be staged in its current state.");
|
|
}
|
|
|
|
ManualDraft = draft;
|
|
}
|
|
}
|
|
|
|
private sealed class PendingFixedSectionBatch
|
|
{
|
|
internal PendingFixedSectionBatch(
|
|
LegacyFixedMarket market,
|
|
int sectionIndex,
|
|
string sectionName,
|
|
IReadOnlyList<PendingFixedSectionItem> items,
|
|
int manualCount)
|
|
{
|
|
Market = market;
|
|
SectionIndex = sectionIndex;
|
|
SectionName = sectionName;
|
|
Items = items;
|
|
ManualCount = manualCount;
|
|
}
|
|
|
|
internal LegacyFixedMarket Market { get; }
|
|
|
|
internal int SectionIndex { get; }
|
|
|
|
internal string SectionName { get; }
|
|
|
|
internal IReadOnlyList<PendingFixedSectionItem> Items { get; }
|
|
|
|
internal int ManualCount { get; }
|
|
|
|
internal PendingFixedSectionItem? CurrentManualItem => Items.FirstOrDefault(item =>
|
|
item.ManualTarget is not null && item.ManualDraft is null);
|
|
|
|
internal bool IsCurrentManual(
|
|
LegacyManualListScreen screen,
|
|
S5025ManualAudience audience)
|
|
{
|
|
var target = CurrentManualItem?.ManualTarget;
|
|
return target is not null && target.Value.Screen == screen &&
|
|
(screen == LegacyManualListScreen.Vi || target.Value.Audience == audience);
|
|
}
|
|
|
|
internal void StageCurrentManual(LegacyManualListPlaylistDraft draft)
|
|
{
|
|
var current = CurrentManualItem
|
|
?? throw new InvalidOperationException(
|
|
"The fixed-section batch has no pending manual item.");
|
|
current.StageManual(draft);
|
|
}
|
|
|
|
internal LegacyFixedSectionBatchView CreateView()
|
|
{
|
|
var current = CurrentManualItem
|
|
?? throw new InvalidOperationException(
|
|
"A completed fixed-section batch cannot be projected as pending.");
|
|
var manualStep = Items
|
|
.Where(item => item.ManualTarget is not null)
|
|
.TakeWhile(item => !ReferenceEquals(item, current))
|
|
.Count() + 1;
|
|
return new LegacyFixedSectionBatchView(
|
|
SectionIndex,
|
|
SectionName,
|
|
Items.Count,
|
|
Items.Count(item => item.IsStaged),
|
|
manualStep,
|
|
ManualCount,
|
|
current.Action.Id,
|
|
current.Action.Label);
|
|
}
|
|
}
|
|
|
|
private enum PendingNativeConfirmationKind
|
|
{
|
|
DeleteTheme,
|
|
DeleteExpert,
|
|
ClearThemeDraft,
|
|
DeleteAllComparisonPairs
|
|
}
|
|
|
|
private sealed record PendingNativeConfirmation(
|
|
PendingNativeConfirmationKind Kind,
|
|
MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity,
|
|
MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity,
|
|
long CatalogRevision,
|
|
long ComparisonRevision)
|
|
{
|
|
internal static PendingNativeConfirmation ForTheme(
|
|
MMoneyCoderSharp.Data.ThemeSelectionIdentity identity) =>
|
|
new(
|
|
PendingNativeConfirmationKind.DeleteTheme,
|
|
identity,
|
|
null,
|
|
0,
|
|
0);
|
|
|
|
internal static PendingNativeConfirmation ForExpert(
|
|
MMoneyCoderSharp.Data.ExpertSelectionIdentity identity) =>
|
|
new(
|
|
PendingNativeConfirmationKind.DeleteExpert,
|
|
null,
|
|
identity,
|
|
0,
|
|
0);
|
|
|
|
internal static PendingNativeConfirmation ForThemeDraftClear(long revision) =>
|
|
new(
|
|
PendingNativeConfirmationKind.ClearThemeDraft,
|
|
null,
|
|
null,
|
|
revision,
|
|
0);
|
|
|
|
internal static PendingNativeConfirmation ForComparisonDeleteAll(long revision) =>
|
|
new(
|
|
PendingNativeConfirmationKind.DeleteAllComparisonPairs,
|
|
null,
|
|
null,
|
|
0,
|
|
revision);
|
|
}
|
|
|
|
private void Touch() => _revision++;
|
|
}
|