feat: advance legacy UI behavior parity
This commit is contained in:
@@ -333,17 +333,6 @@ public sealed class LegacyComparisonWorkflowController
|
||||
throw new InvalidOperationException("The saved comparison pair limit was reached.");
|
||||
}
|
||||
|
||||
var signature = LegacyComparisonWorkflowCatalog.PairSignature(
|
||||
_firstTarget,
|
||||
_secondTarget);
|
||||
if (_savedPairs.Any(pair => string.Equals(
|
||||
LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second),
|
||||
signature,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
throw new InvalidOperationException("The exact comparison pair is already saved.");
|
||||
}
|
||||
|
||||
var rowId = $"comparison-pair-{++_pairSequence:D8}";
|
||||
var candidate = new List<SavedPairState>(_savedPairs)
|
||||
{
|
||||
|
||||
@@ -132,6 +132,27 @@ public sealed class LegacyExpertWorkflowController
|
||||
}
|
||||
}
|
||||
|
||||
public LegacyExpertWorkflowSnapshot RemoveSelectedResultPreservingPreview()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_selected is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var removed = _searchResults.RemoveAll(row => row.Identity == _selected.Identity);
|
||||
if (removed > 0)
|
||||
{
|
||||
// UC6 removed the visible expert row but intentionally retained m_code,
|
||||
// m_name and the recommendation grid until the operator selected again.
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyExpertWorkflowSnapshot> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -304,14 +325,14 @@ public sealed class LegacyExpertWorkflowController
|
||||
}
|
||||
|
||||
var itemCount = _previewItems.Count;
|
||||
if (itemCount is <= 0 or > MaximumAccessibleItems)
|
||||
if (itemCount > MaximumAccessibleItems)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"An expert cut requires one through 100 accessible recommendation rows.");
|
||||
"An expert cut supports zero through 100 accessible recommendation rows.");
|
||||
}
|
||||
|
||||
var pageCount = checked((itemCount + PageSize - 1) / PageSize);
|
||||
if (pageCount is <= 0 or > MaximumPageCount)
|
||||
if (pageCount > MaximumPageCount)
|
||||
{
|
||||
throw new InvalidOperationException("The expert PageN plan exceeds 20 pages.");
|
||||
}
|
||||
@@ -361,7 +382,7 @@ public sealed class LegacyExpertWorkflowController
|
||||
: (previewRows.Length + PageSize - 1) / PageSize;
|
||||
var actionAvailable = _selected is not null &&
|
||||
_previewStatus == LegacyExpertRequestStatus.Ready &&
|
||||
previewRows.Length is > 0 and <= MaximumAccessibleItems &&
|
||||
previewRows.Length <= MaximumAccessibleItems &&
|
||||
pageCount <= MaximumPageCount;
|
||||
return new LegacyExpertWorkflowSnapshot(
|
||||
_revision,
|
||||
@@ -405,7 +426,6 @@ public sealed class LegacyExpertWorkflowController
|
||||
|
||||
var states = new List<ExpertState>(result.Items.Count);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
ExpertSelectionIdentity? previous = null;
|
||||
for (var index = 0; index < result.Items.Count; index++)
|
||||
{
|
||||
@@ -417,11 +437,11 @@ public sealed class LegacyExpertWorkflowController
|
||||
}
|
||||
|
||||
ValidateExpertIdentity(item.Identity);
|
||||
if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) ||
|
||||
if (!codes.Add(item.Identity.ExpertCode) ||
|
||||
previous is not null && CompareIdentities(previous, item.Identity) > 0)
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"Expert identities must be unique and sorted by name then code.");
|
||||
"Expert codes must be unique and identities sorted by name then code.");
|
||||
}
|
||||
|
||||
states.Add(new ExpertState(
|
||||
@@ -448,8 +468,6 @@ public sealed class LegacyExpertWorkflowController
|
||||
|
||||
var items = new List<ExpertRecommendationPreview>(result.Items.Count);
|
||||
var indexes = new HashSet<int>();
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var previousIndex = -1;
|
||||
foreach (var item in result.Items)
|
||||
{
|
||||
@@ -459,9 +477,7 @@ public sealed class LegacyExpertWorkflowController
|
||||
!IsCanonicalText(item.StockName, 200) ||
|
||||
item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m ||
|
||||
decimal.Truncate(item.BuyAmount) != item.BuyAmount ||
|
||||
!indexes.Add(item.PlayIndex) ||
|
||||
!codes.Add(item.StockCode) ||
|
||||
!names.Add(item.StockName))
|
||||
!indexes.Add(item.PlayIndex))
|
||||
{
|
||||
throw new ExpertSelectionDataException(
|
||||
"The expert preview contains an invalid or duplicate recommendation.");
|
||||
|
||||
@@ -3,6 +3,12 @@ using MMoneyCoderSharp.Data;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
public enum LegacyIndustryComparisonSlot
|
||||
{
|
||||
First,
|
||||
Second
|
||||
}
|
||||
|
||||
public sealed record LegacyIndustrySelectionSnapshot(
|
||||
long Revision,
|
||||
IndustryMarket? Market,
|
||||
@@ -11,7 +17,9 @@ public sealed record LegacyIndustrySelectionSnapshot(
|
||||
int? SelectedIndex,
|
||||
IndustrySelection? SelectedIndustry,
|
||||
IndustrySelection? FirstIndustry,
|
||||
IndustrySelection? SecondIndustry);
|
||||
IndustrySelection? SecondIndustry,
|
||||
string FirstText,
|
||||
string SecondText);
|
||||
|
||||
public sealed record LegacyIndustrySceneSelectionDraft(
|
||||
string GroupCode,
|
||||
@@ -52,6 +60,7 @@ public sealed class LegacyIndustryWorkflowException : Exception
|
||||
public sealed class LegacyIndustrySelectionWorkflow
|
||||
{
|
||||
private const int DefaultFadeDuration = 6;
|
||||
public const int MaximumComparisonTextLength = 128;
|
||||
|
||||
private static readonly IReadOnlyList<IndustrySelection> NoIndustries =
|
||||
Array.AsReadOnly(Array.Empty<IndustrySelection>());
|
||||
@@ -68,6 +77,8 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
private IndustrySelection? _selectedIndustry;
|
||||
private IndustrySelection? _firstIndustry;
|
||||
private IndustrySelection? _secondIndustry;
|
||||
private string _firstText = string.Empty;
|
||||
private string _secondText = string.Empty;
|
||||
private long _revision;
|
||||
private long _loadGeneration;
|
||||
|
||||
@@ -98,6 +109,8 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
_selectedIndustry = null;
|
||||
_firstIndustry = null;
|
||||
_secondIndustry = null;
|
||||
_firstText = string.Empty;
|
||||
_secondText = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
@@ -128,6 +141,8 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
var previousSelected = changingMarket ? null : _selectedIndustry;
|
||||
var previousFirst = changingMarket ? null : _firstIndustry;
|
||||
var previousSecond = changingMarket ? null : _secondIndustry;
|
||||
var previousFirstText = changingMarket ? string.Empty : _firstText;
|
||||
var previousSecondText = changingMarket ? string.Empty : _secondText;
|
||||
|
||||
_market = market;
|
||||
_industries = normalizedRows;
|
||||
@@ -138,6 +153,8 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
: FindIdentityIndex(normalizedRows, _selectedIndustry);
|
||||
_firstIndustry = Reconcile(previousFirst, normalizedRows);
|
||||
_secondIndustry = Reconcile(previousSecond, normalizedRows);
|
||||
_firstText = previousFirstText;
|
||||
_secondText = previousSecondText;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
@@ -168,26 +185,71 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
// UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly
|
||||
// double-clicked item to txt1.
|
||||
_secondIndustry = _firstIndustry;
|
||||
_secondText = _firstText;
|
||||
_firstIndustry = industry;
|
||||
_firstText = industry.IndustryName;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SetComparisonText(
|
||||
LegacyIndustryComparisonSlot slot,
|
||||
string text)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
if (slot is not (LegacyIndustryComparisonSlot.First or LegacyIndustryComparisonSlot.Second))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(slot), slot, "The comparison slot must be 1 or 2.");
|
||||
}
|
||||
|
||||
if (text.Length > MaximumComparisonTextLength ||
|
||||
text.Any(static character =>
|
||||
char.IsControl(character) || char.IsSurrogate(character) || character == '\uFFFD'))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException("The industry comparison text is invalid.");
|
||||
}
|
||||
|
||||
if (slot == LegacyIndustryComparisonSlot.First)
|
||||
{
|
||||
if (string.Equals(_firstText, text, StringComparison.Ordinal))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_firstText = text;
|
||||
_firstIndustry = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.Equals(_secondText, text, StringComparison.Ordinal))
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_secondText = text;
|
||||
_secondIndustry = null;
|
||||
}
|
||||
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot SwapIndustries()
|
||||
{
|
||||
if (_firstIndustry is null && _secondIndustry is null)
|
||||
if (_firstText.Length == 0 && _secondText.Length == 0)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
(_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry);
|
||||
(_firstText, _secondText) = (_secondText, _firstText);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyIndustrySelectionSnapshot ClearSelections()
|
||||
{
|
||||
if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null)
|
||||
if (_selectedIndustry is null && _firstText.Length == 0 && _secondText.Length == 0)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
@@ -196,6 +258,8 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
_selectedIndustry = null;
|
||||
_firstIndustry = null;
|
||||
_secondIndustry = null;
|
||||
_firstText = string.Empty;
|
||||
_secondText = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
@@ -315,15 +379,17 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
LegacyIndustryActionDefinition definition,
|
||||
string groupCode)
|
||||
{
|
||||
if (_firstIndustry is not { } first || _secondIndustry is not { } second ||
|
||||
first.IndustryName.Contains('-', StringComparison.Ordinal) ||
|
||||
second.IndustryName.Contains('-', StringComparison.Ordinal))
|
||||
if (_selectedIndustry is null ||
|
||||
string.IsNullOrWhiteSpace(_firstText) ||
|
||||
string.IsNullOrWhiteSpace(_secondText) ||
|
||||
_firstText.Contains('-', StringComparison.Ordinal) ||
|
||||
_secondText.Contains('-', StringComparison.Ordinal))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
$"The {definition.ActionId} action requires two delimiter-safe industries.");
|
||||
$"The {definition.ActionId} action requires a selected industry and two delimiter-safe industries.");
|
||||
}
|
||||
|
||||
var subject = $"{first.IndustryName}-{second.IndustryName}";
|
||||
var subject = $"{_firstText}-{_secondText}";
|
||||
return new IndustryProjection(
|
||||
subject,
|
||||
new LegacyIndustrySceneSelectionDraft(
|
||||
@@ -361,8 +427,6 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
throw new LegacyIndustryWorkflowException("The industry list is invalid.");
|
||||
}
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
var codes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var normalized = new IndustrySelection[rows.Count];
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
@@ -380,12 +444,10 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
name.Any(char.IsControl) ||
|
||||
string.IsNullOrEmpty(code) ||
|
||||
code.Length > 32 ||
|
||||
code.Any(character => !char.IsAsciiLetterOrDigit(character)) ||
|
||||
!names.Add(name) ||
|
||||
!codes.Add(code))
|
||||
code.Any(character => !char.IsAsciiLetterOrDigit(character)))
|
||||
{
|
||||
throw new LegacyIndustryWorkflowException(
|
||||
"The industry list contains an unsafe or ambiguous row.");
|
||||
"The industry list contains an unsafe row.");
|
||||
}
|
||||
|
||||
normalized[index] = new IndustrySelection(market, name, code);
|
||||
@@ -452,7 +514,9 @@ public sealed class LegacyIndustrySelectionWorkflow
|
||||
_selectedIndex,
|
||||
_selectedIndustry,
|
||||
_firstIndustry,
|
||||
_secondIndustry);
|
||||
_secondIndustry,
|
||||
_firstText,
|
||||
_secondText);
|
||||
|
||||
private void Touch() => _revision++;
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@ public sealed record LegacyCutViewRow(
|
||||
bool IsSelected,
|
||||
bool IsFocused = false);
|
||||
|
||||
public sealed record LegacyDialogState(string Message);
|
||||
public sealed record LegacyDialogState(
|
||||
string Message,
|
||||
string Caption = "MmoneyCoder",
|
||||
bool IsConfirmation = false);
|
||||
|
||||
public sealed record LegacyOperatorCommandResult(
|
||||
long Sequence,
|
||||
@@ -196,6 +199,8 @@ public sealed class LegacyOperatorController
|
||||
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;
|
||||
@@ -231,7 +236,7 @@ public sealed class LegacyOperatorController
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_industryWorkflow = industryWorkflow;
|
||||
_themeWorkflow = themeWorkflow;
|
||||
_themeSnapshot = themeWorkflow?.CreateInitial();
|
||||
_themeSnapshot = themeWorkflow?.CreateInitial(CurrentThemeNxtSession());
|
||||
_expertWorkflow = expertWorkflow;
|
||||
_tradingHaltWorkflow = tradingHaltWorkflow;
|
||||
_comparisonWorkflow = comparisonWorkflow;
|
||||
@@ -640,7 +645,7 @@ public sealed class LegacyOperatorController
|
||||
var loaded = await _themeWorkflow.SearchAsync(
|
||||
_themeSnapshot,
|
||||
string.Empty,
|
||||
MMoneyCoderSharp.Data.ThemeSession.PreMarket,
|
||||
CurrentThemeNxtSession(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!IsCurrentTabActivation(tabId, activationRequest))
|
||||
{
|
||||
@@ -791,12 +796,6 @@ public sealed class LegacyOperatorController
|
||||
await _operatorCatalogWorkflow.OpenAsync(
|
||||
LegacyOperatorCatalogEntity.Theme,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (_operatorCatalogWorkflow.Current.CanBeginCreate)
|
||||
{
|
||||
await _operatorCatalogWorkflow.BeginCreateThemeAsync(
|
||||
MMoneyCoderSharp.Data.ThemeMarket.Krx,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ApplyOperatorCatalogStatus();
|
||||
Touch();
|
||||
@@ -824,18 +823,32 @@ public sealed class LegacyOperatorController
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyOperatorSnapshot> DeleteSelectedThemeCatalogAsync(
|
||||
public Task<LegacyOperatorSnapshot> DeleteSelectedThemeCatalogAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EditSelectedThemeCatalogAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_operatorCatalogWorkflow?.Current.CanDelete == true)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_operatorCatalogWorkflow is null)
|
||||
{
|
||||
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
ApplyOperatorCatalogStatus();
|
||||
Touch();
|
||||
return Task.FromResult(ReportError("ThemeA 관리 기능이 구성되지 않았습니다."));
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
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(
|
||||
@@ -884,18 +897,35 @@ public sealed class LegacyOperatorController
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyOperatorSnapshot> DeleteSelectedExpertCatalogAsync(
|
||||
public Task<LegacyOperatorSnapshot> DeleteSelectedExpertCatalogAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EditSelectedExpertCatalogAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_operatorCatalogWorkflow?.Current.CanDelete == true)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_operatorCatalogWorkflow is null)
|
||||
{
|
||||
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
ApplyOperatorCatalogStatus();
|
||||
Touch();
|
||||
return Task.FromResult(ReportError("EList 관리 기능이 구성되지 않았습니다."));
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
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()
|
||||
@@ -910,6 +940,43 @@ public sealed class LegacyOperatorController
|
||||
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,
|
||||
@@ -1058,7 +1125,7 @@ public sealed class LegacyOperatorController
|
||||
|
||||
public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount(
|
||||
string rowId,
|
||||
decimal buyAmount)
|
||||
decimal? buyAmount)
|
||||
{
|
||||
if (_operatorCatalogWorkflow is null)
|
||||
{
|
||||
@@ -1107,6 +1174,61 @@ public sealed class LegacyOperatorController
|
||||
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)
|
||||
@@ -1120,6 +1242,12 @@ public sealed class LegacyOperatorController
|
||||
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();
|
||||
}
|
||||
@@ -1133,23 +1261,95 @@ public sealed class LegacyOperatorController
|
||||
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();
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
var after = _operatorCatalogWorkflow.Current;
|
||||
|
||||
public async Task<LegacyOperatorSnapshot> DeleteOperatorCatalogAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_operatorCatalogWorkflow is null)
|
||||
if (before.Entity == LegacyOperatorCatalogEntity.Theme)
|
||||
{
|
||||
return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다.");
|
||||
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;
|
||||
}
|
||||
|
||||
await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
ApplyOperatorCatalogStatus();
|
||||
if (!refreshFailed)
|
||||
{
|
||||
_statusMessage = string.Empty;
|
||||
_statusKind = LegacyOperatorStatusKind.Neutral;
|
||||
}
|
||||
}
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
@@ -2085,6 +2285,32 @@ public sealed class LegacyOperatorController
|
||||
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);
|
||||
@@ -2117,46 +2343,13 @@ public sealed class LegacyOperatorController
|
||||
return ReportError("업종 목록을 먼저 불러오세요.");
|
||||
}
|
||||
|
||||
var snapshot = _industryWorkflow!.Current;
|
||||
var sections = snapshot.Actions
|
||||
.Select(static action => action.Section)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (sectionIndex < 0 || sectionIndex >= sections.Length)
|
||||
{
|
||||
return ReportError("등록되지 않은 업종 컷 섹션입니다.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var section = sections[sectionIndex];
|
||||
// Materialize every direct child before mutating the playlist. This keeps
|
||||
// the original parent double-click all-or-nothing when one child lacks a
|
||||
// required industry selection.
|
||||
var drafts = snapshot.Actions
|
||||
.Where(action => string.Equals(
|
||||
action.Section,
|
||||
section,
|
||||
StringComparison.Ordinal))
|
||||
.Select(action => _industryWorkflow.MaterializePlaylistDraft(action.ActionId))
|
||||
.ToArray();
|
||||
foreach (var draft in drafts)
|
||||
{
|
||||
AppendIndustryRow(draft, snapshot.Industries.Count);
|
||||
}
|
||||
|
||||
_statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다.";
|
||||
_statusKind = LegacyOperatorStatusKind.Information;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
catch (LegacyIndustryWorkflowException exception)
|
||||
{
|
||||
_dialog = new LegacyDialogState(
|
||||
$"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}");
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
// 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)
|
||||
@@ -2484,8 +2677,17 @@ public sealed class LegacyOperatorController
|
||||
|
||||
if (all)
|
||||
{
|
||||
await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (_dialog is not null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonDeleteAll(
|
||||
_comparisonWorkflow.Current.Revision);
|
||||
_dialog = new LegacyDialogState(
|
||||
"모두 삭제하겠습니까?",
|
||||
"MmoneyCoder",
|
||||
IsConfirmation: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2598,10 +2800,23 @@ public sealed class LegacyOperatorController
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LegacyOperatorSnapshot> SearchThemesAsync(
|
||||
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 = default)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null)
|
||||
@@ -3422,11 +3637,63 @@ public sealed class LegacyOperatorController
|
||||
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)
|
||||
if (_dialog is not null && !_dialog.IsConfirmation)
|
||||
{
|
||||
_dialog = null;
|
||||
if (_closeOperatorCatalogAfterDialog)
|
||||
{
|
||||
_operatorCatalogWorkflow?.Close();
|
||||
_closeOperatorCatalogAfterDialog = false;
|
||||
}
|
||||
ContinuePendingDrop();
|
||||
Touch();
|
||||
}
|
||||
@@ -3434,6 +3701,121 @@ public sealed class LegacyOperatorController
|
||||
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;
|
||||
@@ -4055,6 +4437,11 @@ public sealed class LegacyOperatorController
|
||||
|
||||
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)
|
||||
@@ -4077,6 +4464,12 @@ public sealed class LegacyOperatorController
|
||||
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)
|
||||
{
|
||||
@@ -4382,15 +4775,14 @@ public sealed class LegacyOperatorController
|
||||
|
||||
private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft)
|
||||
{
|
||||
var isCandle = string.Equals(draft.BuilderKey, "s8010", StringComparison.Ordinal);
|
||||
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
||||
$"legacy-overseas-{++_playlistSequence:D8}",
|
||||
draft.IsEnabled,
|
||||
draft.Selection.GroupCode,
|
||||
draft.Title,
|
||||
isCandle ? "캔들그래프" : draft.Selection.GraphicType,
|
||||
draft.Selection.Subtype,
|
||||
draft.InitialPageText,
|
||||
draft.VisibleFields.Market,
|
||||
draft.VisibleFields.StockName,
|
||||
draft.VisibleFields.GraphicType,
|
||||
draft.VisibleFields.Subtype,
|
||||
draft.VisibleFields.PageText,
|
||||
draft.Selection.DataCode,
|
||||
draft.Detail,
|
||||
draft.FadeDuration,
|
||||
@@ -4398,23 +4790,20 @@ public sealed class LegacyOperatorController
|
||||
PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
|
||||
draft.Selection.GroupCode,
|
||||
draft.Selection.Subject,
|
||||
isCandle ? string.Empty : draft.Selection.GraphicType,
|
||||
draft.Selection.GraphicType,
|
||||
draft.Selection.Subtype,
|
||||
draft.Selection.DataCode)));
|
||||
}
|
||||
|
||||
private void AppendThemeRow(LegacyThemePlaylistDraft draft)
|
||||
{
|
||||
var separator = draft.Detail.IndexOf(" · ", StringComparison.Ordinal);
|
||||
var graphicType = separator > 0 ? draft.Detail[..separator] : draft.Detail;
|
||||
var subtype = separator > 0 ? draft.Detail[(separator + 3)..] : string.Empty;
|
||||
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
|
||||
$"legacy-theme-{++_playlistSequence:D8}",
|
||||
true,
|
||||
draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT",
|
||||
draft.Title,
|
||||
graphicType,
|
||||
subtype,
|
||||
draft.PlaylistGraphicType,
|
||||
draft.PlaylistSubtype,
|
||||
draft.PagePlan.InitialPageText,
|
||||
draft.Selection.DataCode,
|
||||
draft.Detail,
|
||||
@@ -4881,5 +5270,55 @@ public sealed class LegacyOperatorController
|
||||
}
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -56,4 +56,10 @@ public sealed record LegacyOperatorPlayoutSnapshot(
|
||||
string Message)
|
||||
{
|
||||
public bool IsTakeOutCompletionPending { get; init; }
|
||||
|
||||
public DateTimeOffset? RefreshNextAtUtc { get; init; }
|
||||
|
||||
public DateTimeOffset? RefreshLastSuccessAtUtc { get; init; }
|
||||
|
||||
public string? RefreshFaultCode { get; init; }
|
||||
}
|
||||
|
||||
@@ -181,6 +181,16 @@ public sealed record LegacyOverseasPlaylistSelection(
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
|
||||
/// <summary>Exactly the five visible legacy playlist columns for a UC5 row.</summary>
|
||||
public sealed record LegacyOverseasPlaylistVisibleFields(
|
||||
string Market,
|
||||
string StockName,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string PageText);
|
||||
|
||||
public sealed record LegacyOverseasActionAvailability(bool IsAvailable, string? Reason);
|
||||
|
||||
public sealed record LegacyOverseasPlaylistDraft(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
@@ -194,6 +204,7 @@ public sealed record LegacyOverseasPlaylistDraft(
|
||||
string Market,
|
||||
LegacyOverseasActionSource Source,
|
||||
LegacyOverseasPlaylistSelection Selection,
|
||||
LegacyOverseasPlaylistVisibleFields VisibleFields,
|
||||
LegacyOverseasMovingAverageSelection MovingAverages,
|
||||
bool IsEnabled,
|
||||
int FadeDuration);
|
||||
@@ -213,14 +224,16 @@ public sealed class LegacyOverseasWorkflowException : Exception
|
||||
/// </summary>
|
||||
public sealed class LegacyOverseasSelectionWorkflow
|
||||
{
|
||||
public const int DefaultMaximumResults = 100;
|
||||
// UC5 itself did not impose a UI result cap. The database boundary remains
|
||||
// bounded, but use that boundary's full capacity and expose truncation.
|
||||
public const int DefaultMaximumResults = LegacyOverseasStockSearchService.MaximumResults;
|
||||
public const int MaximumResults = 500;
|
||||
public const int MaximumQueryLength = 64;
|
||||
|
||||
private const int DefaultFadeDuration = 6;
|
||||
private const string InitialPageText = "1/1";
|
||||
private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
private const string StockError = "미국·대만 해외종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
private const string StockError = "해외종목 검색 결과를 안전하게 확인하지 못했습니다.";
|
||||
|
||||
private static readonly IReadOnlyList<LegacyOverseasIndustrySearchRow> NoIndustryResults =
|
||||
Array.AsReadOnly(Array.Empty<LegacyOverseasIndustrySearchRow>());
|
||||
@@ -229,10 +242,14 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
Array.AsReadOnly(Array.Empty<LegacyOverseasStockSearchRow>());
|
||||
|
||||
private readonly IOverseasIndustryIndexSearchService _industrySearchService;
|
||||
private readonly IWorldStockSearchService _stockSearchService;
|
||||
private readonly IOverseasStockSearchService _stockSearchService;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private LegacyOverseasFixedIndexTarget? _selectedFixedIndex;
|
||||
// FarPoint starts with ActiveRowIndex=-1, but its first AddRows call makes
|
||||
// row zero active. UC5_Load inserts the fixed indices one by one, so the
|
||||
// first index is immediately the active source for the period buttons.
|
||||
private LegacyOverseasFixedIndexTarget? _selectedFixedIndex =
|
||||
LegacyOverseasActionCatalog.FixedIndexTargets[0];
|
||||
private LegacyOverseasSearchStatus _industryStatus;
|
||||
private string _industryQuery = string.Empty;
|
||||
private DateTimeOffset? _industryRetrievedAt;
|
||||
@@ -254,7 +271,7 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
|
||||
public LegacyOverseasSelectionWorkflow(
|
||||
IOverseasIndustryIndexSearchService industrySearchService,
|
||||
IWorldStockSearchService stockSearchService)
|
||||
IOverseasStockSearchService stockSearchService)
|
||||
{
|
||||
_industrySearchService = industrySearchService ??
|
||||
throw new ArgumentNullException(nameof(industrySearchService));
|
||||
@@ -273,6 +290,32 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UC5 retains the selected symbol/nation identity when materializing a
|
||||
/// stock cut. Search hits are therefore actionable for every canonical
|
||||
/// two-letter foreign nation, not only the initial-list US/TW scope.
|
||||
/// </summary>
|
||||
public LegacyOverseasActionAvailability GetActionAvailability(string actionId)
|
||||
{
|
||||
var action = LegacyOverseasActionCatalog.FindAction(actionId)
|
||||
?? throw new LegacyOverseasWorkflowException("The overseas action is not registered.");
|
||||
lock (_sync)
|
||||
{
|
||||
return action.Source switch
|
||||
{
|
||||
LegacyOverseasActionSource.FixedIndex when _selectedFixedIndex is null =>
|
||||
new(false, "해외지수를 선택하십시오."),
|
||||
LegacyOverseasActionSource.Industry when _selectedIndustry is null =>
|
||||
new(false, "해외업종을 선택하십시오."),
|
||||
LegacyOverseasActionSource.Stock when _selectedStock is null =>
|
||||
new(false, "해외종목을 선택하십시오."),
|
||||
LegacyOverseasActionSource.Stock when _selectedStock!.NationCode.Length != 2 =>
|
||||
new(false, "선택한 해외종목의 국가 코드는 두 글자여야 합니다."),
|
||||
_ => new(true, null)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreates UC5's per-control search and selection state while retaining the
|
||||
/// MainForm-owned moving-average checkboxes. Incrementing both generations makes
|
||||
@@ -288,7 +331,9 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
_stockGeneration++;
|
||||
}
|
||||
|
||||
_selectedFixedIndex = null;
|
||||
// Resetting the Web projection recreates UC5's populated fixed
|
||||
// index spread; retain FarPoint's first-row activation behavior.
|
||||
_selectedFixedIndex = LegacyOverseasActionCatalog.FixedIndexTargets[0];
|
||||
_industryStatus = LegacyOverseasSearchStatus.Idle;
|
||||
_industryQuery = string.Empty;
|
||||
_industryRetrievedAt = null;
|
||||
@@ -378,6 +423,10 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
_industryRetrievedAt = result.RetrievedAt;
|
||||
_industryResults = validated;
|
||||
_industryIsTruncated = result.IsTruncated;
|
||||
// A first AddRows call in the original RowMode spread activates
|
||||
// row zero. The action buttons therefore use its hidden identity
|
||||
// without requiring a preliminary click.
|
||||
_selectedIndustry = validated.Count == 0 ? null : validated[0];
|
||||
_industryError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
@@ -474,6 +523,9 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
_stockRetrievedAt = result.RetrievedAt;
|
||||
_stockResults = validated;
|
||||
_stockIsTruncated = result.IsTruncated;
|
||||
// Match UC5's FarPoint RowMode active-row transition after the
|
||||
// first result row is inserted.
|
||||
_selectedStock = validated.Count == 0 ? null : validated[0];
|
||||
_stockError = string.Empty;
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
@@ -639,7 +691,7 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
var movingAverages = MovingAveragesFor(action);
|
||||
return CreateDraft(
|
||||
action,
|
||||
$"{target.DisplayName} 캔들 {action.PeriodDays}일",
|
||||
target.DisplayName,
|
||||
"chart",
|
||||
new LegacyOverseasPlaylistSelection(
|
||||
"FOREIGN_INDEX",
|
||||
@@ -672,7 +724,12 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
LegacyOverseasActionDefinition action)
|
||||
{
|
||||
var stock = _selectedStock ??
|
||||
throw new LegacyOverseasWorkflowException("Select a US/TW overseas stock first.");
|
||||
throw new LegacyOverseasWorkflowException("Select an overseas stock first.");
|
||||
var availability = GetActionAvailability(action.ActionId);
|
||||
if (!availability.IsAvailable)
|
||||
{
|
||||
throw new LegacyOverseasWorkflowException(availability.Reason!);
|
||||
}
|
||||
var movingAverages = MovingAveragesFor(action);
|
||||
var current = action.Kind == LegacyOverseasActionKind.Current;
|
||||
return CreateDraft(
|
||||
@@ -707,6 +764,18 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
"overseas",
|
||||
action.Source,
|
||||
selection,
|
||||
new LegacyOverseasPlaylistVisibleFields(
|
||||
action.Source switch
|
||||
{
|
||||
LegacyOverseasActionSource.FixedIndex => "해외지수",
|
||||
LegacyOverseasActionSource.Industry => "해외업종",
|
||||
LegacyOverseasActionSource.Stock => "해외종목",
|
||||
_ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.")
|
||||
},
|
||||
title,
|
||||
action.Kind == LegacyOverseasActionKind.Candle ? "캔들그래프" : "1열판기본",
|
||||
action.Kind == LegacyOverseasActionKind.Candle ? $"{action.PeriodDays}일" : string.Empty,
|
||||
InitialPageText),
|
||||
movingAverages,
|
||||
IsEnabled: true,
|
||||
FadeDuration: DefaultFadeDuration);
|
||||
@@ -809,17 +878,14 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
throw InvalidData("stock result bound");
|
||||
}
|
||||
|
||||
var inputNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
var identities = new HashSet<WorldStockSearchItem>();
|
||||
for (var index = 0; index < items.Length; index++)
|
||||
{
|
||||
var item = items[index] ?? throw InvalidData("stock identity");
|
||||
if (!IsSafeName(item.InputName) ||
|
||||
!IsSafeSymbol(item.Symbol) ||
|
||||
item.NationCode is not ("US" or "TW") ||
|
||||
!inputNames.Add(item.InputName) ||
|
||||
!identities.Add(item) ||
|
||||
index > 0 && CompareStock(items[index - 1], item) > 0)
|
||||
!IsSafeNationCode(item.NationCode) ||
|
||||
!identities.Add(item))
|
||||
{
|
||||
throw InvalidData("stock identity or ordering");
|
||||
}
|
||||
@@ -848,23 +914,13 @@ public sealed class LegacyOverseasSelectionWorkflow
|
||||
: string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareStock(WorldStockSearchItem left, WorldStockSearchItem right)
|
||||
{
|
||||
var comparison = CompareUpper(left.InputName, right.InputName);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
|
||||
comparison = string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
|
||||
return comparison != 0
|
||||
? comparison
|
||||
: string.Compare(left.NationCode, right.NationCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CompareUpper(string left, string right) =>
|
||||
string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal);
|
||||
|
||||
private static bool IsSafeNationCode(string value) =>
|
||||
value.Length is 2 or 3 &&
|
||||
value.All(character => character is >= 'A' and <= 'Z');
|
||||
|
||||
private static string NormalizeQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
@@ -211,6 +211,8 @@ public sealed record LegacyThemePlaylistDraft(
|
||||
string Code,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Title,
|
||||
string PlaylistGraphicType,
|
||||
string PlaylistSubtype,
|
||||
string Detail,
|
||||
string Category,
|
||||
string Market,
|
||||
@@ -253,6 +255,7 @@ public sealed class LegacyThemeWorkflow
|
||||
RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal));
|
||||
|
||||
private readonly IThemeSelectionService _selectionService;
|
||||
private readonly Func<DateTimeOffset> _localNow;
|
||||
private readonly object _authority = new();
|
||||
|
||||
static LegacyThemeWorkflow()
|
||||
@@ -260,16 +263,22 @@ public sealed class LegacyThemeWorkflow
|
||||
ValidateActions(RegisteredActions);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflow(IThemeSelectionService selectionService)
|
||||
public LegacyThemeWorkflow(
|
||||
IThemeSelectionService selectionService,
|
||||
Func<DateTimeOffset>? localNow = null)
|
||||
{
|
||||
_selectionService = selectionService ??
|
||||
throw new ArgumentNullException(nameof(selectionService));
|
||||
_localNow = localNow ?? (() => DateTimeOffset.Now);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LegacyThemeActionDefinition> Actions => RegisteredActions;
|
||||
|
||||
public static IReadOnlyList<LegacyThemeSortDefinition> Sorts => RegisteredSorts;
|
||||
|
||||
public ThemeSession CurrentNxtSession =>
|
||||
_localNow().Hour <= 12 ? ThemeSession.PreMarket : ThemeSession.AfterMarket;
|
||||
|
||||
public LegacyThemeWorkflowSnapshot CreateInitial(
|
||||
ThemeSession nxtSession = ThemeSession.PreMarket)
|
||||
{
|
||||
@@ -287,6 +296,25 @@ public sealed class LegacyThemeWorkflow
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflowSnapshot ResetForTabActivation(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
ThemeSession nxtSession = ThemeSession.PreMarket)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
RequireNxtSession(nxtSession);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
query: string.Empty,
|
||||
nxtSession,
|
||||
LegacyThemeSort.InputOrder,
|
||||
[],
|
||||
searchIsTruncated: false,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SearchAsync(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string query,
|
||||
@@ -305,6 +333,9 @@ public sealed class LegacyThemeWorkflow
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var rows = ValidateSearchResult(result, normalizedQuery, nxtSession);
|
||||
var retainedSelection = snapshot.SelectedIdentity is null
|
||||
? null
|
||||
: rows.SingleOrDefault(row => row.Identity == snapshot.SelectedIdentity);
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
normalizedQuery,
|
||||
@@ -312,10 +343,10 @@ public sealed class LegacyThemeWorkflow
|
||||
snapshot.Sort,
|
||||
rows,
|
||||
result.IsTruncated,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
retainedSelection?.ResultId,
|
||||
retainedSelection?.Identity,
|
||||
retainedSelection is null ? [] : snapshot.PreviewItems,
|
||||
retainedSelection is not null && snapshot.PreviewIsTruncated);
|
||||
}
|
||||
|
||||
public async Task<LegacyThemeWorkflowSnapshot> SelectAsync(
|
||||
@@ -349,6 +380,31 @@ public sealed class LegacyThemeWorkflow
|
||||
preview.IsTruncated);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflowSnapshot RemoveSelectedResult(
|
||||
LegacyThemeWorkflowSnapshot snapshot)
|
||||
{
|
||||
RequireSnapshot(snapshot);
|
||||
if (snapshot.SelectedResultId is null)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return NewSnapshot(
|
||||
checked(snapshot.Revision + 1),
|
||||
snapshot.Query,
|
||||
snapshot.NxtSession,
|
||||
snapshot.Sort,
|
||||
snapshot.SearchResults.Where(row => !string.Equals(
|
||||
row.ResultId,
|
||||
snapshot.SelectedResultId,
|
||||
StringComparison.Ordinal)),
|
||||
snapshot.SearchIsTruncated,
|
||||
selectedResultId: null,
|
||||
selectedIdentity: null,
|
||||
previewItems: [],
|
||||
previewIsTruncated: false);
|
||||
}
|
||||
|
||||
public LegacyThemeWorkflowSnapshot SelectSort(
|
||||
LegacyThemeWorkflowSnapshot snapshot,
|
||||
string sortId)
|
||||
@@ -391,7 +447,7 @@ public sealed class LegacyThemeWorkflow
|
||||
if (action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
identity.Market == ThemeMarket.Nxt)
|
||||
{
|
||||
throw new InvalidOperationException("NXT themes do not provide expected-price data.");
|
||||
throw new InvalidOperationException("NXT는 데이터가 없습니다.");
|
||||
}
|
||||
|
||||
var sortId = SortId(snapshot.Sort);
|
||||
@@ -399,6 +455,9 @@ public sealed class LegacyThemeWorkflow
|
||||
var displayTitle = identity.Market == ThemeMarket.Nxt
|
||||
? $"{identity.ThemeTitle}(NXT)"
|
||||
: identity.ThemeTitle;
|
||||
var playlistGraphicType = PlaylistGraphicType(action);
|
||||
var playlistSubtype =
|
||||
$"테마-{ValueKindLabel(action.ValueKind)}({SortLabel(snapshot.Sort)})";
|
||||
var selection = identity.Market == ThemeMarket.Krx
|
||||
? new LegacyThemePlaylistSelection(
|
||||
"PAGED_THEME",
|
||||
@@ -409,7 +468,7 @@ public sealed class LegacyThemeWorkflow
|
||||
: new LegacyThemePlaylistSelection(
|
||||
"PAGED_NXT_THEME",
|
||||
displayTitle,
|
||||
SessionId(identity.Session),
|
||||
SessionId(CurrentNxtSession),
|
||||
sortId,
|
||||
identity.ThemeCode);
|
||||
|
||||
@@ -431,7 +490,9 @@ public sealed class LegacyThemeWorkflow
|
||||
action.Code,
|
||||
action.Aliases,
|
||||
displayTitle,
|
||||
$"{action.Label} · {SortLabel(snapshot.Sort)}",
|
||||
playlistGraphicType,
|
||||
playlistSubtype,
|
||||
playlistSubtype,
|
||||
"plate",
|
||||
"theme",
|
||||
selection,
|
||||
@@ -527,16 +588,12 @@ public sealed class LegacyThemeWorkflow
|
||||
throw InvalidData("search envelope");
|
||||
}
|
||||
|
||||
var titles = new Dictionary<ThemeMarket, HashSet<string>>
|
||||
{
|
||||
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
|
||||
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
|
||||
};
|
||||
var codes = new Dictionary<ThemeMarket, HashSet<string>>
|
||||
{
|
||||
[ThemeMarket.Krx] = new(StringComparer.Ordinal),
|
||||
[ThemeMarket.Nxt] = new(StringComparer.Ordinal)
|
||||
};
|
||||
var sawNxt = false;
|
||||
foreach (var item in serviceItems)
|
||||
{
|
||||
if (item is null)
|
||||
@@ -549,25 +606,30 @@ public sealed class LegacyThemeWorkflow
|
||||
? DataSourceKind.Oracle
|
||||
: DataSourceKind.MariaDb;
|
||||
if (item.Source != expectedSource ||
|
||||
!titles[item.Identity.Market].Add(item.Identity.ThemeTitle) ||
|
||||
!codes[item.Identity.Market].Add(item.Identity.ThemeCode))
|
||||
{
|
||||
throw InvalidData("search identity or provider");
|
||||
}
|
||||
|
||||
// UC4 appended the Oracle list and then the MariaDB list. Preserve
|
||||
// each provider's returned order; do not sort rows by title or code.
|
||||
if (item.Identity.Market == ThemeMarket.Nxt)
|
||||
{
|
||||
sawNxt = true;
|
||||
}
|
||||
else if (sawNxt)
|
||||
{
|
||||
throw InvalidData("search provider order");
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = serviceItems
|
||||
.OrderBy(item => item.Identity.Market)
|
||||
.ThenBy(item => item.Identity.ThemeTitle, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Identity.ThemeCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var rows = new LegacyThemeSearchRow[ordered.Length];
|
||||
for (var index = 0; index < ordered.Length; index++)
|
||||
var rows = new LegacyThemeSearchRow[serviceItems.Length];
|
||||
for (var index = 0; index < serviceItems.Length; index++)
|
||||
{
|
||||
rows[index] = new LegacyThemeSearchRow(
|
||||
$"theme-result-{index + 1:000}",
|
||||
ordered[index].Identity,
|
||||
ordered[index].Source);
|
||||
serviceItems[index].Identity,
|
||||
serviceItems[index].Source);
|
||||
}
|
||||
|
||||
return Array.AsReadOnly(rows);
|
||||
@@ -589,7 +651,6 @@ public sealed class LegacyThemeWorkflow
|
||||
throw InvalidData("preview envelope");
|
||||
}
|
||||
|
||||
var itemCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var indexes = new HashSet<int>();
|
||||
var previousIndex = -1;
|
||||
var rows = new LegacyThemePreviewRow[serviceItems.Length];
|
||||
@@ -598,7 +659,7 @@ public sealed class LegacyThemeWorkflow
|
||||
var item = serviceItems[index]
|
||||
?? throw InvalidData("preview item");
|
||||
if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex ||
|
||||
!indexes.Add(item.InputIndex) || !itemCodes.Add(item.ItemCode) ||
|
||||
!indexes.Add(item.InputIndex) ||
|
||||
!IsItemCodeForMarket(item.ItemCode, identity.Market) ||
|
||||
!IsCanonicalText(item.ItemName, 200))
|
||||
{
|
||||
@@ -684,6 +745,28 @@ public sealed class LegacyThemeWorkflow
|
||||
"Unknown theme value kind.")
|
||||
};
|
||||
|
||||
private static string ValueKindLabel(LegacyThemeValueKind valueKind) => valueKind switch
|
||||
{
|
||||
LegacyThemeValueKind.Current => "현재가",
|
||||
LegacyThemeValueKind.Expected => "예상체결가",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(valueKind),
|
||||
valueKind,
|
||||
"Unknown theme value kind.")
|
||||
};
|
||||
|
||||
private static string PlaylistGraphicType(LegacyThemeActionDefinition action) =>
|
||||
action.Id switch
|
||||
{
|
||||
"five-row-current" or "five-row-expected" => "5단 표그래프",
|
||||
"six-row-current" => "6종목 현재가",
|
||||
"twelve-row-current" => "12종목 현재가",
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(action),
|
||||
action.Id,
|
||||
"Unknown theme action.")
|
||||
};
|
||||
|
||||
private static void RequireNxtSession(ThemeSession nxtSession)
|
||||
{
|
||||
if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
|
||||
@@ -780,10 +863,7 @@ public sealed class LegacyThemeWorkflow
|
||||
? "theme-not-selected"
|
||||
: previewItemCount == 0
|
||||
? "theme-preview-is-empty"
|
||||
: action.ValueKind == LegacyThemeValueKind.Expected &&
|
||||
selectedIdentity.Market == ThemeMarket.Nxt
|
||||
? "expected-theme-is-krx-only"
|
||||
: null;
|
||||
: null;
|
||||
states[index] = new LegacyThemeActionState(
|
||||
action.Id,
|
||||
reason is null,
|
||||
|
||||
@@ -76,6 +76,7 @@ public sealed record LegacyOperatorCatalogWorkflowSnapshot(
|
||||
LegacyThemeNameAvailability ThemeNameAvailability,
|
||||
string CheckedThemeName,
|
||||
IReadOnlyList<LegacyOperatorCatalogDraftRowView> DraftRows,
|
||||
string? ActiveDraftRowId,
|
||||
string StockQuery,
|
||||
IReadOnlyList<LegacyOperatorCatalogStockResultView> StockResults,
|
||||
bool StockResultsAreTruncated,
|
||||
@@ -203,6 +204,12 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
{
|
||||
public const int MaximumCatalogResults = 500;
|
||||
public const int MaximumStockResults = 100;
|
||||
public const string ThemeNameDuplicateMessage = "이미 사용중인 테마명입니다.";
|
||||
public const string ThemeNameAvailableMessage = "사용 가능한 테마명입니다.";
|
||||
public const string ThemeSaveSuccessMessage = "ThemeA를 저장했습니다.";
|
||||
public const string ExpertSaveSuccessMessage = "EList를 저장했습니다.";
|
||||
public const string ThemeDeleteSuccessMessage = "ThemeA를 삭제했습니다.";
|
||||
public const string ExpertDeleteSuccessMessage = "EList를 삭제했습니다.";
|
||||
|
||||
private const decimal MaximumWebSafeInteger = 9_007_199_254_740_991m;
|
||||
private const string OutcomeUnknownMessage =
|
||||
@@ -240,6 +247,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
private string _checkedThemeName = string.Empty;
|
||||
private string? _selectedResultId;
|
||||
private string? _selectedStockResultId;
|
||||
private string? _activeDraftRowId;
|
||||
private ThemeSelectionIdentity? _expectedThemeIdentity;
|
||||
private ExpertSelectionIdentity? _expectedExpertIdentity;
|
||||
private string _statusMessage = string.Empty;
|
||||
@@ -298,6 +306,12 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
LegacyOperatorCatalogEntity entity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_isOpen && _mode != LegacyOperatorCatalogMode.List)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Close the active legacy child dialog before switching catalogs.");
|
||||
}
|
||||
|
||||
_isOpen = true;
|
||||
_entity = entity;
|
||||
_mode = LegacyOperatorCatalogMode.List;
|
||||
@@ -420,7 +434,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
ThemeSession nxtSession,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureOpen();
|
||||
EnsureCatalogListMode();
|
||||
_query = ValidateQuery(query);
|
||||
if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
|
||||
{
|
||||
@@ -454,7 +468,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
string resultId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureOpen();
|
||||
EnsureCatalogListMode();
|
||||
var selected = FindResult(resultId);
|
||||
try
|
||||
{
|
||||
@@ -516,6 +530,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
throw new InvalidOperationException("The catalog result has no trusted identity.");
|
||||
}
|
||||
|
||||
_activeDraftRowId = _draftRows.FirstOrDefault()?.RowId;
|
||||
_selectedResultId = resultId;
|
||||
_mode = LegacyOperatorCatalogMode.Edit;
|
||||
ResetThemeNameAvailability();
|
||||
@@ -543,6 +558,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme);
|
||||
EnsureCatalogListMode();
|
||||
if (!CanMutateCurrentEntity())
|
||||
{
|
||||
return RejectUnavailableWrite();
|
||||
@@ -560,6 +576,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
_expectedThemeIdentity = null;
|
||||
_expectedExpertIdentity = null;
|
||||
_draftRows.Clear();
|
||||
_activeDraftRowId = null;
|
||||
ResetThemeNameAvailability();
|
||||
ResetStockSearch();
|
||||
SetStatus("새 ThemeA 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information);
|
||||
@@ -599,14 +616,14 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
{
|
||||
_themeNameAvailability = LegacyThemeNameAvailability.Duplicate;
|
||||
SetStatus(
|
||||
"이미 사용중인 테마명입니다.",
|
||||
ThemeNameDuplicateMessage,
|
||||
LegacyOperatorCatalogStatusKind.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
_themeNameAvailability = LegacyThemeNameAvailability.Available;
|
||||
SetStatus(
|
||||
"사용 가능한 테마명입니다.",
|
||||
ThemeNameAvailableMessage,
|
||||
LegacyOperatorCatalogStatusKind.Information);
|
||||
}
|
||||
}
|
||||
@@ -629,6 +646,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureOpenEntity(LegacyOperatorCatalogEntity.Expert);
|
||||
EnsureCatalogListMode();
|
||||
if (!CanMutateCurrentEntity())
|
||||
{
|
||||
return RejectUnavailableWrite();
|
||||
@@ -644,6 +662,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
_expectedThemeIdentity = null;
|
||||
_expectedExpertIdentity = null;
|
||||
_draftRows.Clear();
|
||||
_activeDraftRowId = null;
|
||||
ResetStockSearch();
|
||||
SetStatus("새 EList 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information);
|
||||
}
|
||||
@@ -665,7 +684,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
string query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureEditing();
|
||||
EnsureDraftRowsEditable();
|
||||
_stockQuery = ValidateRequiredText(query, LegacyStockSearchService.MaximumQueryLength);
|
||||
_selectedStockResultId = null;
|
||||
try
|
||||
@@ -698,7 +717,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
|
||||
public LegacyOperatorCatalogWorkflowSnapshot SelectStock(string resultId)
|
||||
{
|
||||
EnsureEditing();
|
||||
EnsureDraftRowsEditable();
|
||||
var stock = FindStock(resultId);
|
||||
if (!IsCompatible(stock.Item))
|
||||
{
|
||||
@@ -716,7 +735,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
|
||||
public LegacyOperatorCatalogWorkflowSnapshot AddSelectedStock(decimal? buyAmount)
|
||||
{
|
||||
EnsureEditing();
|
||||
EnsureDraftRowsEditable();
|
||||
var selected = _selectedStockResultId is null
|
||||
? throw new InvalidOperationException("Select a stock-master result first.")
|
||||
: FindStock(_selectedStockResultId);
|
||||
@@ -747,29 +766,15 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
throw new InvalidOperationException("EList recommendation limit reached.");
|
||||
}
|
||||
|
||||
if (buyAmount is null || decimal.Truncate(buyAmount.Value) != buyAmount.Value ||
|
||||
buyAmount.Value <= 0 || buyAmount.Value > MaximumWebSafeInteger)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(buyAmount));
|
||||
}
|
||||
|
||||
row = DraftRowState.ForExpert(
|
||||
NewOpaqueId("catalog-draft"),
|
||||
selected.Item.Code,
|
||||
selected.Item.Name,
|
||||
buyAmount.Value);
|
||||
}
|
||||
|
||||
if (_draftRows.Any(existing =>
|
||||
string.Equals(existing.Code, row.Code, StringComparison.Ordinal) ||
|
||||
string.Equals(existing.Name, row.Name, StringComparison.Ordinal)))
|
||||
{
|
||||
SetStatus("같은 코드 또는 이름의 종목이 이미 있습니다.", LegacyOperatorCatalogStatusKind.Warning);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
buyAmount: null);
|
||||
}
|
||||
|
||||
_draftRows.Add(row);
|
||||
_activeDraftRowId = row.RowId;
|
||||
_selectedStockResultId = null;
|
||||
SetStatus("종목을 편집 목록에 추가했습니다.", LegacyOperatorCatalogStatusKind.Information);
|
||||
Touch();
|
||||
@@ -780,15 +785,24 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
string rowId,
|
||||
LegacyOperatorCatalogMoveDirection direction)
|
||||
{
|
||||
EnsureEditing();
|
||||
EnsureDraftRowsEditable();
|
||||
var index = FindDraftIndex(rowId);
|
||||
var target = direction == LegacyOperatorCatalogMoveDirection.Up ? index - 1 : index + 1;
|
||||
var activeChanged = !string.Equals(
|
||||
_activeDraftRowId,
|
||||
rowId,
|
||||
StringComparison.Ordinal);
|
||||
_activeDraftRowId = rowId;
|
||||
if (target >= 0 && target < _draftRows.Count)
|
||||
{
|
||||
(_draftRows[index], _draftRows[target]) = (_draftRows[target], _draftRows[index]);
|
||||
SetStatus("표시 순서를 변경했습니다.", LegacyOperatorCatalogStatusKind.Information);
|
||||
Touch();
|
||||
}
|
||||
else if (activeChanged)
|
||||
{
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
@@ -800,7 +814,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sourceRowId);
|
||||
ArgumentNullException.ThrowIfNull(targetRowId);
|
||||
EnsureEditing();
|
||||
EnsureDraftRowsEditable();
|
||||
|
||||
var sourceIndex = _draftRows.FindIndex(row =>
|
||||
string.Equals(row.RowId, sourceRowId, StringComparison.Ordinal));
|
||||
@@ -811,6 +825,12 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var activeChanged = !string.Equals(
|
||||
_activeDraftRowId,
|
||||
sourceRowId,
|
||||
StringComparison.Ordinal);
|
||||
_activeDraftRowId = sourceRowId;
|
||||
|
||||
var candidate = _draftRows.ToList();
|
||||
var movingRow = candidate[sourceIndex];
|
||||
candidate.RemoveAt(sourceIndex);
|
||||
@@ -828,6 +848,10 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
candidate.Select(static row => row.RowId),
|
||||
StringComparer.Ordinal))
|
||||
{
|
||||
if (activeChanged)
|
||||
{
|
||||
Touch();
|
||||
}
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
@@ -840,23 +864,25 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
|
||||
public LegacyOperatorCatalogWorkflowSnapshot SetDraftBuyAmount(
|
||||
string rowId,
|
||||
decimal buyAmount)
|
||||
decimal? buyAmount)
|
||||
{
|
||||
EnsureEditing();
|
||||
EnsureDraftRowsEditable();
|
||||
if (_entity != LegacyOperatorCatalogEntity.Expert)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Only EList recommendation rows have a buy amount.");
|
||||
}
|
||||
|
||||
if (decimal.Truncate(buyAmount) != buyAmount ||
|
||||
buyAmount <= 0 || buyAmount > MaximumWebSafeInteger)
|
||||
if (buyAmount is not null &&
|
||||
(decimal.Truncate(buyAmount.Value) != buyAmount.Value ||
|
||||
buyAmount.Value <= 0 || buyAmount.Value > MaximumWebSafeInteger))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(buyAmount));
|
||||
}
|
||||
|
||||
var index = FindDraftIndex(rowId);
|
||||
_draftRows[index] = _draftRows[index] with { BuyAmount = buyAmount };
|
||||
_activeDraftRowId = rowId;
|
||||
SetStatus("추천 종목의 매수가를 변경했습니다. 저장을 눌러 DB에 반영하세요.",
|
||||
LegacyOperatorCatalogStatusKind.Information);
|
||||
Touch();
|
||||
@@ -865,19 +891,83 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
|
||||
public LegacyOperatorCatalogWorkflowSnapshot RemoveDraftRow(string rowId)
|
||||
{
|
||||
EnsureEditing();
|
||||
_draftRows.RemoveAt(FindDraftIndex(rowId));
|
||||
EnsureDraftRowsEditable();
|
||||
var index = FindDraftIndex(rowId);
|
||||
_draftRows.RemoveAt(index);
|
||||
_activeDraftRowId = _draftRows.Count == 0
|
||||
? null
|
||||
: _draftRows[Math.Min(index, _draftRows.Count - 1)].RowId;
|
||||
SetStatus("편집 목록에서 종목을 삭제했습니다.", LegacyOperatorCatalogStatusKind.Information);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public Task<LegacyOperatorCatalogWorkflowSnapshot> SaveAsync(
|
||||
public LegacyOperatorCatalogWorkflowSnapshot SelectDraftRow(string rowId)
|
||||
{
|
||||
EnsureDraftRowsEditable();
|
||||
var canonicalRowId = _draftRows[FindDraftIndex(rowId)].RowId;
|
||||
if (!string.Equals(_activeDraftRowId, canonicalRowId, StringComparison.Ordinal))
|
||||
{
|
||||
_activeDraftRowId = canonicalRowId;
|
||||
Touch();
|
||||
}
|
||||
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public LegacyOperatorCatalogWorkflowSnapshot RemoveActiveDraftRow()
|
||||
{
|
||||
EnsureDraftRowsEditable();
|
||||
if (_activeDraftRowId is null)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
return RemoveDraftRow(_activeDraftRowId);
|
||||
}
|
||||
|
||||
public LegacyOperatorCatalogWorkflowSnapshot ClearThemeDraftRows()
|
||||
{
|
||||
EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme);
|
||||
EnsureEditing();
|
||||
if (_draftRows.Count == 0)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
_draftRows.Clear();
|
||||
_activeDraftRowId = null;
|
||||
SetStatus("모든 구성 종목을 편집 목록에서 삭제했습니다.",
|
||||
LegacyOperatorCatalogStatusKind.Information);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
public async Task<LegacyOperatorCatalogWorkflowSnapshot> SaveAsync(
|
||||
string editorName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureEditing();
|
||||
_editorName = ValidateRequiredText(editorName, 128);
|
||||
|
||||
// UC6 silently ignored Save when the selected expert had no recommendation rows.
|
||||
// This check intentionally precedes name validation and does not touch revision/status.
|
||||
if (_entity == LegacyOperatorCatalogEntity.Expert &&
|
||||
_mode == LegacyOperatorCatalogMode.Edit &&
|
||||
_draftRows.Count == 0)
|
||||
{
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
if (!CanMutateCurrentEntity())
|
||||
{
|
||||
return RejectUnavailableWrite();
|
||||
}
|
||||
|
||||
_editorName = _entity == LegacyOperatorCatalogEntity.Expert &&
|
||||
_mode == LegacyOperatorCatalogMode.Edit
|
||||
? (_expectedExpertIdentity ?? throw new InvalidOperationException(
|
||||
"The trusted EList edit identity is missing.")).ExpertName
|
||||
: ValidateRequiredText(editorName, 128);
|
||||
|
||||
if (_entity == LegacyOperatorCatalogEntity.Theme)
|
||||
{
|
||||
@@ -890,24 +980,43 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
_codeLabel,
|
||||
_editorName,
|
||||
Array.AsReadOnly(items));
|
||||
return ExecuteWriteAsync(
|
||||
"ThemeA를 저장했습니다.",
|
||||
token => _identityValidationService.ValidateThemeItemsAsync(items, token),
|
||||
return await ExecuteWriteAsync(
|
||||
ThemeSaveSuccessMessage,
|
||||
async token =>
|
||||
{
|
||||
if (await _themePersistenceService.ThemeNameExistsAsync(
|
||||
_newThemeMarket,
|
||||
_editorName,
|
||||
token).ConfigureAwait(false))
|
||||
{
|
||||
throw new ThemeNameDuplicateException();
|
||||
}
|
||||
|
||||
await _identityValidationService.ValidateThemeItemsAsync(items, token)
|
||||
.ConfigureAwait(false);
|
||||
},
|
||||
token => _themePersistenceService.CreateAsync(definition, token),
|
||||
cancellationToken);
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expected = _expectedThemeIdentity ?? throw new InvalidOperationException(
|
||||
"The trusted ThemeA edit identity is missing.");
|
||||
return ExecuteWriteAsync(
|
||||
"ThemeA를 저장했습니다.",
|
||||
return await ExecuteWriteAsync(
|
||||
ThemeSaveSuccessMessage,
|
||||
token => _identityValidationService.ValidateThemeItemsAsync(items, token),
|
||||
token => _themePersistenceService.ReplaceAsync(
|
||||
expected,
|
||||
_editorName,
|
||||
items,
|
||||
token),
|
||||
cancellationToken);
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_draftRows.Any(static row => row.BuyAmount is null))
|
||||
{
|
||||
SetStatus("매수가를 입력하세요.", LegacyOperatorCatalogStatusKind.Warning);
|
||||
Touch();
|
||||
return CreateSnapshot();
|
||||
}
|
||||
|
||||
var recommendations = _draftRows.Select((row, index) =>
|
||||
@@ -915,26 +1024,29 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
index,
|
||||
row.Code,
|
||||
row.Name,
|
||||
row.BuyAmount ?? throw new InvalidOperationException(
|
||||
"An EList recommendation amount is missing."))).ToArray();
|
||||
row.BuyAmount!.Value)).ToArray();
|
||||
if (_mode == LegacyOperatorCatalogMode.Create)
|
||||
{
|
||||
if (recommendations.Length != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The legacy EList create dialog cannot contain recommendations.");
|
||||
}
|
||||
|
||||
var definition = new ExpertCatalogDefinition(
|
||||
new ExpertSelectionIdentity(_codeLabel, _editorName),
|
||||
Array.AsReadOnly(recommendations));
|
||||
return ExecuteWriteAsync(
|
||||
"EList를 저장했습니다.",
|
||||
token => _identityValidationService.ValidateExpertRecommendationsAsync(
|
||||
recommendations,
|
||||
token),
|
||||
return await ExecuteWriteAsync(
|
||||
ExpertSaveSuccessMessage,
|
||||
static _ => Task.CompletedTask,
|
||||
token => _expertPersistenceService.CreateAsync(definition, token),
|
||||
cancellationToken);
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedExpert = _expectedExpertIdentity ?? throw new InvalidOperationException(
|
||||
"The trusted EList edit identity is missing.");
|
||||
return ExecuteWriteAsync(
|
||||
"EList를 저장했습니다.",
|
||||
return await ExecuteWriteAsync(
|
||||
ExpertSaveSuccessMessage,
|
||||
token => _identityValidationService.ValidateExpertRecommendationsAsync(
|
||||
recommendations,
|
||||
token),
|
||||
@@ -943,7 +1055,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
_editorName,
|
||||
recommendations,
|
||||
token),
|
||||
cancellationToken);
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<LegacyOperatorCatalogWorkflowSnapshot> DeleteAsync(
|
||||
@@ -960,7 +1072,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
var expected = _expectedThemeIdentity ?? throw new InvalidOperationException(
|
||||
"The trusted ThemeA delete identity is missing.");
|
||||
return ExecuteWriteAsync(
|
||||
"ThemeA를 삭제했습니다.",
|
||||
ThemeDeleteSuccessMessage,
|
||||
validation: null,
|
||||
token => _themePersistenceService.DeleteAsync(expected, token),
|
||||
cancellationToken);
|
||||
@@ -969,7 +1081,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
var expectedExpert = _expectedExpertIdentity ?? throw new InvalidOperationException(
|
||||
"The trusted EList delete identity is missing.");
|
||||
return ExecuteWriteAsync(
|
||||
"EList를 삭제했습니다.",
|
||||
ExpertDeleteSuccessMessage,
|
||||
validation: null,
|
||||
token => _expertPersistenceService.DeleteAsync(expectedExpert, token),
|
||||
cancellationToken);
|
||||
@@ -1068,6 +1180,13 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
"DB 내용이 바뀌었거나 코드/이름이 중복되어 transaction이 rollback되었습니다. 목록을 다시 읽으세요.",
|
||||
LegacyOperatorCatalogStatusKind.Warning);
|
||||
}
|
||||
catch (ThemeNameDuplicateException) when (!dispatched)
|
||||
{
|
||||
_writeSafety.Complete();
|
||||
_checkedThemeName = _editorName;
|
||||
_themeNameAvailability = LegacyThemeNameAvailability.Duplicate;
|
||||
SetStatus(ThemeNameDuplicateMessage, LegacyOperatorCatalogStatusKind.Warning);
|
||||
}
|
||||
catch (StockMasterIdentityDataException) when (!dispatched)
|
||||
{
|
||||
_writeSafety.Complete();
|
||||
@@ -1183,7 +1302,11 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
var draftViews = _draftRows.Select((row, index) =>
|
||||
new LegacyOperatorCatalogDraftRowView(
|
||||
row.RowId,
|
||||
row.Name,
|
||||
_entity == LegacyOperatorCatalogEntity.Theme &&
|
||||
row.Code.Length > 0 && row.Code[0] is 'X' or 'T' &&
|
||||
!row.Name.EndsWith("(NXT)", StringComparison.Ordinal)
|
||||
? row.Name + "(NXT)"
|
||||
: row.Name,
|
||||
row.MarketLabel,
|
||||
row.BuyAmount,
|
||||
index)).ToArray();
|
||||
@@ -1214,6 +1337,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
_themeNameAvailability,
|
||||
_checkedThemeName,
|
||||
Array.AsReadOnly(draftViews),
|
||||
_activeDraftRowId,
|
||||
_stockQuery,
|
||||
Array.AsReadOnly(stockViews),
|
||||
_stockResultsAreTruncated,
|
||||
@@ -1229,6 +1353,8 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
canMutate && _mode == LegacyOperatorCatalogMode.Edit,
|
||||
canMutate &&
|
||||
(_mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit) &&
|
||||
(_entity != LegacyOperatorCatalogEntity.Expert ||
|
||||
_mode == LegacyOperatorCatalogMode.Edit) &&
|
||||
_selectedStockResultId is not null,
|
||||
quarantined && string.IsNullOrWhiteSpace(_statusMessage)
|
||||
? quarantineReason ?? OutcomeUnknownMessage
|
||||
@@ -1262,6 +1388,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
_codeLabel = string.Empty;
|
||||
_editorName = string.Empty;
|
||||
_draftRows.Clear();
|
||||
_activeDraftRowId = null;
|
||||
ResetThemeNameAvailability();
|
||||
ResetStockSearch();
|
||||
}
|
||||
@@ -1306,6 +1433,27 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureCatalogListMode()
|
||||
{
|
||||
EnsureOpen();
|
||||
if (_mode != LegacyOperatorCatalogMode.List)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The legacy parent catalog is disabled while its child dialog is open.");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDraftRowsEditable()
|
||||
{
|
||||
EnsureEditing();
|
||||
if (_entity == LegacyOperatorCatalogEntity.Expert &&
|
||||
_mode != LegacyOperatorCatalogMode.Edit)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The legacy EList create dialog only accepts an expert name.");
|
||||
}
|
||||
}
|
||||
|
||||
private CatalogResultState FindResult(string resultId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resultId);
|
||||
@@ -1489,7 +1637,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
string rowId,
|
||||
string code,
|
||||
string name,
|
||||
decimal buyAmount) =>
|
||||
decimal? buyAmount) =>
|
||||
new(rowId, code, name, "종목", buyAmount);
|
||||
|
||||
private static string ThemeMarketLabel(string code) => code.Length > 0
|
||||
@@ -1505,4 +1653,8 @@ public sealed class LegacyOperatorCatalogWorkflowController
|
||||
}
|
||||
|
||||
private sealed record StockResultState(string ResultId, StockSearchItem Item);
|
||||
|
||||
private sealed class ThemeNameDuplicateException : Exception
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user