feat: advance legacy UI behavior parity

This commit is contained in:
2026-07-18 10:18:29 +09:00
parent d7ec571343
commit 1302b1b92f
71 changed files with 11244 additions and 1153 deletions

View File

@@ -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)
{

View File

@@ -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.");

View File

@@ -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++;

View File

@@ -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++;
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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
{
}
}