feat: restore legacy named playlist workflows
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
|
||||
public enum LegacyNamedForeignStockRestoreFailure
|
||||
{
|
||||
InvalidRow,
|
||||
IdentityNotFound,
|
||||
IdentityAmbiguous,
|
||||
DatabaseDataInvalid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-sensitive reason token for read-only diagnostics. No database value is
|
||||
/// carried, so aggregate audits can distinguish schema, correlation and symbol
|
||||
/// contract failures without printing an input name or symbol.
|
||||
/// </summary>
|
||||
public enum LegacyNamedForeignStockDatabaseIssue
|
||||
{
|
||||
SchemaOrRowBound,
|
||||
ValueType,
|
||||
InputNameMismatch,
|
||||
InputNameInvalid,
|
||||
SymbolEmpty,
|
||||
SymbolTooLong,
|
||||
SymbolNotCanonical,
|
||||
SymbolDataDelimiter,
|
||||
SymbolCaret,
|
||||
SymbolUnsupportedSlash,
|
||||
SymbolUnsupportedPlus,
|
||||
SymbolUnsupportedAmpersand,
|
||||
SymbolUnsupportedApostrophe,
|
||||
SymbolUnsupportedComma,
|
||||
SymbolUnsupportedParenthesis,
|
||||
SymbolUnsupportedHash,
|
||||
SymbolUnsupportedPercent,
|
||||
SymbolUnsupportedAsterisk,
|
||||
SymbolUnsupportedEquals,
|
||||
SymbolUnsupportedBackslash,
|
||||
SymbolUnsupportedAsciiPunctuation,
|
||||
SymbolNonAsciiFullWidth,
|
||||
SymbolNonAsciiHangul,
|
||||
SymbolNonAsciiCjk,
|
||||
SymbolNonAsciiWhitespace,
|
||||
SymbolNonAsciiLetterOrNumber,
|
||||
SymbolNonAsciiPunctuation,
|
||||
SymbolNonAsciiOther,
|
||||
NationInvalid,
|
||||
ForeignDomesticInvalid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One untrusted seven-field PLAY_LIST row from the original UC5 overseas-stock
|
||||
/// selector. The row has an input name but did not persist its symbol or nation.
|
||||
/// </summary>
|
||||
public sealed record LegacyNamedForeignStockRestoreRow(
|
||||
int ItemIndex,
|
||||
bool IsEnabled,
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string PageText,
|
||||
string DataCode);
|
||||
|
||||
public sealed record LegacyNamedForeignStockRestoreIdentity(
|
||||
string ActionId,
|
||||
string InputName,
|
||||
string Symbol,
|
||||
string NationCode,
|
||||
string ForeignDomesticCode,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
string ValueType,
|
||||
int? PeriodDays);
|
||||
|
||||
public sealed record LegacyNamedForeignStockRestoreOutcome(
|
||||
int ItemIndex,
|
||||
LegacyNamedForeignStockRestoreIdentity? Identity,
|
||||
LegacyNamedForeignStockRestoreFailure? Failure,
|
||||
LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null)
|
||||
{
|
||||
public bool IsResolved =>
|
||||
Identity is not null && Failure is null && DatabaseIssue is null;
|
||||
}
|
||||
|
||||
public sealed record LegacyNamedForeignStockRestoreResult(
|
||||
DateTimeOffset RetrievedAt,
|
||||
IReadOnlyList<LegacyNamedForeignStockRestoreOutcome> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the original UC5 `해외종목` rows against today's Oracle master.
|
||||
/// Only an exact F_INPUT_NAME that identifies one distinct US/TW symbol is
|
||||
/// accepted. Queries are bound and read-only, and this service never retries.
|
||||
/// </summary>
|
||||
public sealed class LegacyNamedForeignStockRestoreService
|
||||
{
|
||||
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
|
||||
public const string QueryName = "LEGACY_NAMED_FOREIGN_STOCK_RESTORE";
|
||||
|
||||
private const string ExpectedGroupCode = "해외종목";
|
||||
private const string ExpectedPageText = "1/1";
|
||||
private const string ForeignDomesticCode = "1";
|
||||
private const string InputNameColumn = "F_INPUT_NAME";
|
||||
private const string SymbolColumn = "F_SYMB";
|
||||
private const string NationCodeColumn = "F_NATC";
|
||||
private const string ForeignDomesticColumn = "F_FDTC";
|
||||
private const int MaximumInputNameLength = 200;
|
||||
private const int MaximumSymbolLength = 64;
|
||||
|
||||
private const string IdentitySql = """
|
||||
SELECT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
|
||||
FROM (
|
||||
SELECT DISTINCT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
|
||||
FROM T_WORLD_IX_EQ_MASTER
|
||||
WHERE F_FDTC = '1'
|
||||
AND F_NATC IN ('US', 'TW')
|
||||
AND F_INPUT_NAME = :input_name
|
||||
ORDER BY F_SYMB, F_NATC
|
||||
)
|
||||
WHERE ROWNUM <= 2
|
||||
""";
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, ActionProfile> CandleActions =
|
||||
new Dictionary<string, ActionProfile>(StringComparer.Ordinal)
|
||||
{
|
||||
["5일"] = new("stock-candle-5d", "s8010", "8061", "PRICE", 5),
|
||||
["20일"] = new("stock-candle-20d", "s8010", "8040", "PRICE", 20),
|
||||
["60일"] = new("stock-candle-60d", "s8010", "8046", "PRICE", 60),
|
||||
["120일"] = new("stock-candle-120d", "s8010", "8051", "PRICE", 120),
|
||||
["240일"] = new("stock-candle-240d", "s8010", "8056", "PRICE", 240)
|
||||
};
|
||||
|
||||
private static readonly ActionProfile CurrentAction =
|
||||
new("stock-current", "s5001", "5001", "CURRENT", PeriodDays: null);
|
||||
|
||||
private readonly IDataQueryExecutor _executor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LegacyNamedForeignStockRestoreService(IDataQueryExecutor executor)
|
||||
: this(executor, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
internal LegacyNamedForeignStockRestoreService(
|
||||
IDataQueryExecutor executor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes only the exact legacy group and closed UC5 action grammar. The
|
||||
/// canonical FOREIGN_STOCK format is deliberately handled by its existing
|
||||
/// identity-bearing workflow and cannot collide with this restore pass.
|
||||
/// </summary>
|
||||
public static bool IsCandidate(LegacyNamedForeignStockRestoreRow? row) =>
|
||||
row is not null &&
|
||||
row.ItemIndex is >= 0 and < MaximumRows &&
|
||||
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) &&
|
||||
IsCanonicalText(row.Subject, MaximumInputNameLength, allowEmpty: false) &&
|
||||
TryGetAction(row.GraphicType, row.Subtype, out _) &&
|
||||
string.Equals(row.PageText, ExpectedPageText, StringComparison.Ordinal) &&
|
||||
string.Equals(row.DataCode, string.Empty, StringComparison.Ordinal);
|
||||
|
||||
public async Task<LegacyNamedForeignStockRestoreResult> ResolveAsync(
|
||||
IReadOnlyList<LegacyNamedForeignStockRestoreRow> rows,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
if (rows.Count is < 1 or > MaximumRows)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(rows),
|
||||
$"A named foreign-stock restore must contain 1 through {MaximumRows} rows.");
|
||||
}
|
||||
|
||||
var itemIndexes = new HashSet<int>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
|
||||
!itemIndexes.Add(row.ItemIndex))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Named foreign-stock restore rows must have unique bounded item indexes.",
|
||||
nameof(rows));
|
||||
}
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var identityCache = new Dictionary<string, IdentityResolution>(StringComparer.Ordinal);
|
||||
var outcomes = new List<LegacyNamedForeignStockRestoreOutcome>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
outcomes.Add(await ResolveRowAsync(row, identityCache, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return new LegacyNamedForeignStockRestoreResult(
|
||||
_timeProvider.GetLocalNow(),
|
||||
outcomes.AsReadOnly());
|
||||
}
|
||||
|
||||
private async Task<LegacyNamedForeignStockRestoreOutcome> ResolveRowAsync(
|
||||
LegacyNamedForeignStockRestoreRow row,
|
||||
IDictionary<string, IdentityResolution> identityCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsCandidate(row) ||
|
||||
!TryGetAction(row.GraphicType, row.Subtype, out var action))
|
||||
{
|
||||
return Failed(row.ItemIndex, LegacyNamedForeignStockRestoreFailure.InvalidRow);
|
||||
}
|
||||
|
||||
if (!identityCache.TryGetValue(row.Subject, out var resolution))
|
||||
{
|
||||
resolution = await ResolveIdentityAsync(row.Subject, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
identityCache.Add(row.Subject, resolution);
|
||||
}
|
||||
|
||||
if (resolution.Failure.HasValue)
|
||||
{
|
||||
return Failed(
|
||||
row.ItemIndex,
|
||||
resolution.Failure.Value,
|
||||
resolution.DatabaseIssue);
|
||||
}
|
||||
|
||||
var identity = resolution.Identity!;
|
||||
return new LegacyNamedForeignStockRestoreOutcome(
|
||||
row.ItemIndex,
|
||||
new LegacyNamedForeignStockRestoreIdentity(
|
||||
action.ActionId,
|
||||
identity.InputName,
|
||||
identity.Symbol,
|
||||
identity.NationCode,
|
||||
ForeignDomesticCode,
|
||||
action.BuilderKey,
|
||||
action.CutCode,
|
||||
action.ValueType,
|
||||
action.PeriodDays),
|
||||
Failure: null,
|
||||
DatabaseIssue: null);
|
||||
}
|
||||
|
||||
private async Task<IdentityResolution> ResolveIdentityAsync(
|
||||
string inputName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spec = new DataQuerySpec(
|
||||
IdentitySql,
|
||||
[new DataQueryParameter("input_name", inputName, DbType.String)]);
|
||||
spec.ValidateFor(DataSourceKind.Oracle);
|
||||
|
||||
var table = await _executor.ExecuteAsync(
|
||||
DataSourceKind.Oracle,
|
||||
QueryName,
|
||||
spec,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return ValidateIdentityTable(table, inputName);
|
||||
}
|
||||
|
||||
private static IdentityResolution ValidateIdentityTable(DataTable? table, string inputName)
|
||||
{
|
||||
if (table is null || table.Columns.Count != 4 || table.Rows.Count > 2 ||
|
||||
!HasExactStringColumn(table.Columns[0], InputNameColumn) ||
|
||||
!HasExactStringColumn(table.Columns[1], SymbolColumn) ||
|
||||
!HasExactStringColumn(table.Columns[2], NationCodeColumn) ||
|
||||
!HasExactStringColumn(table.Columns[3], ForeignDomesticColumn))
|
||||
{
|
||||
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound);
|
||||
}
|
||||
|
||||
var identities = new List<WorldStockSearchItem>(table.Rows.Count);
|
||||
foreach (DataRow row in table.Rows)
|
||||
{
|
||||
if (row[0] is not string foundInputName ||
|
||||
row[1] is not string symbol ||
|
||||
row[2] is not string nationCode ||
|
||||
row[3] is not string foreignDomestic)
|
||||
{
|
||||
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ValueType);
|
||||
}
|
||||
|
||||
if (!string.Equals(foundInputName, inputName, StringComparison.Ordinal))
|
||||
{
|
||||
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameMismatch);
|
||||
}
|
||||
|
||||
if (!IsCanonicalText(foundInputName, MaximumInputNameLength, allowEmpty: false))
|
||||
{
|
||||
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.InputNameInvalid);
|
||||
}
|
||||
|
||||
var symbolIssue = GetSymbolIssue(symbol);
|
||||
if (symbolIssue.HasValue)
|
||||
{
|
||||
return InvalidDatabaseData(symbolIssue.Value);
|
||||
}
|
||||
|
||||
if (nationCode is not ("US" or "TW"))
|
||||
{
|
||||
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.NationInvalid);
|
||||
}
|
||||
|
||||
if (!string.Equals(foreignDomestic, ForeignDomesticCode, StringComparison.Ordinal))
|
||||
{
|
||||
return InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.ForeignDomesticInvalid);
|
||||
}
|
||||
|
||||
identities.Add(new WorldStockSearchItem(foundInputName, symbol, nationCode));
|
||||
}
|
||||
|
||||
return identities.Count switch
|
||||
{
|
||||
0 => new IdentityResolution(
|
||||
Identity: null,
|
||||
LegacyNamedForeignStockRestoreFailure.IdentityNotFound),
|
||||
1 => new IdentityResolution(identities[0], Failure: null),
|
||||
2 => new IdentityResolution(
|
||||
Identity: null,
|
||||
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous),
|
||||
_ => InvalidDatabaseData(LegacyNamedForeignStockDatabaseIssue.SchemaOrRowBound)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetAction(
|
||||
string? graphicType,
|
||||
string? subtype,
|
||||
out ActionProfile action)
|
||||
{
|
||||
action = null!;
|
||||
if (string.Equals(graphicType, "1열판기본", StringComparison.Ordinal) &&
|
||||
string.Equals(subtype, string.Empty, StringComparison.Ordinal))
|
||||
{
|
||||
action = CurrentAction;
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(graphicType, "캔들그래프", StringComparison.Ordinal) &&
|
||||
subtype is not null &&
|
||||
CandleActions.TryGetValue(subtype, out action!);
|
||||
}
|
||||
|
||||
internal static bool IsCanonicalBoundSymbol(string? value) =>
|
||||
value is not null && GetSymbolIssue(value) is null;
|
||||
|
||||
private static LegacyNamedForeignStockDatabaseIssue? GetSymbolIssue(string value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolEmpty;
|
||||
}
|
||||
|
||||
if (value.Length > MaximumSymbolLength)
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolTooLong;
|
||||
}
|
||||
|
||||
if (!string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical;
|
||||
}
|
||||
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
if (rune.Value == '|')
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter;
|
||||
}
|
||||
|
||||
if (rune.Value == '^')
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolCaret;
|
||||
}
|
||||
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical;
|
||||
}
|
||||
|
||||
// Oracle currently contains a small set of provider symbols with
|
||||
// Hangul letters and internal ASCII spaces. They are safe identity
|
||||
// data: the runtime passes the symbol only as a bound parameter,
|
||||
// canonical validation rejects edge whitespace, and serialized
|
||||
// identity uses '|' (not a letter/number/space) as its delimiter.
|
||||
if (Rune.IsLetterOrDigit(rune))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rune.Value > 0x7F)
|
||||
{
|
||||
if (rune.Value is >= 0xFF01 and <= 0xFF5E)
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiFullWidth;
|
||||
}
|
||||
|
||||
if (rune.Value is >= 0xAC00 and <= 0xD7AF or
|
||||
>= 0x1100 and <= 0x11FF or
|
||||
>= 0x3130 and <= 0x318F)
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiHangul;
|
||||
}
|
||||
|
||||
if (rune.Value is >= 0x3400 and <= 0x9FFF or
|
||||
>= 0xF900 and <= 0xFAFF)
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiCjk;
|
||||
}
|
||||
|
||||
if (Rune.IsWhiteSpace(rune))
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiWhitespace;
|
||||
}
|
||||
|
||||
if (category is UnicodeCategory.ConnectorPunctuation or
|
||||
UnicodeCategory.DashPunctuation or
|
||||
UnicodeCategory.OpenPunctuation or
|
||||
UnicodeCategory.ClosePunctuation or
|
||||
UnicodeCategory.InitialQuotePunctuation or
|
||||
UnicodeCategory.FinalQuotePunctuation or
|
||||
UnicodeCategory.OtherPunctuation)
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiPunctuation;
|
||||
}
|
||||
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther;
|
||||
}
|
||||
|
||||
var character = (char)rune.Value;
|
||||
if (character == '/')
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedSlash;
|
||||
}
|
||||
|
||||
var punctuationIssue = character switch
|
||||
{
|
||||
'+' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPlus,
|
||||
'&' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAmpersand,
|
||||
'\'' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedApostrophe,
|
||||
',' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedComma,
|
||||
'(' or ')' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedParenthesis,
|
||||
'#' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedHash,
|
||||
'%' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPercent,
|
||||
'*' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsterisk,
|
||||
'=' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedEquals,
|
||||
'\\' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedBackslash,
|
||||
_ => (LegacyNamedForeignStockDatabaseIssue?)null
|
||||
};
|
||||
if (punctuationIssue.HasValue)
|
||||
{
|
||||
return punctuationIssue.Value;
|
||||
}
|
||||
|
||||
if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' '))
|
||||
{
|
||||
return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsCanonicalText(string? value, int maximumLength, bool allowEmpty)
|
||||
{
|
||||
if (value is null || value.Length > maximumLength ||
|
||||
(!allowEmpty && value.Length == 0) ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return !value.Contains('^');
|
||||
}
|
||||
|
||||
private static bool HasExactStringColumn(DataColumn column, string name) =>
|
||||
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
||||
column.DataType == typeof(string);
|
||||
|
||||
private static LegacyNamedForeignStockRestoreOutcome Failed(
|
||||
int itemIndex,
|
||||
LegacyNamedForeignStockRestoreFailure failure,
|
||||
LegacyNamedForeignStockDatabaseIssue? databaseIssue = null) =>
|
||||
new(itemIndex, Identity: null, failure, databaseIssue);
|
||||
|
||||
private static IdentityResolution InvalidDatabaseData(
|
||||
LegacyNamedForeignStockDatabaseIssue issue) =>
|
||||
new(
|
||||
Identity: null,
|
||||
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid,
|
||||
issue);
|
||||
|
||||
private sealed record ActionProfile(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
string ValueType,
|
||||
int? PeriodDays);
|
||||
|
||||
private sealed record IdentityResolution(
|
||||
WorldStockSearchItem? Identity,
|
||||
LegacyNamedForeignStockRestoreFailure? Failure,
|
||||
LegacyNamedForeignStockDatabaseIssue? DatabaseIssue = null);
|
||||
}
|
||||
487
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs
Normal file
487
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedKrxThemeRestore.cs
Normal file
@@ -0,0 +1,487 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
|
||||
public enum LegacyNamedKrxThemeRestoreFailure
|
||||
{
|
||||
InvalidRow,
|
||||
UnsupportedAction,
|
||||
IdentityNotFound,
|
||||
IdentityAmbiguous,
|
||||
PreviewEmpty,
|
||||
DatabaseDataInvalid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One untrusted seven-field PLAY_LIST row routed to the KRX-theme verifier.
|
||||
/// Every original field remains evidence; a successful restore uses the
|
||||
/// current Oracle identity for scene selection.
|
||||
/// </summary>
|
||||
public sealed record LegacyNamedKrxThemeRestoreRow(
|
||||
int ItemIndex,
|
||||
bool IsEnabled,
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string PageText,
|
||||
string DataCode);
|
||||
|
||||
/// <summary>
|
||||
/// A closed current KRX identity. StoredThemeCode is evidence only and must
|
||||
/// never be used by PREPARE after this identity has been resolved.
|
||||
/// </summary>
|
||||
public sealed record LegacyNamedKrxThemeRestoreIdentity(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
int PageSize,
|
||||
string Sort,
|
||||
string ThemeTitle,
|
||||
string StoredThemeCode,
|
||||
string CurrentThemeCode,
|
||||
int PreviewItemCount,
|
||||
bool PreviewIsTruncated)
|
||||
{
|
||||
public bool CodeWasRemapped =>
|
||||
!string.Equals(StoredThemeCode, CurrentThemeCode, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record LegacyNamedKrxThemeRestoreOutcome(
|
||||
int ItemIndex,
|
||||
LegacyNamedKrxThemeRestoreIdentity? Identity,
|
||||
LegacyNamedKrxThemeRestoreFailure? Failure,
|
||||
string? ObservedCurrentThemeCode = null)
|
||||
{
|
||||
public bool IsResolved => Identity is not null && Failure is null;
|
||||
}
|
||||
|
||||
public sealed record LegacyNamedKrxThemeRestoreResult(
|
||||
DateTimeOffset RetrievedAt,
|
||||
IReadOnlyList<LegacyNamedKrxThemeRestoreOutcome> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Rehydrates legacy `테마` rows through exact, bound Oracle reads. The old
|
||||
/// s5074/s5077/s5088 constructors looked the code up by exact title immediately
|
||||
/// before loading data. This service closes that lookup to one title and one
|
||||
/// code, validates the selected items through LegacyThemeSelectionService, and
|
||||
/// retains the stale stored code solely for lossless PLAY_LIST persistence.
|
||||
/// </summary>
|
||||
public sealed partial class LegacyNamedKrxThemeRestoreService
|
||||
{
|
||||
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
|
||||
public const string IdentityQueryName = "LEGACY_NAMED_KRX_THEME_IDENTITY";
|
||||
|
||||
private const string ExpectedGroupCode = "테마";
|
||||
private const int MaximumTitleLength = 128;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, SubtypeProfile> Subtypes =
|
||||
new Dictionary<string, SubtypeProfile>(StringComparer.Ordinal)
|
||||
{
|
||||
["테마-현재가(입력순)"] = new("CURRENT", "INPUT_ORDER"),
|
||||
["테마-현재가(상승률순)"] = new("CURRENT", "GAIN_DESC"),
|
||||
["테마-현재가(하락률순)"] = new("CURRENT", "GAIN_ASC"),
|
||||
// All three original scene constructors fall through to their
|
||||
// final loss-rate branch when no parenthesized token is present.
|
||||
["테마-현재가"] = new("CURRENT", "GAIN_ASC"),
|
||||
["테마-예상체결가(입력순)"] = new("EXPECTED", "INPUT_ORDER"),
|
||||
["테마-예상체결가(상승률순)"] = new("EXPECTED", "GAIN_DESC"),
|
||||
["테마-예상체결가(하락률순)"] = new("EXPECTED", "GAIN_ASC"),
|
||||
["테마-예상체결가"] = new("EXPECTED", "GAIN_ASC")
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, ActionProfile> Actions =
|
||||
new Dictionary<string, ActionProfile>(StringComparer.Ordinal)
|
||||
{
|
||||
[ActionKey("5단 표그래프", "CURRENT")] =
|
||||
new("five-row-current", "s5074", "5074", 5),
|
||||
[ActionKey("5단 표그래프", "EXPECTED")] =
|
||||
new("five-row-expected", "s5074", "5074", 5),
|
||||
[ActionKey("6종목 현재가", "CURRENT")] =
|
||||
new("six-row-current", "s5077", "5077", 6),
|
||||
[ActionKey("12종목 현재가", "CURRENT")] =
|
||||
new("twelve-row-current", "s5088", "5088", 12)
|
||||
};
|
||||
|
||||
private const string IdentitySql = """
|
||||
SELECT THEME_TITLE, THEME_CODE, THEME_MARKET, CODE_IDENTITY_COUNT
|
||||
FROM (
|
||||
SELECT s.SB_TITLE THEME_TITLE,
|
||||
s.SB_CODE THEME_CODE,
|
||||
s.SB_MARKET THEME_MARKET,
|
||||
TO_CHAR((SELECT COUNT(*)
|
||||
FROM SB_LIST d
|
||||
WHERE d.SB_MARKET = 'KRX'
|
||||
AND d.SB_CODE = s.SB_CODE), 'FM999999990')
|
||||
CODE_IDENTITY_COUNT
|
||||
FROM SB_LIST s
|
||||
WHERE s.SB_MARKET = 'KRX'
|
||||
AND s.SB_TITLE = :theme_title
|
||||
ORDER BY s.SB_CODE
|
||||
)
|
||||
WHERE ROWNUM <= 2
|
||||
""";
|
||||
|
||||
private static readonly string[] IdentityColumns =
|
||||
["THEME_TITLE", "THEME_CODE", "THEME_MARKET", "CODE_IDENTITY_COUNT"];
|
||||
|
||||
private readonly IDataQueryExecutor _executor;
|
||||
private readonly LegacyThemeSelectionService _themeSelectionService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LegacyNamedKrxThemeRestoreService(IDataQueryExecutor executor)
|
||||
: this(executor, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
internal LegacyNamedKrxThemeRestoreService(
|
||||
IDataQueryExecutor executor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
_themeSelectionService = new LegacyThemeSelectionService(_executor);
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes every exact `테마` group row, including malformed grammar, so
|
||||
/// native verification returns an explicit closed result rather than
|
||||
/// allowing the stale code through the generic mapper.
|
||||
/// </summary>
|
||||
public static bool IsCandidate(LegacyNamedKrxThemeRestoreRow? row) =>
|
||||
row is not null &&
|
||||
row.ItemIndex is >= 0 and < MaximumRows &&
|
||||
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal);
|
||||
|
||||
public async Task<LegacyNamedKrxThemeRestoreResult> ResolveAsync(
|
||||
IReadOnlyList<LegacyNamedKrxThemeRestoreRow> rows,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
if (rows.Count is < 1 or > MaximumRows)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(rows),
|
||||
$"A KRX-theme restore must contain 1 through {MaximumRows} rows.");
|
||||
}
|
||||
|
||||
var indexes = new HashSet<int>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
|
||||
!indexes.Add(row.ItemIndex))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"KRX-theme restore rows must have unique bounded item indexes.",
|
||||
nameof(rows));
|
||||
}
|
||||
}
|
||||
|
||||
var verifications = new Dictionary<string, ThemeVerification>(StringComparer.Ordinal);
|
||||
var outcomes = new List<LegacyNamedKrxThemeRestoreOutcome>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
outcomes.Add(await ResolveRowAsync(row, verifications, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return new LegacyNamedKrxThemeRestoreResult(
|
||||
_timeProvider.GetLocalNow(),
|
||||
outcomes.AsReadOnly());
|
||||
}
|
||||
|
||||
private async Task<LegacyNamedKrxThemeRestoreOutcome> ResolveRowAsync(
|
||||
LegacyNamedKrxThemeRestoreRow row,
|
||||
IDictionary<string, ThemeVerification> verifications,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var classification = Classify(row);
|
||||
if (classification.Failure.HasValue)
|
||||
{
|
||||
return Failed(row.ItemIndex, classification.Failure.Value);
|
||||
}
|
||||
|
||||
var title = classification.Title!;
|
||||
if (!verifications.TryGetValue(title, out var verification))
|
||||
{
|
||||
verification = await VerifyThemeAsync(title, cancellationToken).ConfigureAwait(false);
|
||||
verifications.Add(title, verification);
|
||||
}
|
||||
|
||||
if (verification.Failure.HasValue)
|
||||
{
|
||||
return Failed(
|
||||
row.ItemIndex,
|
||||
verification.Failure.Value,
|
||||
verification.CurrentThemeCode);
|
||||
}
|
||||
|
||||
var profile = classification.Action!;
|
||||
return new LegacyNamedKrxThemeRestoreOutcome(
|
||||
row.ItemIndex,
|
||||
new LegacyNamedKrxThemeRestoreIdentity(
|
||||
profile.ActionId,
|
||||
profile.BuilderKey,
|
||||
profile.CutCode,
|
||||
profile.PageSize,
|
||||
classification.Sort!,
|
||||
title,
|
||||
row.DataCode,
|
||||
verification.CurrentThemeCode!,
|
||||
verification.PreviewItemCount,
|
||||
verification.PreviewIsTruncated),
|
||||
Failure: null,
|
||||
ObservedCurrentThemeCode: verification.CurrentThemeCode);
|
||||
}
|
||||
|
||||
private static RowClassification Classify(LegacyNamedKrxThemeRestoreRow row)
|
||||
{
|
||||
if (!string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) ||
|
||||
!IsSafeCanonicalText(row.Subject, MaximumTitleLength) ||
|
||||
row.Subject.Contains("(NXT)", StringComparison.Ordinal) ||
|
||||
!StoredThemeCodePattern().IsMatch(row.DataCode ?? string.Empty) ||
|
||||
!IsValidStoredPage(row.PageText))
|
||||
{
|
||||
return new RowClassification(Failure: LegacyNamedKrxThemeRestoreFailure.InvalidRow);
|
||||
}
|
||||
|
||||
if (!Subtypes.TryGetValue(row.Subtype ?? string.Empty, out var subtype))
|
||||
{
|
||||
return new RowClassification(
|
||||
Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction);
|
||||
}
|
||||
|
||||
if (!Actions.TryGetValue(
|
||||
ActionKey(row.GraphicType ?? string.Empty, subtype.ValueKind),
|
||||
out var action))
|
||||
{
|
||||
return new RowClassification(
|
||||
Failure: LegacyNamedKrxThemeRestoreFailure.UnsupportedAction);
|
||||
}
|
||||
|
||||
return new RowClassification(row.Subject, action, subtype.Sort, Failure: null);
|
||||
}
|
||||
|
||||
private async Task<ThemeVerification> VerifyThemeAsync(
|
||||
string title,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spec = new DataQuerySpec(
|
||||
IdentitySql,
|
||||
[new DataQueryParameter("theme_title", title, DbType.String)]);
|
||||
spec.ValidateFor(DataSourceKind.Oracle);
|
||||
var table = await _executor.ExecuteAsync(
|
||||
DataSourceKind.Oracle,
|
||||
IdentityQueryName,
|
||||
spec,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var identity = ValidateIdentityTable(table, title);
|
||||
if (identity.Failure.HasValue)
|
||||
{
|
||||
return identity;
|
||||
}
|
||||
|
||||
var selectedIdentity = new ThemeSelectionIdentity(
|
||||
ThemeMarket.Krx,
|
||||
ThemeSession.NotApplicable,
|
||||
identity.CurrentThemeCode!,
|
||||
title);
|
||||
ThemePreviewResult preview;
|
||||
try
|
||||
{
|
||||
preview = await _themeSelectionService.PreviewAsync(
|
||||
selectedIdentity,
|
||||
LegacyThemeSelectionService.MaximumPreviewItems,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (ThemeSelectionDataException)
|
||||
{
|
||||
return InvalidDatabaseData(identity.CurrentThemeCode);
|
||||
}
|
||||
|
||||
if (!Equals(preview.Identity, selectedIdentity) ||
|
||||
preview.Items.Count > LegacyThemeSelectionService.MaximumPreviewItems ||
|
||||
preview.IsTruncated &&
|
||||
preview.Items.Count != LegacyThemeSelectionService.MaximumPreviewItems)
|
||||
{
|
||||
return InvalidDatabaseData(identity.CurrentThemeCode);
|
||||
}
|
||||
|
||||
if (preview.Items.Count == 0)
|
||||
{
|
||||
return identity with
|
||||
{
|
||||
Failure = LegacyNamedKrxThemeRestoreFailure.PreviewEmpty
|
||||
};
|
||||
}
|
||||
|
||||
return identity with
|
||||
{
|
||||
PreviewItemCount = preview.Items.Count,
|
||||
PreviewIsTruncated = preview.IsTruncated
|
||||
};
|
||||
}
|
||||
|
||||
private static ThemeVerification ValidateIdentityTable(DataTable? table, string title)
|
||||
{
|
||||
if (table is null || table.Columns.Count != IdentityColumns.Length ||
|
||||
table.Rows.Count > 2)
|
||||
{
|
||||
return InvalidDatabaseData();
|
||||
}
|
||||
|
||||
for (var index = 0; index < IdentityColumns.Length; index++)
|
||||
{
|
||||
if (!string.Equals(
|
||||
table.Columns[index].ColumnName,
|
||||
IdentityColumns[index],
|
||||
StringComparison.Ordinal) ||
|
||||
table.Columns[index].DataType != typeof(string))
|
||||
{
|
||||
return InvalidDatabaseData();
|
||||
}
|
||||
}
|
||||
|
||||
if (table.Rows.Count == 0)
|
||||
{
|
||||
return new ThemeVerification(
|
||||
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityNotFound);
|
||||
}
|
||||
|
||||
if (table.Rows.Count > 1)
|
||||
{
|
||||
return new ThemeVerification(
|
||||
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous);
|
||||
}
|
||||
|
||||
var row = table.Rows[0];
|
||||
if (row[0] is not string actualTitle ||
|
||||
row[1] is not string code ||
|
||||
row[2] is not string market ||
|
||||
!string.Equals(actualTitle, title, StringComparison.Ordinal) ||
|
||||
!string.Equals(market, "KRX", StringComparison.Ordinal) ||
|
||||
!StoredThemeCodePattern().IsMatch(code) ||
|
||||
!TryReadCount(row[3], out var codeIdentityCount))
|
||||
{
|
||||
return InvalidDatabaseData();
|
||||
}
|
||||
|
||||
if (codeIdentityCount != 1)
|
||||
{
|
||||
return new ThemeVerification(
|
||||
CurrentThemeCode: code,
|
||||
Failure: LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous);
|
||||
}
|
||||
|
||||
return new ThemeVerification(CurrentThemeCode: code, Failure: null);
|
||||
}
|
||||
|
||||
private static bool TryReadCount(object value, out int result)
|
||||
{
|
||||
result = 0;
|
||||
return value is string text &&
|
||||
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out result) &&
|
||||
result is >= 0 and <= 1_000_000 &&
|
||||
string.Equals(
|
||||
text,
|
||||
result.ToString(CultureInfo.InvariantCulture),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsValidStoredPage(string value)
|
||||
{
|
||||
var match = StoredPagePattern().Match(value ?? string.Empty);
|
||||
if (!match.Success ||
|
||||
!int.TryParse(
|
||||
match.Groups[1].Value,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var current) ||
|
||||
!int.TryParse(
|
||||
match.Groups[2].Value,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var total))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return current is >= 1 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
|
||||
total is >= 0 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
|
||||
(total == 0 ? current == 1 : current <= total);
|
||||
}
|
||||
|
||||
private static bool IsSafeCanonicalText(string? value, int maximumLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length > maximumLength ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
|
||||
value.Contains('^'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ActionKey(string graphicType, string valueKind) =>
|
||||
string.Concat(graphicType, "\u001F", valueKind);
|
||||
|
||||
private static LegacyNamedKrxThemeRestoreOutcome Failed(
|
||||
int itemIndex,
|
||||
LegacyNamedKrxThemeRestoreFailure failure,
|
||||
string? observedCurrentThemeCode = null) =>
|
||||
new(itemIndex, Identity: null, failure, observedCurrentThemeCode);
|
||||
|
||||
private static ThemeVerification InvalidDatabaseData(string? currentThemeCode = null) =>
|
||||
new(
|
||||
CurrentThemeCode: currentThemeCode,
|
||||
Failure: LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid);
|
||||
|
||||
private sealed record ActionProfile(
|
||||
string ActionId,
|
||||
string BuilderKey,
|
||||
string CutCode,
|
||||
int PageSize);
|
||||
|
||||
private sealed record SubtypeProfile(string ValueKind, string Sort);
|
||||
|
||||
private sealed record RowClassification(
|
||||
string? Title = null,
|
||||
ActionProfile? Action = null,
|
||||
string? Sort = null,
|
||||
LegacyNamedKrxThemeRestoreFailure? Failure = null);
|
||||
|
||||
private sealed record ThemeVerification(
|
||||
string? CurrentThemeCode = null,
|
||||
int PreviewItemCount = 0,
|
||||
bool PreviewIsTruncated = false,
|
||||
LegacyNamedKrxThemeRestoreFailure? Failure = null);
|
||||
|
||||
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex StoredThemeCodePattern();
|
||||
|
||||
[GeneratedRegex("^([1-9][0-9]{0,3})/([0-9]{1,4})$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex StoredPagePattern();
|
||||
}
|
||||
511
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs
Normal file
511
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedManualRestoreAudit.cs
Normal file
@@ -0,0 +1,511 @@
|
||||
#nullable enable
|
||||
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Closed kinds understood by the named-playlist manual restore workflow. The
|
||||
/// candidate deliberately has a non-data-bearing ToString implementation so an
|
||||
/// audit failure cannot accidentally print an operator stock name or VI list.
|
||||
/// </summary>
|
||||
public enum LegacyNamedManualRestoreKind
|
||||
{
|
||||
Financial,
|
||||
NetSell,
|
||||
Vi
|
||||
}
|
||||
|
||||
public enum LegacyNamedManualFreshProofFailure
|
||||
{
|
||||
None,
|
||||
FinancialSnapshotChanged,
|
||||
FinancialStockSearchTruncated,
|
||||
FinancialStockIdentityNotUnique,
|
||||
FinancialSelectionMismatch,
|
||||
NetSellSnapshotInvalid,
|
||||
ViSnapshotInvalid,
|
||||
ViSnapshotVersionChanged,
|
||||
ViHistoricalNamesChanged,
|
||||
ViPageCountChanged
|
||||
}
|
||||
|
||||
public sealed class LegacyNamedManualRestoreCandidate
|
||||
{
|
||||
internal LegacyNamedManualRestoreCandidate(
|
||||
int itemIndex,
|
||||
LegacyNamedManualRestoreKind kind,
|
||||
bool isHistorical,
|
||||
ManualFinancialScreenKind? financialScreen = null,
|
||||
StockMarket? market = null,
|
||||
string? stockName = null,
|
||||
S5025ManualAudience? audience = null,
|
||||
IReadOnlyList<string>? historicalViNames = null,
|
||||
string? expectedViVersion = null,
|
||||
int expectedPageCount = 1)
|
||||
{
|
||||
ItemIndex = itemIndex;
|
||||
Kind = kind;
|
||||
IsHistorical = isHistorical;
|
||||
FinancialScreen = financialScreen;
|
||||
Market = market;
|
||||
StockName = stockName;
|
||||
Audience = audience;
|
||||
HistoricalViNames = historicalViNames;
|
||||
ExpectedViVersion = expectedViVersion;
|
||||
ExpectedPageCount = expectedPageCount;
|
||||
}
|
||||
|
||||
public int ItemIndex { get; }
|
||||
|
||||
public LegacyNamedManualRestoreKind Kind { get; }
|
||||
|
||||
public bool IsHistorical { get; }
|
||||
|
||||
public ManualFinancialScreenKind? FinancialScreen { get; }
|
||||
|
||||
public StockMarket? Market { get; }
|
||||
|
||||
public string? StockName { get; }
|
||||
|
||||
public S5025ManualAudience? Audience { get; }
|
||||
|
||||
public IReadOnlyList<string>? HistoricalViNames { get; }
|
||||
|
||||
public string? ExpectedViVersion { get; }
|
||||
|
||||
public int ExpectedPageCount { get; }
|
||||
|
||||
public override string ToString() => $"Named manual restore candidate ({Kind})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C# equivalent of Web/named-manual-restore-workflow.js classify(). It accepts
|
||||
/// only the historical seven-field PList grammar and the canonical grammar
|
||||
/// emitted after a fresh materialization. It performs no I/O.
|
||||
/// </summary>
|
||||
public static class LegacyNamedManualRestoreClassifier
|
||||
{
|
||||
private const int ViPageSize = 5;
|
||||
private const int MaximumViItems = 100;
|
||||
|
||||
public static LegacyNamedManualRestoreCandidate? Classify(LegacyPListAuditRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
return ClassifyNetSell(row) ?? ClassifyVi(row) ?? ClassifyFinancial(row);
|
||||
}
|
||||
|
||||
private static LegacyNamedManualRestoreCandidate? ClassifyNetSell(
|
||||
LegacyPListAuditRow row)
|
||||
{
|
||||
if (row.PageTextForDisplay != "1/1" || row.DataCode.Length != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TryCanonicalAudience(row.GroupCode, out var canonical) &&
|
||||
row.Subject == "INDEX" &&
|
||||
row.GraphicType == "MANUAL_NET_SELL" &&
|
||||
row.Subtype == "MANUAL_NET_SELL")
|
||||
{
|
||||
return new LegacyNamedManualRestoreCandidate(
|
||||
row.ItemIndex,
|
||||
LegacyNamedManualRestoreKind.NetSell,
|
||||
isHistorical: false,
|
||||
audience: canonical);
|
||||
}
|
||||
|
||||
if (TryHistoricalAudience(row.GroupCode, out var historical) &&
|
||||
row.Subject == "지수" &&
|
||||
row.GraphicType == "순매도 상위" &&
|
||||
row.Subtype == "순매도 상위")
|
||||
{
|
||||
return new LegacyNamedManualRestoreCandidate(
|
||||
row.ItemIndex,
|
||||
LegacyNamedManualRestoreKind.NetSell,
|
||||
isHistorical: true,
|
||||
audience: historical);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LegacyNamedManualRestoreCandidate? ClassifyVi(
|
||||
LegacyPListAuditRow row)
|
||||
{
|
||||
if (!TryPageCount(row.PageTextForDisplay, out var pageCount))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.GroupCode == "PAGED_VI" &&
|
||||
row.Subject == TrustedViManualReference.SubjectSentinel &&
|
||||
row.GraphicType == "INPUT_ORDER" &&
|
||||
row.Subtype == "CURRENT" &&
|
||||
TrustedViManualReference.IsRowVersion(row.DataCode))
|
||||
{
|
||||
return new LegacyNamedManualRestoreCandidate(
|
||||
row.ItemIndex,
|
||||
LegacyNamedManualRestoreKind.Vi,
|
||||
isHistorical: false,
|
||||
expectedViVersion: row.DataCode,
|
||||
expectedPageCount: pageCount);
|
||||
}
|
||||
|
||||
if (row.GroupCode.Length != 0 ||
|
||||
row.GraphicType != "5단 표그래프" ||
|
||||
row.Subtype != "VI 발동(수동)" ||
|
||||
row.DataCode.Length != 0 ||
|
||||
!TryHistoricalViNames(row.Subject, out var names) ||
|
||||
pageCount != DivideRoundUp(names.Count, ViPageSize))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LegacyNamedManualRestoreCandidate(
|
||||
row.ItemIndex,
|
||||
LegacyNamedManualRestoreKind.Vi,
|
||||
isHistorical: true,
|
||||
historicalViNames: names,
|
||||
expectedPageCount: pageCount);
|
||||
}
|
||||
|
||||
private static LegacyNamedManualRestoreCandidate? ClassifyFinancial(
|
||||
LegacyPListAuditRow row)
|
||||
{
|
||||
if (row.PageTextForDisplay != "1/1" ||
|
||||
row.Subtype.Length != 0 ||
|
||||
row.DataCode.Length != 0 ||
|
||||
!IsTrimmedVisible(row.Subject, 200))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var canonicalMarket = TryCanonicalMarket(row.GroupCode, out var canonical);
|
||||
var historicalMarket = TryHistoricalMarket(row.GroupCode, out var historical);
|
||||
foreach (var contract in ManualFinancialCutContracts.All)
|
||||
{
|
||||
if (canonicalMarket && row.GraphicType == contract.CanonicalGraphicType)
|
||||
{
|
||||
return Financial(row, contract.Screen, canonical, isHistorical: false);
|
||||
}
|
||||
|
||||
if (historicalMarket && row.GraphicType == contract.OriginalGraphicType)
|
||||
{
|
||||
return Financial(row, contract.Screen, historical, isHistorical: true);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LegacyNamedManualRestoreCandidate Financial(
|
||||
LegacyPListAuditRow row,
|
||||
ManualFinancialScreenKind screen,
|
||||
StockMarket market,
|
||||
bool isHistorical) =>
|
||||
new(
|
||||
row.ItemIndex,
|
||||
LegacyNamedManualRestoreKind.Financial,
|
||||
isHistorical,
|
||||
financialScreen: screen,
|
||||
market: market,
|
||||
stockName: row.Subject);
|
||||
|
||||
private static bool TryCanonicalAudience(
|
||||
string value,
|
||||
out S5025ManualAudience audience)
|
||||
{
|
||||
audience = value switch
|
||||
{
|
||||
"INDIVIDUAL" => S5025ManualAudience.Individual,
|
||||
"FOREIGN" => S5025ManualAudience.Foreign,
|
||||
"INSTITUTION" => S5025ManualAudience.Institution,
|
||||
_ => default
|
||||
};
|
||||
return value is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION";
|
||||
}
|
||||
|
||||
private static bool TryHistoricalAudience(
|
||||
string value,
|
||||
out S5025ManualAudience audience)
|
||||
{
|
||||
audience = value switch
|
||||
{
|
||||
"개인 순매도 상위(수동)" => S5025ManualAudience.Individual,
|
||||
"외국인 순매도 상위(수동)" => S5025ManualAudience.Foreign,
|
||||
"기관 순매도 상위(수동)" => S5025ManualAudience.Institution,
|
||||
_ => default
|
||||
};
|
||||
return value is
|
||||
"개인 순매도 상위(수동)" or
|
||||
"외국인 순매도 상위(수동)" or
|
||||
"기관 순매도 상위(수동)";
|
||||
}
|
||||
|
||||
private static bool TryCanonicalMarket(string value, out StockMarket market)
|
||||
{
|
||||
market = value switch
|
||||
{
|
||||
"KOSPI" => StockMarket.Kospi,
|
||||
"KOSDAQ" => StockMarket.Kosdaq,
|
||||
"NXT_KOSPI" => StockMarket.NxtKospi,
|
||||
"NXT_KOSDAQ" => StockMarket.NxtKosdaq,
|
||||
_ => default
|
||||
};
|
||||
return value is "KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ";
|
||||
}
|
||||
|
||||
private static bool TryHistoricalMarket(string value, out StockMarket market)
|
||||
{
|
||||
market = value switch
|
||||
{
|
||||
"코스피" => StockMarket.Kospi,
|
||||
"코스닥" => StockMarket.Kosdaq,
|
||||
"코스피_NXT" => StockMarket.NxtKospi,
|
||||
"코스닥_NXT" => StockMarket.NxtKosdaq,
|
||||
_ => default
|
||||
};
|
||||
return value is "코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT";
|
||||
}
|
||||
|
||||
private static bool TryPageCount(string value, out int pageCount)
|
||||
{
|
||||
pageCount = 0;
|
||||
if (!value.StartsWith("1/", StringComparison.Ordinal) ||
|
||||
!int.TryParse(value.AsSpan(2), out var parsed) ||
|
||||
parsed is < 1 or > 20 ||
|
||||
value != $"1/{parsed}")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pageCount = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryHistoricalViNames(
|
||||
string value,
|
||||
out IReadOnlyList<string> names)
|
||||
{
|
||||
names = Array.Empty<string>();
|
||||
if (!IsTrimmedVisible(value, LegacyPListReadAuditParser.MaximumListTextLength))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var values = value.Split(',', StringSplitOptions.None);
|
||||
if (values.Length is < 1 or > MaximumViItems ||
|
||||
values.Any(name => !IsTrimmedVisible(name, 200)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
names = Array.AsReadOnly(values);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsTrimmedVisible(string value, int maximumLength) =>
|
||||
value.Length is > 0 &&
|
||||
value.Length <= maximumLength &&
|
||||
value == value.Trim() &&
|
||||
!value.Any(char.IsControl) &&
|
||||
!value.Contains('\uFFFD');
|
||||
|
||||
private static int DivideRoundUp(int value, int divisor) =>
|
||||
checked((value + divisor - 1) / divisor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure checks for the evidence gathered through production read boundaries.
|
||||
/// No method opens a database or file and no result contains source text.
|
||||
/// </summary>
|
||||
public static class LegacyNamedManualFreshProof
|
||||
{
|
||||
private const int ViPageSize = 5;
|
||||
|
||||
public static string FinancialStockSearchQuery(
|
||||
LegacyNamedManualRestoreCandidate candidate)
|
||||
{
|
||||
RequireKind(candidate, LegacyNamedManualRestoreKind.Financial);
|
||||
var stockName = candidate.StockName!;
|
||||
if (candidate.Market is not (StockMarket.NxtKospi or StockMarket.NxtKosdaq))
|
||||
{
|
||||
return stockName;
|
||||
}
|
||||
|
||||
const string suffix = "(NXT)";
|
||||
if (!stockName.EndsWith(suffix, StringComparison.Ordinal) ||
|
||||
stockName.Length == suffix.Length)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"An NXT manual-financial identity requires its original display suffix.",
|
||||
nameof(candidate));
|
||||
}
|
||||
|
||||
var query = stockName[..^suffix.Length];
|
||||
if (query.Length == 0 || query != query.Trim() || query.Contains(suffix))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"An NXT manual-financial identity has an invalid search identity.",
|
||||
nameof(candidate));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
public static LegacyNamedManualFreshProofFailure EvaluateFinancial(
|
||||
LegacyNamedManualRestoreCandidate candidate,
|
||||
ManualFinancialSnapshot firstRead,
|
||||
ManualFinancialSnapshot secondRead,
|
||||
StockSearchResult stockSearch)
|
||||
{
|
||||
RequireKind(candidate, LegacyNamedManualRestoreKind.Financial);
|
||||
ArgumentNullException.ThrowIfNull(firstRead);
|
||||
ArgumentNullException.ThrowIfNull(secondRead);
|
||||
ArgumentNullException.ThrowIfNull(stockSearch);
|
||||
|
||||
var identity = new ManualFinancialIdentity(
|
||||
candidate.FinancialScreen!.Value,
|
||||
candidate.StockName!);
|
||||
if (firstRead.Record.Identity != identity ||
|
||||
secondRead.Record.Identity != identity ||
|
||||
!string.Equals(firstRead.RowVersion, secondRead.RowVersion, StringComparison.Ordinal))
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged;
|
||||
}
|
||||
|
||||
if (stockSearch.IsTruncated ||
|
||||
!string.Equals(
|
||||
stockSearch.Query,
|
||||
FinancialStockSearchQuery(candidate),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.FinancialStockSearchTruncated;
|
||||
}
|
||||
|
||||
var matches = stockSearch.Items
|
||||
.Where(item =>
|
||||
item.Market == candidate.Market &&
|
||||
string.Equals(item.Name, candidate.StockName, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
if (matches.Length != 1)
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verified = ManualFinancialCutContracts.VerifyStock(
|
||||
identity,
|
||||
stockSearch.Items,
|
||||
candidate.Market!.Value,
|
||||
matches[0].Code);
|
||||
var selection = ManualFinancialCutContracts.CreateSelection(secondRead, verified);
|
||||
var contract = ManualFinancialCutContracts.Get(identity.Screen);
|
||||
if (selection.BuilderKey != contract.BuilderKey ||
|
||||
selection.CutCode != contract.CutCode ||
|
||||
selection.CurrentPage != 1 ||
|
||||
selection.TotalPages != 1 ||
|
||||
selection.Selection.GroupCode != CanonicalMarket(candidate.Market.Value) ||
|
||||
selection.Selection.Subject != candidate.StockName ||
|
||||
selection.Selection.GraphicType != contract.CanonicalGraphicType ||
|
||||
selection.Selection.Subtype.Length != 0 ||
|
||||
selection.Selection.DataCode.Length != 0)
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.FinancialSelectionMismatch;
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique;
|
||||
}
|
||||
|
||||
return LegacyNamedManualFreshProofFailure.None;
|
||||
}
|
||||
|
||||
public static LegacyNamedManualFreshProofFailure EvaluateNetSell(
|
||||
LegacyNamedManualRestoreCandidate candidate,
|
||||
IReadOnlyList<S5025TrustedManualRow> rows)
|
||||
{
|
||||
RequireKind(candidate, LegacyNamedManualRestoreKind.NetSell);
|
||||
if (rows is null || rows.Count != 5 || rows.Any(row =>
|
||||
row is null ||
|
||||
!ValidManualCell(row.LeftName) ||
|
||||
!ValidManualCell(row.LeftAmount) ||
|
||||
!ValidManualCell(row.RightName) ||
|
||||
!ValidManualCell(row.RightAmount)))
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid;
|
||||
}
|
||||
|
||||
return LegacyNamedManualFreshProofFailure.None;
|
||||
}
|
||||
|
||||
public static LegacyNamedManualFreshProofFailure EvaluateVi(
|
||||
LegacyNamedManualRestoreCandidate candidate,
|
||||
TrustedViStockNameSnapshot snapshot)
|
||||
{
|
||||
RequireKind(candidate, LegacyNamedManualRestoreKind.Vi);
|
||||
if (snapshot is null ||
|
||||
!TrustedViManualReference.IsRowVersion(snapshot.RowVersion) ||
|
||||
snapshot.StockNames is null ||
|
||||
snapshot.StockNames.Count is < 1 or > TrustedViManualReference.MaximumItemCount ||
|
||||
snapshot.StockNames.Any(name =>
|
||||
name is null ||
|
||||
name.Length is < 1 or > 200 ||
|
||||
name != name.Trim() ||
|
||||
name.Contains(',') ||
|
||||
name.Any(char.IsControl)))
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.ViSnapshotInvalid;
|
||||
}
|
||||
|
||||
if (candidate.ExpectedViVersion is not null &&
|
||||
!string.Equals(
|
||||
candidate.ExpectedViVersion,
|
||||
snapshot.RowVersion,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.ViSnapshotVersionChanged;
|
||||
}
|
||||
|
||||
if (candidate.HistoricalViNames is not null &&
|
||||
!candidate.HistoricalViNames.SequenceEqual(
|
||||
snapshot.StockNames,
|
||||
StringComparer.Ordinal))
|
||||
{
|
||||
return LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged;
|
||||
}
|
||||
|
||||
var pageCount = checked((snapshot.StockNames.Count + ViPageSize - 1) / ViPageSize);
|
||||
return pageCount == candidate.ExpectedPageCount
|
||||
? LegacyNamedManualFreshProofFailure.None
|
||||
: LegacyNamedManualFreshProofFailure.ViPageCountChanged;
|
||||
}
|
||||
|
||||
private static void RequireKind(
|
||||
LegacyNamedManualRestoreCandidate candidate,
|
||||
LegacyNamedManualRestoreKind expected)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidate);
|
||||
if (candidate.Kind != expected)
|
||||
{
|
||||
throw new ArgumentException("The manual restore candidate kind is invalid.", nameof(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidManualCell(string value) =>
|
||||
value is not null &&
|
||||
value.Length <= 256 &&
|
||||
!value.Any(char.IsControl);
|
||||
|
||||
private static string CanonicalMarket(StockMarket market) => market switch
|
||||
{
|
||||
StockMarket.Kospi => "KOSPI",
|
||||
StockMarket.Kosdaq => "KOSDAQ",
|
||||
StockMarket.NxtKospi => "NXT_KOSPI",
|
||||
StockMarket.NxtKosdaq => "NXT_KOSDAQ",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(market))
|
||||
};
|
||||
}
|
||||
341
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs
Normal file
341
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNxtThemeJoinAudit.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A closed pair of current and persisted NXT theme codes used only by the
|
||||
/// read-only restore diagnostic. Titles and stock identities never cross this
|
||||
/// boundary.
|
||||
/// </summary>
|
||||
public sealed record LegacyNxtThemeJoinAuditIdentity(
|
||||
string CurrentThemeCode,
|
||||
string StoredThemeCode);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate evidence for one current theme code. No theme or stock identifier
|
||||
/// is returned, so callers can safely log only cardinalities.
|
||||
/// </summary>
|
||||
public sealed record LegacyNxtThemeJoinAuditEvidence(
|
||||
int InputIndex,
|
||||
int RawCurrentItemCount,
|
||||
int RawStoredItemCount,
|
||||
int CurrentOriginalJoinCount,
|
||||
int CurrentNonStoppedJoinCount,
|
||||
int CurrentLiveJoinCount,
|
||||
int StoredOriginalJoinCount,
|
||||
int StoredNonStoppedJoinCount,
|
||||
int StoredLiveJoinCount,
|
||||
int CurrentExactJoinCount,
|
||||
int CurrentStripBothJoinCount,
|
||||
int CurrentRightSixJoinCount,
|
||||
int CurrentValidItemCodeCount,
|
||||
int CurrentKospiMasterJoinCount,
|
||||
int CurrentKosdaqMasterJoinCount,
|
||||
int CurrentKospiOnlineJoinCount,
|
||||
int CurrentKosdaqOnlineJoinCount);
|
||||
|
||||
public sealed record LegacyNxtThemeJoinAuditResult(
|
||||
DateTimeOffset RetrievedAt,
|
||||
IReadOnlyList<LegacyNxtThemeJoinAuditEvidence> Themes);
|
||||
|
||||
/// <summary>
|
||||
/// Executes only bounded aggregate MariaDB reads. The original s5074, s5077,
|
||||
/// and s5088 NXT loaders all use SUBSTRING(SB_I_CODE, 2, 12) = F_STOCK_CODE;
|
||||
/// the other joins are diagnostic evidence only and are never used to resolve
|
||||
/// or play a scene.
|
||||
/// </summary>
|
||||
public sealed partial class LegacyNxtThemeJoinAuditService
|
||||
{
|
||||
public const int MaximumThemes = 100;
|
||||
public const string QueryName = "LEGACY_NXT_THEME_JOIN_AUDIT";
|
||||
|
||||
private static readonly string[] Columns =
|
||||
[
|
||||
"RAW_CURRENT_ITEM_COUNT",
|
||||
"RAW_STORED_ITEM_COUNT",
|
||||
"CURRENT_ORIGINAL_JOIN_COUNT",
|
||||
"CURRENT_NON_STOPPED_JOIN_COUNT",
|
||||
"CURRENT_LIVE_JOIN_COUNT",
|
||||
"STORED_ORIGINAL_JOIN_COUNT",
|
||||
"STORED_NON_STOPPED_JOIN_COUNT",
|
||||
"STORED_LIVE_JOIN_COUNT",
|
||||
"CURRENT_EXACT_JOIN_COUNT",
|
||||
"CURRENT_STRIP_BOTH_JOIN_COUNT",
|
||||
"CURRENT_RIGHT_SIX_JOIN_COUNT",
|
||||
"CURRENT_VALID_ITEM_CODE_COUNT",
|
||||
"CURRENT_KOSPI_MASTER_JOIN_COUNT",
|
||||
"CURRENT_KOSDAQ_MASTER_JOIN_COUNT",
|
||||
"CURRENT_KOSPI_ONLINE_JOIN_COUNT",
|
||||
"CURRENT_KOSDAQ_ONLINE_JOIN_COUNT"
|
||||
];
|
||||
|
||||
private const string Sql = """
|
||||
SELECT
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||
RAW_CURRENT_ITEM_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR)
|
||||
RAW_STORED_ITEM_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||
CURRENT_ORIGINAL_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||
AND q.F_STOP_GUBUN = 'N') AS CHAR)
|
||||
CURRENT_NON_STOPPED_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||
AND q.F_STOP_GUBUN = 'N'
|
||||
AND q.F_CURR_PRICE != 0) AS CHAR)
|
||||
CURRENT_LIVE_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE) AS CHAR)
|
||||
STORED_ORIGINAL_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE
|
||||
AND q.F_STOP_GUBUN = 'N') AS CHAR)
|
||||
STORED_NON_STOPPED_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.STORED_THEME_CODE
|
||||
AND q.F_STOP_GUBUN = 'N'
|
||||
AND q.F_CURR_PRICE != 0) AS CHAR)
|
||||
STORED_LIVE_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON i.SB_I_CODE = q.F_STOCK_CODE
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||
CURRENT_EXACT_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON SUBSTRING(i.SB_I_CODE, 2, 12) =
|
||||
SUBSTRING(q.F_STOCK_CODE, 2, 12)
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||
CURRENT_STRIP_BOTH_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
JOIN v_all_stock q
|
||||
ON RIGHT(i.SB_I_CODE, 6) = RIGHT(q.F_STOCK_CODE, 6)
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE) AS CHAR)
|
||||
CURRENT_RIGHT_SIX_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||
AND i.SB_I_CODE REGEXP BINARY '^[XT][A-Za-z0-9]{1,12}$') AS CHAR)
|
||||
CURRENT_VALID_ITEM_CODE_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM N_STOCK q
|
||||
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
)) AS CHAR)
|
||||
CURRENT_KOSPI_MASTER_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM N_KOSDAQ_STOCK q
|
||||
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
)) AS CHAR)
|
||||
CURRENT_KOSDAQ_MASTER_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM N_ONLINE q
|
||||
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
)) AS CHAR)
|
||||
CURRENT_KOSPI_ONLINE_JOIN_COUNT,
|
||||
CAST((SELECT COUNT(*)
|
||||
FROM SB_ITEM i
|
||||
WHERE BINARY i.SB_M_CODE = BINARY p.CURRENT_THEME_CODE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM N_KOSDAQ_ONLINE q
|
||||
WHERE SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
|
||||
)) AS CHAR)
|
||||
CURRENT_KOSDAQ_ONLINE_JOIN_COUNT
|
||||
FROM (
|
||||
SELECT @current_theme_code CURRENT_THEME_CODE,
|
||||
@stored_theme_code STORED_THEME_CODE
|
||||
) p
|
||||
""";
|
||||
|
||||
private readonly IDataQueryExecutor _executor;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LegacyNxtThemeJoinAuditService(IDataQueryExecutor executor)
|
||||
: this(executor, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
internal LegacyNxtThemeJoinAuditService(
|
||||
IDataQueryExecutor executor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<LegacyNxtThemeJoinAuditResult> AuditAsync(
|
||||
IReadOnlyList<LegacyNxtThemeJoinAuditIdentity> identities,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(identities);
|
||||
if (identities.Count is < 1 or > MaximumThemes)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(identities),
|
||||
$"A join audit must contain 1 through {MaximumThemes} themes.");
|
||||
}
|
||||
|
||||
var currentCodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var identity in identities)
|
||||
{
|
||||
var current = identity?.CurrentThemeCode;
|
||||
var stored = identity?.StoredThemeCode;
|
||||
if (!ThemeCodePattern().IsMatch(current ?? string.Empty) ||
|
||||
!ThemeCodePattern().IsMatch(stored ?? string.Empty) ||
|
||||
!currentCodes.Add(current!))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"NXT join audit identities must have unique canonical current codes.",
|
||||
nameof(identities));
|
||||
}
|
||||
}
|
||||
|
||||
var evidence = new List<LegacyNxtThemeJoinAuditEvidence>(identities.Count);
|
||||
for (var index = 0; index < identities.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var identity = identities[index];
|
||||
var spec = new DataQuerySpec(
|
||||
Sql,
|
||||
[
|
||||
new DataQueryParameter(
|
||||
"current_theme_code",
|
||||
identity.CurrentThemeCode,
|
||||
DbType.String),
|
||||
new DataQueryParameter(
|
||||
"stored_theme_code",
|
||||
identity.StoredThemeCode,
|
||||
DbType.String)
|
||||
]);
|
||||
spec.ValidateFor(DataSourceKind.MariaDb);
|
||||
var table = await _executor.ExecuteAsync(
|
||||
DataSourceKind.MariaDb,
|
||||
QueryName,
|
||||
spec,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
evidence.Add(MapEvidence(table, index));
|
||||
}
|
||||
|
||||
return new LegacyNxtThemeJoinAuditResult(
|
||||
_timeProvider.GetLocalNow(),
|
||||
evidence.AsReadOnly());
|
||||
}
|
||||
|
||||
private static LegacyNxtThemeJoinAuditEvidence MapEvidence(
|
||||
DataTable? table,
|
||||
int inputIndex)
|
||||
{
|
||||
if (table is null || table.Columns.Count != Columns.Length || table.Rows.Count != 1)
|
||||
{
|
||||
throw InvalidData();
|
||||
}
|
||||
|
||||
for (var index = 0; index < Columns.Length; index++)
|
||||
{
|
||||
if (!string.Equals(
|
||||
table.Columns[index].ColumnName,
|
||||
Columns[index],
|
||||
StringComparison.Ordinal) ||
|
||||
table.Columns[index].DataType != typeof(string))
|
||||
{
|
||||
throw InvalidData();
|
||||
}
|
||||
}
|
||||
|
||||
var counts = table.Rows[0].ItemArray.Select(ReadCount).ToArray();
|
||||
if (counts[2] > counts[0] || counts[3] > counts[2] || counts[4] > counts[3] ||
|
||||
counts[5] > counts[1] || counts[6] > counts[5] || counts[7] > counts[6] ||
|
||||
counts[8] > counts[0] || counts[9] > counts[0] || counts[10] > counts[0] ||
|
||||
counts[11] > counts[0] || counts[12] > counts[0] || counts[13] > counts[0] ||
|
||||
counts[14] > counts[0] || counts[15] > counts[0] ||
|
||||
counts[12] + counts[13] > counts[0] ||
|
||||
counts[14] + counts[15] > counts[0])
|
||||
{
|
||||
throw InvalidData();
|
||||
}
|
||||
|
||||
return new LegacyNxtThemeJoinAuditEvidence(
|
||||
inputIndex,
|
||||
counts[0],
|
||||
counts[1],
|
||||
counts[2],
|
||||
counts[3],
|
||||
counts[4],
|
||||
counts[5],
|
||||
counts[6],
|
||||
counts[7],
|
||||
counts[8],
|
||||
counts[9],
|
||||
counts[10],
|
||||
counts[11],
|
||||
counts[12],
|
||||
counts[13],
|
||||
counts[14],
|
||||
counts[15]);
|
||||
}
|
||||
|
||||
private static int ReadCount(object? value)
|
||||
{
|
||||
if (value is not string text ||
|
||||
!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var count) ||
|
||||
count is < 0 or > 1_000_000 ||
|
||||
!string.Equals(text, count.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
|
||||
{
|
||||
throw InvalidData();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static InvalidDataException InvalidData() =>
|
||||
new("The NXT theme join audit returned invalid aggregate data.");
|
||||
|
||||
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ThemeCodePattern();
|
||||
}
|
||||
524
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs
Normal file
524
src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs
Normal file
@@ -0,0 +1,524 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The exact seven values written by the original PList.data_save method.
|
||||
/// RawListText and Fields are retained so a read-only audit can prove that
|
||||
/// parsing did not normalize any operator text. PageTextForDisplay is the one
|
||||
/// original load-time transformation: ASCII spaces are removed from field 5.
|
||||
/// </summary>
|
||||
public sealed record LegacyPListAuditRow(
|
||||
string ProgramCode,
|
||||
string ProgramTitle,
|
||||
int ItemIndex,
|
||||
bool IsEnabled,
|
||||
IReadOnlyList<string> Fields,
|
||||
string RawListText,
|
||||
string PageTextForDisplay)
|
||||
{
|
||||
public string GroupCode => Fields[1];
|
||||
|
||||
public string Subject => Fields[2];
|
||||
|
||||
public string GraphicType => Fields[3];
|
||||
|
||||
public string Subtype => Fields[4];
|
||||
|
||||
public string RawPageText => Fields[5];
|
||||
|
||||
public string DataCode => Fields[6];
|
||||
|
||||
public bool IsLossless =>
|
||||
string.Equals(string.Join('^', Fields), RawListText, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public enum LegacyPListAuditParseFailure
|
||||
{
|
||||
InvalidProgramCode,
|
||||
InvalidProgramTitle,
|
||||
InvalidItemIndex,
|
||||
InvalidListText,
|
||||
InvalidFieldCount,
|
||||
InvalidEnabledField,
|
||||
UnsafeText,
|
||||
ReplacementCharacter,
|
||||
MojibakeSignal
|
||||
}
|
||||
|
||||
public enum LegacyPListAuditRoute
|
||||
{
|
||||
ExistingClosedMapper,
|
||||
IndustryFixedClosedMapper,
|
||||
ComparisonFreshIdentity,
|
||||
ForeignIndexCandleFreshIdentity,
|
||||
NxtThemeFreshIdentity,
|
||||
KrxThemeFreshIdentity,
|
||||
ForeignStockFreshIdentity,
|
||||
ManualFreshMaterialization,
|
||||
Unmapped
|
||||
}
|
||||
|
||||
public sealed record LegacyPListAuditParseResult(
|
||||
LegacyPListAuditRow? Row,
|
||||
LegacyPListAuditParseFailure? Failure)
|
||||
{
|
||||
public bool IsSuccess => Row is not null && Failure is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parser used only for the read-only production-data audit. It intentionally
|
||||
/// accepts the original PList grammar before any current scene mapping is
|
||||
/// attempted and never includes source text in exception or diagnostic output.
|
||||
/// </summary>
|
||||
public static class LegacyPListReadAuditParser
|
||||
{
|
||||
public const int MaximumProgramTitleLength = 128;
|
||||
public const int MaximumListTextLength = 4_000;
|
||||
public const int MaximumItems = LegacyNamedPlaylistPersistenceService.MaximumItems;
|
||||
|
||||
public static LegacyPListAuditParseResult Parse(
|
||||
string? programCode,
|
||||
string? programTitle,
|
||||
string? itemIndex,
|
||||
string? listText)
|
||||
{
|
||||
if (programCode is null || programCode.Length != 8 ||
|
||||
programCode.Any(static value => !char.IsAsciiDigit(value)))
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.InvalidProgramCode);
|
||||
}
|
||||
|
||||
if (!IsCanonicalText(programTitle, MaximumProgramTitleLength, allowEmpty: false))
|
||||
{
|
||||
return Failed(programTitle?.Contains('\uFFFD') == true
|
||||
? LegacyPListAuditParseFailure.ReplacementCharacter
|
||||
: ContainsMojibakeSignal(programTitle)
|
||||
? LegacyPListAuditParseFailure.MojibakeSignal
|
||||
: LegacyPListAuditParseFailure.InvalidProgramTitle);
|
||||
}
|
||||
|
||||
if (itemIndex is null ||
|
||||
!int.TryParse(
|
||||
itemIndex,
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var parsedIndex) ||
|
||||
parsedIndex is < 0 or >= MaximumItems ||
|
||||
!string.Equals(
|
||||
itemIndex,
|
||||
parsedIndex.ToString(CultureInfo.InvariantCulture),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.InvalidItemIndex);
|
||||
}
|
||||
|
||||
if (listText is null || listText.Length is < 1 or > MaximumListTextLength)
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.InvalidListText);
|
||||
}
|
||||
|
||||
if (listText.Contains('\uFFFD'))
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.ReplacementCharacter);
|
||||
}
|
||||
|
||||
if (ContainsMojibakeSignal(listText))
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.MojibakeSignal);
|
||||
}
|
||||
|
||||
if (!IsSafeText(listText))
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.UnsafeText);
|
||||
}
|
||||
|
||||
var fields = listText.Split('^', StringSplitOptions.None);
|
||||
if (fields.Length != 7)
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.InvalidFieldCount);
|
||||
}
|
||||
|
||||
if (fields[0] is not ("0" or "1"))
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.InvalidEnabledField);
|
||||
}
|
||||
|
||||
// A caret cannot occur inside a field after an exact seven-way split.
|
||||
// Retain empty strings and every code unit exactly as the original row.
|
||||
if (fields.Any(static field => !IsSafeText(field)))
|
||||
{
|
||||
return Failed(LegacyPListAuditParseFailure.UnsafeText);
|
||||
}
|
||||
|
||||
var frozenFields = Array.AsReadOnly(fields);
|
||||
var row = new LegacyPListAuditRow(
|
||||
programCode,
|
||||
programTitle!,
|
||||
parsedIndex,
|
||||
fields[0] == "1",
|
||||
frozenFields,
|
||||
listText,
|
||||
fields[5].Replace(" ", string.Empty, StringComparison.Ordinal));
|
||||
return row.IsLossless
|
||||
? new LegacyPListAuditParseResult(row, Failure: null)
|
||||
: Failed(LegacyPListAuditParseFailure.InvalidListText);
|
||||
}
|
||||
|
||||
public static bool ContainsMojibakeSignal(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// U+FFFD is reported separately. These code points and byte-order
|
||||
// marker are reliable signs of UTF-8/legacy-codepage damage in the
|
||||
// Korean operator data. Literal question marks are not rejected because
|
||||
// they are valid operator text and are not proof of corruption alone.
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (character is '\u00C2' or '\u00C3' or '\u00EC' or '\u00ED' or
|
||||
'\u00EA' or '\u00EB' or '\uFEFF')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return value.Contains("â€", StringComparison.Ordinal) ||
|
||||
value.Contains("", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LegacyPListAuditParseResult Failed(
|
||||
LegacyPListAuditParseFailure failure) =>
|
||||
new(Row: null, failure);
|
||||
|
||||
private static bool IsCanonicalText(
|
||||
string? value,
|
||||
int maximumLength,
|
||||
bool allowEmpty)
|
||||
{
|
||||
if (value is null || value.Length > maximumLength ||
|
||||
(!allowEmpty && value.Length == 0) ||
|
||||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
|
||||
value.Contains('\uFFFD') || ContainsMojibakeSignal(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsSafeText(value);
|
||||
}
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
for (var index = 0; index < value.Length; index++)
|
||||
{
|
||||
if (char.IsHighSurrogate(value[index]))
|
||||
{
|
||||
if (index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
else if (char.IsLowSurrogate(value[index]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || category is
|
||||
UnicodeCategory.Format or
|
||||
UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Side-effect-free evidence checks shared by the database smoke audit and its
|
||||
/// fixtures. This is deliberately a route classifier, not a resolver: routes
|
||||
/// that require current database or trusted-file evidence remain unplayable
|
||||
/// until the corresponding service reports a unique materialization result.
|
||||
/// </summary>
|
||||
public static class LegacyPListReadAuditEvidence
|
||||
{
|
||||
public static bool HasContiguousIndexes(IReadOnlyList<LegacyPListAuditRow> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
if (rows[index] is null || rows[index].ItemIndex != index)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool MatchesPersistenceResult(
|
||||
LegacyPListAuditRow raw,
|
||||
NamedPlaylistStoredItem stored)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(raw);
|
||||
ArgumentNullException.ThrowIfNull(stored);
|
||||
return raw.ItemIndex == stored.ItemIndex &&
|
||||
raw.IsEnabled == stored.IsEnabled &&
|
||||
string.Equals(raw.GroupCode, stored.Selection.GroupCode, StringComparison.Ordinal) &&
|
||||
string.Equals(raw.Subject, stored.Selection.Subject, StringComparison.Ordinal) &&
|
||||
string.Equals(raw.GraphicType, stored.Selection.GraphicType, StringComparison.Ordinal) &&
|
||||
string.Equals(raw.Subtype, stored.Selection.Subtype, StringComparison.Ordinal) &&
|
||||
string.Equals(raw.DataCode, stored.Selection.DataCode, StringComparison.Ordinal) &&
|
||||
string.Equals(
|
||||
raw.PageTextForDisplay,
|
||||
stored.Page?.ToString() ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static LegacyPListAuditRoute ClassifyRoute(LegacyPListAuditRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
var comparison = ToComparisonRow(row);
|
||||
if (LegacyNamedComparisonRestoreService.IsCandidate(comparison))
|
||||
{
|
||||
return LegacyPListAuditRoute.ComparisonFreshIdentity;
|
||||
}
|
||||
|
||||
var foreignIndex = ToForeignIndexCandleRow(row);
|
||||
if (LegacyForeignIndexCandleRestoreService.IsCandidate(foreignIndex))
|
||||
{
|
||||
return LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity;
|
||||
}
|
||||
|
||||
var nxt = ToNxtThemeRow(row);
|
||||
if (LegacyNamedNxtThemeRestoreService.IsCandidate(nxt))
|
||||
{
|
||||
return LegacyPListAuditRoute.NxtThemeFreshIdentity;
|
||||
}
|
||||
|
||||
if (IsIndustryFixed(row))
|
||||
{
|
||||
return LegacyPListAuditRoute.IndustryFixedClosedMapper;
|
||||
}
|
||||
|
||||
if (string.Equals(row.GroupCode, "테마", StringComparison.Ordinal))
|
||||
{
|
||||
return LegacyPListAuditRoute.KrxThemeFreshIdentity;
|
||||
}
|
||||
|
||||
if (row.GroupCode == "해외종목")
|
||||
{
|
||||
return LegacyPListAuditRoute.ForeignStockFreshIdentity;
|
||||
}
|
||||
|
||||
// The identity-bearing canonical format is already handled by the
|
||||
// current overseas workflow. Only its one unambiguous current-price
|
||||
// action is a closed seven-field mapping; the five candle periods share
|
||||
// the same selection fields and therefore remain deliberately unmapped.
|
||||
if (row.GroupCode == "FOREIGN_STOCK")
|
||||
{
|
||||
return IsCanonicalForeignStockCurrent(row)
|
||||
? LegacyPListAuditRoute.ExistingClosedMapper
|
||||
: LegacyPListAuditRoute.Unmapped;
|
||||
}
|
||||
|
||||
if (IsManualCandidate(row))
|
||||
{
|
||||
return LegacyPListAuditRoute.ManualFreshMaterialization;
|
||||
}
|
||||
|
||||
return IsExistingClosedMapper(row)
|
||||
? LegacyPListAuditRoute.ExistingClosedMapper
|
||||
: LegacyPListAuditRoute.Unmapped;
|
||||
}
|
||||
|
||||
public static LegacyNamedComparisonRestoreRow ToComparisonRow(LegacyPListAuditRow row) =>
|
||||
new(
|
||||
row.ItemIndex,
|
||||
row.IsEnabled,
|
||||
row.GroupCode,
|
||||
row.Subject,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.PageTextForDisplay,
|
||||
row.DataCode);
|
||||
|
||||
public static LegacyForeignIndexCandleRestoreRow ToForeignIndexCandleRow(
|
||||
LegacyPListAuditRow row) =>
|
||||
new(
|
||||
row.ItemIndex,
|
||||
row.IsEnabled,
|
||||
row.GroupCode,
|
||||
row.Subject,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.PageTextForDisplay,
|
||||
row.DataCode);
|
||||
|
||||
public static LegacyNamedNxtThemeRestoreRow ToNxtThemeRow(LegacyPListAuditRow row) =>
|
||||
new(
|
||||
row.ItemIndex,
|
||||
row.IsEnabled,
|
||||
row.GroupCode,
|
||||
row.Subject,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.PageTextForDisplay,
|
||||
row.DataCode);
|
||||
|
||||
private static bool IsIndustryFixed(LegacyPListAuditRow row)
|
||||
{
|
||||
if (row.Subtype.Length != 0 || row.DataCode.Length != 0 ||
|
||||
row.GraphicType is not ("5단 표그래프" or "네모그래프" or "섹터지수"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return row.GroupCode switch
|
||||
{
|
||||
"업종_코스피" => string.Equals(row.Subject, "코스피", StringComparison.Ordinal),
|
||||
"업종_코스닥" => string.Equals(row.Subject, "코스닥", StringComparison.Ordinal),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsManualCandidate(LegacyPListAuditRow row) =>
|
||||
LegacyNamedManualRestoreClassifier.Classify(row) is not null;
|
||||
|
||||
private static bool IsCanonicalForeignStockCurrent(LegacyPListAuditRow row)
|
||||
{
|
||||
if (row.PageTextForDisplay != "1/1" ||
|
||||
row.Subject.Length is < 1 or > 200 ||
|
||||
row.Subject != row.Subject.Trim() ||
|
||||
row.Subject.Contains('|') ||
|
||||
row.GraphicType != "1열판기본" ||
|
||||
row.Subtype != "CURRENT")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = row.DataCode.Split('|', StringSplitOptions.None);
|
||||
return parts.Length == 3 &&
|
||||
LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[0]) &&
|
||||
parts[1] is "US" or "TW" &&
|
||||
parts[2] == "1";
|
||||
}
|
||||
|
||||
private static bool IsExistingClosedMapper(LegacyPListAuditRow row) =>
|
||||
IsLegacyStock(row) || IsLegacyExpert(row) || IsCanonicalDefault(row);
|
||||
|
||||
private static bool IsLegacyStock(LegacyPListAuditRow row)
|
||||
{
|
||||
if (row.PageTextForDisplay != "1/1" || row.Subject.Length is < 1 or > 128 ||
|
||||
row.DataCode.Length is < 1 or > 32 ||
|
||||
row.DataCode.Any(static value => !char.IsAsciiLetterOrDigit(value)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isNxt = row.GroupCode is "코스피_NXT" or "코스닥_NXT";
|
||||
if (row.GroupCode is not ("코스피" or "코스닥" or "코스피_NXT" or "코스닥_NXT"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var label = row.Subtype.Length == 0
|
||||
? row.GraphicType
|
||||
: string.Concat(row.GraphicType, "_", row.Subtype);
|
||||
if (isNxt)
|
||||
{
|
||||
return label == "1열판기본_현재가";
|
||||
}
|
||||
|
||||
return StockLegacyLabels.Contains(label);
|
||||
}
|
||||
|
||||
private static bool IsLegacyExpert(LegacyPListAuditRow row) =>
|
||||
row.PageTextForDisplay.StartsWith("1/", StringComparison.Ordinal) &&
|
||||
row.GroupCode == "전문가 추천" && row.Subject.Length is > 0 and <= 128 &&
|
||||
row.GraphicType == "5단 표그래프" && row.Subtype == "현재가" &&
|
||||
row.DataCode.Length == 4 && row.DataCode.All(char.IsAsciiDigit);
|
||||
|
||||
private static bool IsCanonicalDefault(LegacyPListAuditRow row) =>
|
||||
CanonicalDefaults.Contains(new SelectionKey(
|
||||
row.GroupCode,
|
||||
row.Subject,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.DataCode));
|
||||
|
||||
private static readonly HashSet<string> StockLegacyLabels = new(StringComparer.Ordinal)
|
||||
{
|
||||
"1열판기본_예상체결가",
|
||||
"1열판기본_현재가",
|
||||
"1열판기본_시간외단일가",
|
||||
"1열판상세_시가",
|
||||
"1열판상세_액면가",
|
||||
"1열판상세_PBR",
|
||||
"1열판상세_거래량",
|
||||
"수익률그래프_5일",
|
||||
"수익률그래프_20일",
|
||||
"수익률그래프_60일",
|
||||
"수익률그래프_120일",
|
||||
"수익률그래프_240일",
|
||||
"캔들그래프_일봉",
|
||||
"캔들그래프_5일",
|
||||
"캔들그래프_20일",
|
||||
"캔들그래프_60일",
|
||||
"캔들그래프_120일",
|
||||
"캔들그래프_240일",
|
||||
"캔들그래프(거래량)_일봉",
|
||||
"캔들그래프(거래량)_5일",
|
||||
"캔들그래프(거래량)_20일",
|
||||
"캔들그래프(거래량)_60일",
|
||||
"캔들그래프(거래량)_120일",
|
||||
"캔들그래프(거래량)_240일",
|
||||
"캔들그래프(예상체결가)_5일",
|
||||
"캔들그래프(예상체결가)_20일",
|
||||
"캔들그래프(예상체결가)_60일",
|
||||
"캔들그래프(예상체결가)_120일",
|
||||
"캔들그래프(예상체결가)_240일",
|
||||
"호가창_표그래프",
|
||||
"거래원_표그래프"
|
||||
};
|
||||
|
||||
private static readonly HashSet<SelectionKey> CanonicalDefaults = new()
|
||||
{
|
||||
new("ALL", "INDEX", string.Empty, "UnitedStatesIndices", string.Empty),
|
||||
new("ALL", "INDEX", string.Empty, "Cotton", string.Empty),
|
||||
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", string.Empty),
|
||||
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", string.Empty),
|
||||
new("INDEX", "FUTURES,Kospi", string.Empty, "CURRENT", string.Empty),
|
||||
new("FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", string.Empty, string.Empty),
|
||||
new("ALL", "INDEX", string.Empty, string.Empty, string.Empty),
|
||||
new("ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", string.Empty),
|
||||
new("KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", string.Empty),
|
||||
new("ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", string.Empty),
|
||||
new("EXCHANGE_RATE", "원달러", string.Empty, "20일", string.Empty),
|
||||
new("FOREIGN_INDEX", "DOW", string.Empty, "DowJones", string.Empty),
|
||||
new("ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", string.Empty),
|
||||
new("ALL", "KOSPI", string.Empty, string.Empty, string.Empty),
|
||||
new("INDEX", "Kospi,Kosdaq", string.Empty, "CURRENT", string.Empty)
|
||||
};
|
||||
|
||||
private readonly record struct SelectionKey(
|
||||
string GroupCode,
|
||||
string Subject,
|
||||
string GraphicType,
|
||||
string Subtype,
|
||||
string DataCode);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MMoneyCoderSharp.Data;
|
||||
@@ -459,13 +460,11 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
|
||||
|
||||
private static bool IsSafeText(string value)
|
||||
{
|
||||
for (var index = 0; index < value.Length; index++)
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
var character = value[index];
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
||||
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
||||
character == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or
|
||||
var category = Rune.GetUnicodeCategory(rune);
|
||||
if (Rune.IsControl(rune) || rune.Value == '\uFFFD' ||
|
||||
category is UnicodeCategory.Format or UnicodeCategory.Surrogate or
|
||||
UnicodeCategory.LineSeparator or
|
||||
UnicodeCategory.ParagraphSeparator)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user