feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacySearchTrigger
{
Button,
Enter
}
public enum LegacyOperatorStatusKind
{
Neutral,
Information,
Warning,
Error
}
public sealed record LegacyStockResultRow(int Index, string DisplayName);
public sealed record LegacyCutViewRow(
int PhysicalIndex,
int? DisplayOrdinal,
string RawLabel,
bool IsSeparator,
bool IsSelected);
public sealed record LegacyDialogState(string Message);
public sealed record LegacyOperatorSnapshot(
long Revision,
string SearchText,
IReadOnlyList<LegacyStockResultRow> SearchResults,
int? SelectedStockIndex,
IReadOnlyList<LegacyCutViewRow> CutRows,
IReadOnlyList<LegacyOperatorPlaylistRow> Playlist,
LegacyDialogState? Dialog,
string StatusMessage,
LegacyOperatorStatusKind StatusKind);
/// <summary>
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
/// input intent only; it never derives stock identity, cut aliases or playlist fields.
/// </summary>
public sealed class LegacyOperatorController
{
private const long DoubleClickWindowMilliseconds = 500;
private readonly ILegacyStockLookup _stockLookup;
private readonly SortedSet<int> _legacyPointerRows = [];
private readonly SortedSet<int> _nativeSelectedCutRows = [];
private readonly List<LegacyOperatorPlaylistRow> _playlist = [];
private Queue<int>? _pendingDropCutRows;
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
private int? _selectedStockIndex;
private int? _cutAnchorIndex;
private int _nativeSelectionAnchorIndex;
private int? _previousPointerRow;
private int _previousPointerX;
private int _previousPointerY;
private long? _previousPointerTimestamp;
private long _revision;
private long _playlistSequence;
private string _searchText = string.Empty;
private string _statusMessage = string.Empty;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
private LegacyDialogState? _dialog;
public LegacyOperatorController(ILegacyStockLookup stockLookup)
{
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
}
public LegacyOperatorSnapshot Current => CreateSnapshot();
public async Task<LegacyOperatorSnapshot> SearchStocksAsync(
string text,
LegacySearchTrigger trigger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(text);
_searchText = text;
// button9_Click returned before calling searchStock only for an exactly empty string.
// txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows.
if (trigger == LegacySearchTrigger.Button && text.Length == 0)
{
Touch();
return CreateSnapshot();
}
_searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false);
_selectedStockIndex = null;
_statusMessage = string.Empty;
_statusKind = LegacyOperatorStatusKind.Neutral;
_dialog = null;
_pendingDropCutRows = null;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SelectStock(int resultIndex)
{
if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count)
{
return CreateSnapshot();
}
_selectedStockIndex = resultIndex;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot CutPointerDown(
int physicalIndex,
bool control,
bool shift,
int x,
int y,
long timestampMilliseconds)
{
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
{
return CreateSnapshot();
}
var gap = _previousPointerTimestamp is long previousTimestamp
? timestampMilliseconds - previousTimestamp
: long.MaxValue;
if (_previousPointerTimestamp is not null &&
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
_previousPointerRow == physicalIndex &&
_previousPointerX == x &&
_previousPointerY == y &&
_legacyPointerRows.Count == 1)
{
TryAppendCut(_legacyPointerRows.Min);
_previousPointerTimestamp = null;
}
else
{
_previousPointerTimestamp = timestampMilliseconds;
}
_previousPointerRow = physicalIndex;
_previousPointerX = x;
_previousPointerY = y;
ApplyLegacyPointerSelection(physicalIndex, control, shift);
ApplyNativeListSelection(physicalIndex, control, shift);
_cutAnchorIndex = physicalIndex;
_nativeSelectionAnchorIndex = physicalIndex;
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist()
{
if (_selectedStockIndex is null || _dialog is not null)
{
return CreateSnapshot();
}
_pendingDropCutRows = new Queue<int>(_nativeSelectedCutRows);
ContinuePendingDrop();
Touch();
return CreateSnapshot();
}
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
{
ArgumentNullException.ThrowIfNull(rowId);
var index = _playlist.FindIndex(row =>
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
if (index >= 0)
{
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot DismissDialog()
{
if (_dialog is not null)
{
_dialog = null;
ContinuePendingDrop();
Touch();
}
return CreateSnapshot();
}
public LegacyOperatorSnapshot ReportError(string message)
{
_statusMessage = message ?? string.Empty;
_statusKind = LegacyOperatorStatusKind.Error;
Touch();
return CreateSnapshot();
}
private void ContinuePendingDrop()
{
while (_pendingDropCutRows is { Count: > 0 })
{
var physicalIndex = _pendingDropCutRows.Dequeue();
if (!TryAppendCut(physicalIndex))
{
return;
}
}
_pendingDropCutRows = null;
}
private bool TryAppendCut(int physicalIndex)
{
if (_selectedStockIndex is not int stockIndex ||
stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count ||
!IsCutIndex(physicalIndex))
{
return true;
}
var cut = LegacyStockCutCatalog.Rows[physicalIndex];
var stockName = _searchData.DisplayNames[stockIndex];
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
if (isNxt && !string.Equals(
cut.RawLabel,
LegacyStockCutCatalog.NxtAllowedLabel,
StringComparison.Ordinal))
{
_dialog = new LegacyDialogState("NXT는 제공하지 않습니다.");
return false;
}
if (cut.IsSeparator)
{
return true;
}
var identity = _searchData.ResolveLastByDisplayName(stockName);
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
_playlist.Add(LegacyStockCutCatalog.CreatePlaylistRow(
rowId,
stockName,
identity,
cut));
return true;
}
private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift)
{
if (control && shift)
{
AddLegacyRangeFromAnchor(physicalIndex);
}
else if (control)
{
if (!_legacyPointerRows.Add(physicalIndex))
{
_legacyPointerRows.Remove(physicalIndex);
}
}
else if (shift)
{
_legacyPointerRows.Clear();
AddLegacyRangeFromAnchor(physicalIndex);
}
else
{
_legacyPointerRows.Clear();
_legacyPointerRows.Add(physicalIndex);
}
}
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
{
if (control && shift)
{
AddInclusiveRange(
_nativeSelectedCutRows,
_nativeSelectionAnchorIndex,
physicalIndex);
}
else if (control)
{
if (!_nativeSelectedCutRows.Add(physicalIndex))
{
_nativeSelectedCutRows.Remove(physicalIndex);
}
}
else if (shift)
{
_nativeSelectedCutRows.Clear();
AddInclusiveRange(
_nativeSelectedCutRows,
_nativeSelectionAnchorIndex,
physicalIndex);
}
else
{
_nativeSelectedCutRows.Clear();
_nativeSelectedCutRows.Add(physicalIndex);
}
}
private void AddLegacyRangeFromAnchor(int physicalIndex)
{
// MainForm used beforeRow = -1 and skipped the range when the previous
// and current rows were equal. The sentinel also affected its double-click count.
var anchor = _cutAnchorIndex ?? -1;
if (anchor == physicalIndex)
{
return;
}
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
_legacyPointerRows.Add(index);
}
}
private static void AddInclusiveRange(
ISet<int> target,
int anchor,
int physicalIndex)
{
var first = Math.Min(anchor, physicalIndex);
var last = Math.Max(anchor, physicalIndex);
for (var index = first; index <= last; index++)
{
target.Add(index);
}
}
private LegacyOperatorSnapshot CreateSnapshot()
{
var searchRows = _searchData.DisplayNames
.Select((name, index) => new LegacyStockResultRow(index, name))
.ToArray();
var cutRows = LegacyStockCutCatalog.Rows
.Select(row => new LegacyCutViewRow(
row.PhysicalIndex,
row.DisplayOrdinal,
row.RawLabel,
row.IsSeparator,
_nativeSelectedCutRows.Contains(row.PhysicalIndex)))
.ToArray();
return new LegacyOperatorSnapshot(
_revision,
_searchText,
Array.AsReadOnly(searchRows),
_selectedStockIndex,
Array.AsReadOnly(cutRows),
Array.AsReadOnly(_playlist.ToArray()),
_dialog,
_statusMessage,
_statusKind);
}
private static bool IsCutIndex(int physicalIndex) =>
physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count;
private void Touch() => _revision++;
}

View File

@@ -0,0 +1,164 @@
using System.Data;
using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
/// <summary>
/// Compatibility search for the operator screen. Unlike the modern lookup it intentionally
/// keeps blank-enter, LIKE wildcard, provider ordering and unlimited-result semantics.
/// Values remain bound parameters so parity does not reintroduce SQL concatenation.
/// </summary>
public sealed class LegacyParityStockSearchService : ILegacyStockLookup
{
public const int LegacyTextBoxMaximumLength = 32_767;
private static readonly SearchProfile[] Profiles =
[
new(
LegacyDomesticStockMarket.Kospi,
DataSourceKind.Oracle,
"LEGACY_PARITY_KOSPI",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"),
new(
LegacyDomesticStockMarket.Kosdaq,
DataSourceKind.Oracle,
"LEGACY_PARITY_KOSDAQ",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"),
new(
LegacyDomesticStockMarket.NxtKospi,
DataSourceKind.MariaDb,
"LEGACY_PARITY_NXT_KOSPI",
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_STOCK a, N_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'"),
new(
LegacyDomesticStockMarket.NxtKosdaq,
DataSourceKind.MariaDb,
"LEGACY_PARITY_NXT_KOSDAQ",
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK a, N_KOSDAQ_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'")
];
private readonly IDataQueryExecutor _executor;
private readonly SemaphoreSlim _masterLoadGate = new(1, 1);
private IReadOnlyList<LegacyStockIdentity>? _masterCatalog;
public LegacyParityStockSearchService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public async Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
if (query.Length > LegacyTextBoxMaximumLength)
{
throw new ArgumentOutOfRangeException(
nameof(query),
$"The legacy search text cannot exceed {LegacyTextBoxMaximumLength} characters.");
}
var masterCatalog = await GetMasterCatalogAsync(cancellationToken).ConfigureAwait(false);
var searchTerm = query.ToUpper(CultureInfo.CurrentCulture);
var names = new List<string>();
foreach (var profile in Profiles)
{
cancellationToken.ThrowIfCancellationRequested();
var parameter = new DataQueryParameter("search_term", searchTerm, DbType.String);
var spec = new DataQuerySpec(profile.SearchSql, [parameter]);
var table = await _executor
.ExecuteAsync(profile.Source, profile.SearchTableName, spec, cancellationToken)
.ConfigureAwait(false);
AppendNames(table, profile.SearchTableName, names);
}
return new LegacyStockSearchData(query, names, masterCatalog);
}
private async Task<IReadOnlyList<LegacyStockIdentity>> GetMasterCatalogAsync(
CancellationToken cancellationToken)
{
var cached = Volatile.Read(ref _masterCatalog);
if (cached is not null)
{
return cached;
}
await _masterLoadGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cached = Volatile.Read(ref _masterCatalog);
if (cached is not null)
{
return cached;
}
var items = new List<LegacyStockIdentity>();
foreach (var profile in Profiles)
{
cancellationToken.ThrowIfCancellationRequested();
var table = await _executor
.ExecuteAsync(
profile.Source,
$"{profile.SearchTableName}_MASTER",
new DataQuerySpec(profile.MasterSql),
cancellationToken)
.ConfigureAwait(false);
AppendMaster(table, profile, items);
}
cached = Array.AsReadOnly(items.ToArray());
Volatile.Write(ref _masterCatalog, cached);
return cached;
}
finally
{
_masterLoadGate.Release();
}
}
private static void AppendNames(DataTable? table, string sourceName, ICollection<string> target)
{
ValidateTable(table, sourceName);
foreach (DataRow row in table!.Rows)
{
target.Add(Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty);
}
}
private static void AppendMaster(
DataTable? table,
SearchProfile profile,
ICollection<LegacyStockIdentity> target)
{
ValidateTable(table, $"{profile.SearchTableName}_MASTER");
foreach (DataRow row in table!.Rows)
{
target.Add(new LegacyStockIdentity(
profile.Market,
Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty,
Convert.ToString(row[1], CultureInfo.CurrentCulture) ?? string.Empty));
}
}
private static void ValidateTable(DataTable? table, string sourceName)
{
if (table is null || table.Columns.Count < 2)
{
throw new InvalidOperationException(
$"Legacy stock data from {sourceName} does not contain two columns.");
}
}
private sealed record SearchProfile(
LegacyDomesticStockMarket Market,
DataSourceKind Source,
string SearchTableName,
string SearchSql,
string MasterSql);
}

View File

@@ -0,0 +1,197 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public sealed record LegacyCutCatalogRow(
int PhysicalIndex,
int? DisplayOrdinal,
string RawLabel,
bool IsSeparator);
public sealed record LegacyOperatorPlaylistRow(
string RowId,
bool IsEnabled,
string MarketText,
string StockName,
string GraphicType,
string Subtype,
string PageText,
string StockCode,
string RawCutLabel,
int FadeDuration)
{
public LegacyPlaylistEntry ToPlayoutEntry()
{
var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
return new LegacyPlaylistEntry(
RowId,
cutCode,
IsEnabled,
FadeDuration,
new LegacySceneSelection(
MarketText,
StockName,
GraphicType,
Subtype,
StockCode));
}
}
public static class LegacyStockCutCatalog
{
public const string RuntimeAssetSha256 =
"45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20";
public const string NxtAllowedLabel = "1열판기본_현재가";
private static readonly string?[] RuntimeRows =
[
"1열판기본_예상체결가",
"1열판기본_현재가",
"1열판기본_시간외단일가",
null,
"1열판상세_시가",
"1열판상세_액면가",
"1열판상세_PBR",
"1열판상세_거래량",
null,
"수익률그래프_5일",
"수익률그래프_20일",
"수익률그래프_60일",
"수익률그래프_120일",
"수익률그래프_240일",
"캔들그래프_일봉",
"캔들그래프_5일",
"캔들그래프_20일",
"캔들그래프_60일",
"캔들그래프_120일",
"캔들그래프_240일",
"캔들그래프(거래량)_일봉",
"캔들그래프(거래량)_5일",
"캔들그래프(거래량)_20일",
"캔들그래프(거래량)_60일",
"캔들그래프(거래량)_120일",
"캔들그래프(거래량)_240일",
"캔들그래프(예상체결가)_5일",
"캔들그래프(예상체결가)_20일",
"캔들그래프(예상체결가)_60일",
"캔들그래프(예상체결가)_120일",
"캔들그래프(예상체결가)_240일",
"호가창_표그래프",
"거래원_표그래프"
];
public static IReadOnlyList<LegacyCutCatalogRow> Rows { get; } = CreateRows();
public static string ResolveCutAlias(string rawLabel, bool isNxt)
{
ArgumentException.ThrowIfNullOrEmpty(rawLabel);
if (isNxt)
{
if (!string.Equals(rawLabel, NxtAllowedLabel, StringComparison.Ordinal))
{
throw new InvalidOperationException("NXT는 1열판기본_현재가만 지원합니다.");
}
return "N5001";
}
if (rawLabel.StartsWith("1열판기본_", StringComparison.Ordinal))
{
return "5001";
}
if (string.Equals(rawLabel, "1열판상세_시가", StringComparison.Ordinal))
{
return "5006";
}
if (rawLabel.StartsWith("1열판상세_", StringComparison.Ordinal))
{
return "5011";
}
if (rawLabel.StartsWith("수익률그래프_", StringComparison.Ordinal))
{
return "5086";
}
if (rawLabel.StartsWith("캔들그래프", StringComparison.Ordinal))
{
var split = rawLabel.LastIndexOf('_');
var period = split >= 0 ? rawLabel[(split + 1)..] : string.Empty;
return period switch
{
"일봉" => "8035",
"5일" => "8061",
"20일" => "8040",
"60일" => "8046",
"120일" => "8051",
"240일" => "8056",
_ => throw UnknownCut(rawLabel)
};
}
return rawLabel switch
{
"호가창_표그래프" => "8003",
"거래원_표그래프" => "5037",
_ => throw UnknownCut(rawLabel)
};
}
public static LegacyOperatorPlaylistRow CreatePlaylistRow(
string rowId,
string stockName,
LegacyStockIdentity? identity,
LegacyCutCatalogRow cut,
int fadeDuration = 6)
{
ArgumentException.ThrowIfNullOrEmpty(rowId);
ArgumentNullException.ThrowIfNull(stockName);
ArgumentNullException.ThrowIfNull(cut);
if (cut.IsSeparator || string.IsNullOrEmpty(cut.RawLabel))
{
throw new ArgumentException("A separator cannot become a playlist row.", nameof(cut));
}
var split = cut.RawLabel.IndexOf('_');
if (split <= 0 || split == cut.RawLabel.Length - 1)
{
throw UnknownCut(cut.RawLabel);
}
return new LegacyOperatorPlaylistRow(
rowId,
true,
identity?.MarketText ?? string.Empty,
stockName,
cut.RawLabel[..split],
cut.RawLabel[(split + 1)..],
"1/1",
identity?.StockCode ?? string.Empty,
cut.RawLabel,
fadeDuration);
}
private static IReadOnlyList<LegacyCutCatalogRow> CreateRows()
{
var displayOrdinal = 0;
var rows = new LegacyCutCatalogRow[RuntimeRows.Length];
for (var index = 0; index < RuntimeRows.Length; index++)
{
var label = RuntimeRows[index];
var separator = label is null;
rows[index] = new LegacyCutCatalogRow(
index,
separator ? null : ++displayOrdinal,
label ?? string.Empty,
separator);
}
return Array.AsReadOnly(rows);
}
private static InvalidOperationException UnknownCut(string label) =>
new($"The legacy stock cut is unknown: {label}");
}

View File

@@ -0,0 +1,77 @@
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyDomesticStockMarket
{
Kospi,
Kosdaq,
NxtKospi,
NxtKosdaq
}
public sealed record LegacyStockIdentity(
LegacyDomesticStockMarket Market,
string DisplayName,
string StockCode)
{
public bool IsNxt => Market is
LegacyDomesticStockMarket.NxtKospi or LegacyDomesticStockMarket.NxtKosdaq;
public string MarketText => Market switch
{
LegacyDomesticStockMarket.Kospi => "코스피",
LegacyDomesticStockMarket.Kosdaq => "코스닥",
LegacyDomesticStockMarket.NxtKospi => "코스피_NXT",
LegacyDomesticStockMarket.NxtKosdaq => "코스닥_NXT",
_ => string.Empty
};
}
public sealed class LegacyStockSearchData
{
private readonly LegacyStockIdentity[] _masterCatalog;
public LegacyStockSearchData(
string query,
IEnumerable<string> displayNames,
IEnumerable<LegacyStockIdentity> masterCatalog)
{
Query = query ?? throw new ArgumentNullException(nameof(query));
ArgumentNullException.ThrowIfNull(displayNames);
ArgumentNullException.ThrowIfNull(masterCatalog);
DisplayNames = Array.AsReadOnly(displayNames.ToArray());
_masterCatalog = masterCatalog.ToArray();
MasterCatalog = Array.AsReadOnly(_masterCatalog);
}
public string Query { get; }
public IReadOnlyList<string> DisplayNames { get; }
public IReadOnlyList<LegacyStockIdentity> MasterCatalog { get; }
/// <summary>
/// MainForm.jongmokCode scanned all four cached master tables without breaking.
/// Keeping the final match therefore reproduces its duplicate-name behaviour.
/// </summary>
public LegacyStockIdentity? ResolveLastByDisplayName(string displayName)
{
LegacyStockIdentity? match = null;
foreach (var item in _masterCatalog)
{
if (string.Equals(item.DisplayName, displayName, StringComparison.Ordinal))
{
match = item;
}
}
return match;
}
}
public interface ILegacyStockLookup
{
Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyApplication</RootNamespace>
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyApplication</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
</Project>