feat: consolidate legacy parity migration baseline

This commit is contained in:
2026-07-20 22:06:12 +09:00
parent 5eb4054120
commit 4a54977341
66 changed files with 5391 additions and 460 deletions

View File

@@ -4,6 +4,10 @@ using CoreMovingAverageSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
using CoreMovingAverageSource =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
using CoreSceneSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection;
using S5011DetailBranch =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -403,8 +407,17 @@ public sealed class LegacyOperatorController
bool shift,
int x,
int y,
long timestampMilliseconds)
long timestampMilliseconds,
bool allowAppend = true)
{
if (!allowAppend)
{
// Selection remains available while a scene is prepared or on air, but
// an on-air click must neither activate a cut nor prime the next IDLE
// click as the second half of a legacy pointer double-click.
_previousPointerTimestamp = null;
}
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
{
return CreateSnapshot();
@@ -413,7 +426,8 @@ public sealed class LegacyOperatorController
var gap = _previousPointerTimestamp is long previousTimestamp
? timestampMilliseconds - previousTimestamp
: long.MaxValue;
if (_previousPointerTimestamp is not null &&
if (allowAppend &&
_previousPointerTimestamp is not null &&
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
_previousPointerRow == physicalIndex &&
_previousPointerX == x &&
@@ -425,7 +439,7 @@ public sealed class LegacyOperatorController
}
else
{
_previousPointerTimestamp = timestampMilliseconds;
_previousPointerTimestamp = allowAppend ? timestampMilliseconds : null;
}
_previousPointerRow = physicalIndex;
@@ -3409,7 +3423,18 @@ public sealed class LegacyOperatorController
var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
cancellationToken).ConfigureAwait(false);
var loadedRows = namedPlaylist.Rows
.Select(CreateNamedPlaylistOperatorRow)
.Select(row =>
{
try
{
return CreateNamedPlaylistOperatorRow(row);
}
catch (InvalidDataException exception)
{
exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex;
throw;
}
})
.ToArray();
_playlist.Clear();
@@ -3422,10 +3447,13 @@ public sealed class LegacyOperatorController
{
throw;
}
catch (InvalidDataException)
catch (InvalidDataException exception)
{
var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex
? $" {itemIndex + 1}행에"
: string.Empty;
_dialog = new LegacyDialogState(
"DB 재생목록 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
$"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Error;
}
@@ -4176,12 +4204,33 @@ public sealed class LegacyOperatorController
{
var selection = row.Selection;
var sceneAlias = ResolveNamedPlaylistSceneAlias(selection);
var playoutSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode);
var playoutSelection = TryCreateNamedFixedSelection(
selection,
out var fixedSelection,
out _)
? fixedSelection
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
? stockDetailSelection
: TryCreateNamedIndustrySelection(selection, out var industrySelection)
? industrySelection
: TryCreateNamedComparisonSelection(selection, out var comparisonSelection)
? comparisonSelection
: TryCreateNamedManualFinancialSelection(
selection,
out var manualFinancialSelection)
? manualFinancialSelection
: TryCreateNamedManualNetSellSelection(
selection,
out var manualNetSellSelection)
? manualNetSellSelection
: TryCreateNamedVersionedViSelection(selection, out var viSelection)
? viSelection
: new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode);
var rawCutLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
@@ -4226,26 +4275,9 @@ public sealed class LegacyOperatorController
}
}
var fixedMarket = selection.GroupCode switch
if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
{
"해외지수" => LegacyFixedMarket.Overseas,
"환율" => LegacyFixedMarket.Exchange,
"주요지수" => LegacyFixedMarket.Index,
_ => (LegacyFixedMarket?)null
};
if (fixedMarket is { } market)
{
var actions = _fixedCatalog.GetActions(market)
.Where(action =>
string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) &&
string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length == 1)
{
return actions[0].Code;
}
return fixedAction!.Code;
}
var industryMarket = selection.GroupCode switch
@@ -4294,6 +4326,23 @@ public sealed class LegacyOperatorController
return "5074";
}
// FSell rows are saved with the closed canonical selection emitted by the
// native manual-list workflow. Older PList rows use the original visible
// seven-field identity; the scene resolver accepts both shapes and performs
// the fresh trusted-file read again before any cue can reach Tornado2.
if (TryCreateNamedManualNetSellSelection(selection, out _))
{
return "5025";
}
// A versioned VI row carries no path or operator-controlled scene name. The
// native cue provider reopens the trusted snapshot and requires this exact
// version at PREPARE, so alias restoration remains fail-closed.
if (TryCreateNamedVersionedViSelection(selection, out _))
{
return "5074";
}
if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
{
var alias = selection.GraphicType switch
@@ -4327,6 +4376,474 @@ public sealed class LegacyOperatorController
"The named-playlist row does not map to a registered scene alias.");
}
private bool TryCreateNamedFixedSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized,
out LegacyFixedAction? action)
{
normalized = null!;
action = null;
var fixedMarket = selection.GroupCode switch
{
"해외지수" => LegacyFixedMarket.Overseas,
"환율" => LegacyFixedMarket.Exchange,
"주요지수" => LegacyFixedMarket.Index,
_ => (LegacyFixedMarket?)null
};
if (fixedMarket is not { } market)
{
return false;
}
var matches = _fixedCatalog.GetActions(market)
.Where(candidate =>
string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) &&
string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (matches.Length != 1 || !matches[0].Available)
{
return false;
}
action = matches[0];
var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection;
normalized = new CoreSceneSelection(
materialized.GroupCode,
materialized.Subject,
materialized.GraphicType,
materialized.Subtype,
selection.DataCode);
return true;
}
private static bool TryCreateNamedStockDetailSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.GroupCode is not ("코스피" or "코스닥") ||
!string.Equals(selection.GraphicType, "1열판상세", StringComparison.Ordinal))
{
return false;
}
var branch = selection.Subtype switch
{
"액면가" => nameof(S5011DetailBranch.FaceValue),
"PBR" => nameof(S5011DetailBranch.Valuation),
"거래량" => nameof(S5011DetailBranch.Volume),
_ => null
};
if (branch is null)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
normalized = new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
branch,
selection.DataCode);
return true;
}
private static bool TryCreateNamedIndustrySelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var market = selection.GroupCode switch
{
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
"업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
_ => (MMoneyCoderSharp.Data.IndustryMarket?)null
};
if (market is not { } exactMarket)
{
return false;
}
var rawLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
var actions = LegacyIndustryActionCatalog.GetActions(exactMarket)
.Where(action => string.Equals(action.Label, rawLabel, StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length != 1)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
256,
nameof(selection.Subject));
}
catch (ArgumentException)
{
return false;
}
var action = actions[0];
var industryGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "INDUSTRY_KOSPI"
: "INDUSTRY_KOSDAQ";
var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "KOSPI"
: "KOSDAQ";
normalized = action.Kind switch
{
LegacyIndustryActionKind.Single => new CoreSceneSelection(
industryGroup,
selection.Subject,
"1열판",
"CURRENT",
string.Empty),
LegacyIndustryActionKind.Pair => new CoreSceneSelection(
industryGroup,
selection.Subject,
"2열판",
string.Empty,
string.Empty),
LegacyIndustryActionKind.Yield => new CoreSceneSelection(
industryGroup,
selection.Subject,
"YIELD_GRAPH",
action.YieldPeriod!,
string.Empty),
LegacyIndustryActionKind.Candle => new CoreSceneSelection(
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "KOSPI_INDUSTRY"
: "KOSDAQ_INDUSTRY",
selection.Subject,
string.Empty,
action.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE",
string.Empty),
LegacyIndustryActionKind.FiveRow when
DateOnly.TryParseExact(
selection.DataCode,
"yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out _) => new CoreSceneSelection(
exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
? "PAGED_DOMESTIC_KOSPI"
: "PAGED_DOMESTIC_KOSDAQ",
"INDEX",
string.Empty,
"INDUSTRY",
selection.DataCode),
LegacyIndustryActionKind.Square => new CoreSceneSelection(
industryGroup,
marketName,
"네모그래프",
string.Empty,
string.Empty),
LegacyIndustryActionKind.Sector => new CoreSceneSelection(
industryGroup,
marketName,
"SECTOR_INDEX",
string.Empty,
string.Empty),
_ => null!
};
return normalized is not null;
}
private static bool TryCreateNamedComparisonSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (!string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal))
{
return false;
}
var names = selection.Subject.Split(" ↔ ", StringSplitOptions.None);
if (names.Length != 2 || names.Any(string.IsNullOrWhiteSpace) ||
names.Any(name => !string.Equals(name, name.Trim(), StringComparison.Ordinal)))
{
return false;
}
LegacyComparisonTarget first;
LegacyComparisonTarget second;
if (selection.DataCode.Length == 0)
{
first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[0], string.Empty))!;
second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
new LegacyComparisonStoredTargetReference(names[1], string.Empty))!;
if (first is null || second is null)
{
return false;
}
}
else if (!TryCreateNamedComparisonStocks(
names,
selection.DataCode,
out first,
out second))
{
return false;
}
var actions = LegacyComparisonWorkflowCatalog.Actions
.Where(action => string.Equals(
action.Label,
selection.Subtype,
StringComparison.Ordinal))
.Take(2)
.ToArray();
if (actions.Length != 1)
{
return false;
}
try
{
var draft = LegacyComparisonWorkflowCatalog.Materialize(
actions[0].ActionId,
first,
second,
"named-restore");
if (!string.Equals(draft.BuilderKey, selection.GraphicType, StringComparison.Ordinal) ||
!string.Equals(draft.Detail, selection.Subtype, StringComparison.Ordinal) ||
!string.Equals(
draft.Selection.DataCode,
selection.DataCode,
StringComparison.Ordinal))
{
return false;
}
normalized = draft.Selection;
return true;
}
catch (Exception exception) when (exception is
ArgumentException or InvalidOperationException)
{
return false;
}
}
private static bool TryCreateNamedComparisonStocks(
IReadOnlyList<string> names,
string dataCode,
out LegacyComparisonTarget first,
out LegacyComparisonTarget second)
{
first = null!;
second = null!;
var identities = dataCode.Split('|', StringSplitOptions.None);
if (identities.Length != 2)
{
return false;
}
try
{
var targets = new LegacyComparisonTarget[2];
for (var index = 0; index < targets.Length; index++)
{
var separator = identities[index].IndexOf(':');
if (separator <= 0 ||
separator != identities[index].LastIndexOf(':') ||
separator == identities[index].Length - 1)
{
return false;
}
var market = identities[index][..separator] switch
{
"KOSPI" => LegacyComparisonDomesticMarket.Kospi,
"KOSDAQ" => LegacyComparisonDomesticMarket.Kosdaq,
_ => (LegacyComparisonDomesticMarket?)null
};
if (market is not { } exactMarket)
{
return false;
}
targets[index] = LegacyComparisonTarget.CreateKrxStock(
exactMarket,
names[index],
identities[index][(separator + 1)..]);
}
first = targets[0];
second = targets[1];
return true;
}
catch (ArgumentException)
{
return false;
}
}
private static bool TryCreateNamedManualFinancialSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.GroupCode is not
("KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ"))
{
return false;
}
var definitions = LegacyManualFinancialWorkflow.Screens
.Where(definition =>
string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) &&
string.Equals(
definition.CanonicalGraphicType,
selection.Subtype,
StringComparison.Ordinal))
.Take(2)
.ToArray();
if (definitions.Length != 1)
{
return false;
}
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
normalized = new CoreSceneSelection(
selection.GroupCode,
selection.Subject,
definitions[0].CanonicalGraphicType,
string.Empty,
string.Empty);
return true;
}
private static bool TryCreateNamedManualNetSellSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
if (selection.DataCode.Length != 0)
{
return false;
}
string? audience = null;
if (selection.GroupCode is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION" &&
string.Equals(selection.Subject, "INDEX", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "MANUAL_NET_SELL", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "MANUAL_NET_SELL", StringComparison.Ordinal))
{
audience = selection.GroupCode;
}
else if (string.Equals(selection.Subject, "지수", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
{
audience = selection.GroupCode switch
{
"개인 순매도 상위(수동)" => "INDIVIDUAL",
"외국인 순매도 상위(수동)" => "FOREIGN",
"기관 순매도 상위(수동)" => "INSTITUTION",
_ => null
};
}
else if (string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal))
{
audience = selection.Subject switch
{
"개인 순매도 상위(수동)" => "INDIVIDUAL",
"외국인 순매도 상위(수동)" => "FOREIGN",
"기관 순매도 상위(수동)" => "INSTITUTION",
_ => null
};
}
if (audience is null)
{
return false;
}
normalized = new CoreSceneSelection(
audience,
"INDEX",
"MANUAL_NET_SELL",
"MANUAL_NET_SELL",
string.Empty);
return true;
}
private static bool TryCreateNamedVersionedViSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
var canonical = string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) &&
string.Equals(
selection.Subject,
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
StringComparison.Ordinal) &&
string.Equals(selection.GraphicType, "INPUT_ORDER", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "CURRENT", StringComparison.Ordinal);
var savedVisibleFields = string.Equals(
selection.GroupCode,
"지수",
StringComparison.Ordinal) &&
selection.Subject.Length > 0 &&
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "VI 발동(수동)", StringComparison.Ordinal);
if ((!canonical && !savedVisibleFields) ||
!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.IsRowVersion(
selection.DataCode))
{
return false;
}
normalized = new CoreSceneSelection(
"PAGED_VI",
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel,
"INPUT_ORDER",
"CURRENT",
selection.DataCode);
return true;
}
private LegacyOperatorSnapshot CreateSnapshot()
{
var searchRows = _searchData.DisplayNames