using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Globalization; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; public enum LegacyManualFinancialWorkflowStatus { Idle, ListLoaded, DetailLoaded, StockCandidatesLoaded, StockVerified, WriteCommitted, WriteRejected, ReadBackRequired, OutcomeUnknown } public enum LegacyManualFinancialFindDirection { Up, Down } public enum LegacyManualFinancialNameSortDirection { Ascending, Descending } public sealed class LegacyManualFinancialScreenDefinition { internal LegacyManualFinancialScreenDefinition( ManualFinancialScreenKind screen, string actionId, string label, string tableName, string builderKey, string code, string canonicalGraphicType, string deleteAllConfirmationToken) { Screen = screen; ActionId = actionId; Label = label; TableName = tableName; BuilderKey = builderKey; Code = code; Aliases = Array.AsReadOnly([code]); CanonicalGraphicType = canonicalGraphicType; DeleteAllConfirmationToken = deleteAllConfirmationToken; } public ManualFinancialScreenKind Screen { get; } public string ActionId { get; } public string Label { get; } public string TableName { get; } public string BuilderKey { get; } public string Code { get; } public IReadOnlyList Aliases { get; } public string CanonicalGraphicType { get; } public string DeleteAllConfirmationToken { get; } } public sealed class LegacyManualFinancialListRow { internal LegacyManualFinancialListRow(string resultId, ManualFinancialSnapshot snapshot) { ResultId = resultId; Snapshot = snapshot; StockName = snapshot.Record.Identity.StockName; PreviewValues = CreatePreviewValues(snapshot.Record); } internal LegacyManualFinancialListRow( string resultId, ManualFinancialRawSnapshot snapshot) { ResultId = resultId; RawSnapshot = snapshot; Snapshot = snapshot.StrictSnapshot; StockName = snapshot.Record.Identity.StockName; PreviewValues = Array.AsReadOnly( new[] { StockName } .Concat(snapshot.Record.StorageValues) .ToArray()); } internal LegacyManualFinancialListRow(string resultId, string stockName) { ResultId = resultId; StockName = stockName; PreviewValues = Array.AsReadOnly([stockName]); } public string ResultId { get; } public string StockName { get; } /// /// Read-only GraphE list projection in the fixed INPUT_* storage-column /// order. It contains operator display values only: no row version, ROWID, /// native row handle, stock code, or database authority is exposed. /// public IReadOnlyList PreviewValues { get; } public string RowVersion => RawSnapshot?.RowVersion ?? Snapshot?.RowVersion ?? string.Empty; public bool IsDataReadable => RawSnapshot is not null || Snapshot is not null; public bool IsPlayoutReady => Snapshot is not null; internal ManualFinancialSnapshot? Snapshot { get; } internal ManualFinancialRawSnapshot? RawSnapshot { get; } private static IReadOnlyList CreatePreviewValues( ManualFinancialRecord record) { var values = record switch { ManualRevenueCompositionRecord revenue => new[] { revenue.Identity.StockName, revenue.BaseDate } .Concat(revenue.Slices.Select(static slice => slice is null ? string.Empty : $"{slice.Label}_{slice.PercentageText}")), ManualGrowthMetricsRecord growth => new[] { growth.Identity.StockName } .Concat(new[] { FormatGrowthSeries(growth.SalesGrowth), FormatGrowthSeries(growth.OperatingProfitGrowth), FormatGrowthSeries(growth.NetAssetGrowth), FormatGrowthSeries(growth.NetIncomeGrowth), string.Join('_', growth.Periods.ToArray()) }), ManualSalesRecord sales => new[] { sales.Identity.StockName } .Concat(sales.Quarters.Select(FormatQuarter)), ManualOperatingProfitRecord profit => new[] { profit.Identity.StockName } .Concat(profit.Quarters.Select(FormatQuarter)), _ => throw new ArgumentOutOfRangeException(nameof(record)) }; return Array.AsReadOnly(values.ToArray()); } private static string FormatGrowthSeries(ManualGrowthSeriesValues series) => string.Join('_', series.ToArray().Select(static value => value?.ToString( "G17", CultureInfo.InvariantCulture) ?? string.Empty)); private static string FormatQuarter(ManualQuarterValue quarter) => $"{quarter.Quarter}_{quarter.Value.ToString(CultureInfo.InvariantCulture)}"; } public sealed class LegacyManualFinancialStockRow { internal LegacyManualFinancialStockRow(string resultId, StockSearchItem item) { ResultId = resultId; Item = item; } public string ResultId { get; } public StockMarket Market => Item.Market; public string Name => Item.Name; public string Code => Item.Code; internal StockSearchItem Item { get; } } public sealed record LegacyManualFinancialVerifiedStockSummary( StockMarket Market, string Name, string Code); public sealed record LegacyManualFinancialPlaylistDraft( string ActionId, string BuilderKey, string Code, IReadOnlyList Aliases, string Title, string Detail, string Category, string Market, string StockCode, bool IsNxt, ManualFinancialCutSelection CutSelection, ManualFinancialSnapshot? OperatorSnapshot, ManualFinancialRawSnapshot? OperatorRawSnapshot); public sealed record LegacyManualFinancialWorkflowSnapshot { internal LegacyManualFinancialWorkflowSnapshot( object authority, long revision, LegacyManualFinancialScreenDefinition definition) { Authority = authority; Revision = revision; Definition = definition; Query = string.Empty; FindQuery = string.Empty; NameSortDirection = LegacyManualFinancialNameSortDirection.Ascending; Rows = Array.Empty(); StockQuery = string.Empty; StockCandidates = Array.Empty(); } public long Revision { get; internal init; } public LegacyManualFinancialScreenDefinition Definition { get; internal init; } public ManualFinancialScreenKind Screen => Definition.Screen; public string Query { get; internal init; } public string FindQuery { get; internal init; } public LegacyManualFinancialNameSortDirection NameSortDirection { get; internal init; } public IReadOnlyList Rows { get; internal init; } public bool ListIsTruncated { get; internal init; } public int ListRejectedItemCount { get; internal init; } public string? SelectedRowId { get; internal init; } public ManualFinancialRecord? SelectedRecord => Detail?.Record; public ManualFinancialRawRecord? SelectedRawRecord => RawDetail?.Record; public string? SelectedRowVersion => RawDetail?.RowVersion ?? Detail?.RowVersion; public bool IsDetailFresh { get; internal init; } public bool IsRawDetailFresh { get; internal init; } public bool IsPlayoutReady => IsDetailFresh && Detail is not null; public string StockQuery { get; internal init; } public IReadOnlyList StockCandidates { get; internal init; } public bool StockSearchIsTruncated { get; internal init; } public string? SelectedStockResultId { get; internal init; } public LegacyManualFinancialVerifiedStockSummary? VerifiedStock => Verified is null ? null : new LegacyManualFinancialVerifiedStockSummary( Verified.Market, Verified.Name, Verified.Code); public LegacyManualFinancialWorkflowStatus Status { get; internal init; } public bool WritesQuarantined { get; internal init; } public string? LastMessage { get; internal init; } public string? LastRequestId { get; internal init; } public ManualFinancialMutationReceipt? LastReceipt { get; internal init; } public bool CanEdit => !WritesQuarantined && (IsRawDetailFresh && RawDetail is not null || IsDetailFresh && Detail is not null); public bool CanSave => !WritesQuarantined && (RawDetail is not null ? IsRawDetailFresh : Verified is not null && (Detail is null || IsDetailFresh && string.Equals( VerifiedForRowVersion, Detail.RowVersion, StringComparison.Ordinal) && string.Equals( Verified.Name, Detail.Record.Identity.StockName, StringComparison.Ordinal))); public bool CanDelete => !WritesQuarantined && (IsRawDetailFresh && RawDetail is not null || IsDetailFresh && Detail is not null); public bool CanMaterializePlaylist => !WritesQuarantined && Verified is not null && SelectedIdentity is { } identity && SelectedRowVersion is { } rowVersion && (IsRawDetailFresh || IsDetailFresh) && string.Equals(VerifiedForRowVersion, rowVersion, StringComparison.Ordinal) && string.Equals( Verified.Name, identity.StockName, StringComparison.Ordinal); internal object Authority { get; } internal long WriteEpoch { get; init; } internal ManualFinancialSnapshot? Detail { get; init; } internal ManualFinancialRawSnapshot? RawDetail { get; init; } internal ManualFinancialIdentity? SelectedIdentity => RawDetail?.Record.Identity ?? Detail?.Record.Identity; internal VerifiedManualFinancialStock? Verified { get; init; } internal string? VerifiedForRowVersion { get; init; } } public abstract class LegacyManualFinancialWriteRequest { internal LegacyManualFinancialWriteRequest( object authority, long sourceRevision, long sourceWriteEpoch, ManualFinancialMutationKind kind, ManualFinancialScreenKind screen, string stockName) { Authority = authority; SourceRevision = sourceRevision; SourceWriteEpoch = sourceWriteEpoch; Kind = kind; Screen = screen; StockName = stockName; RequestId = Guid.NewGuid().ToString("N"); } public string RequestId { get; } public ManualFinancialMutationKind Kind { get; } public ManualFinancialScreenKind Screen { get; } public string StockName { get; } public virtual string? ExpectedRowVersion => null; internal object Authority { get; } internal long SourceRevision { get; } internal long SourceWriteEpoch { get; } internal virtual VerifiedManualFinancialStock? VerifiedStock => null; } public sealed class LegacyManualFinancialCreateRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialCreateRequest( object authority, long sourceRevision, long sourceWriteEpoch, ManualFinancialRecord record, VerifiedManualFinancialStock verifiedStock) : base( authority, sourceRevision, sourceWriteEpoch, ManualFinancialMutationKind.Create, record.Identity.Screen, record.Identity.StockName) { Record = record; Stock = verifiedStock; } public ManualFinancialRecord Record { get; } internal override VerifiedManualFinancialStock VerifiedStock => Stock; private VerifiedManualFinancialStock Stock { get; } } public sealed class LegacyManualFinancialUpdateRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialUpdateRequest( object authority, long sourceRevision, long sourceWriteEpoch, ManualFinancialSnapshot expected, ManualFinancialRecord replacement, VerifiedManualFinancialStock verifiedStock) : base( authority, sourceRevision, sourceWriteEpoch, ManualFinancialMutationKind.Update, expected.Record.Identity.Screen, expected.Record.Identity.StockName) { Expected = expected; Replacement = replacement; Stock = verifiedStock; } public override string ExpectedRowVersion => Expected.RowVersion; public ManualFinancialRecord Replacement { get; } internal ManualFinancialSnapshot Expected { get; } internal override VerifiedManualFinancialStock VerifiedStock => Stock; private VerifiedManualFinancialStock Stock { get; } } public sealed class LegacyManualFinancialRawUpdateRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialRawUpdateRequest( object authority, long sourceRevision, long sourceWriteEpoch, ManualFinancialRawSnapshot expected, ManualFinancialRawRecord replacement) : base( authority, sourceRevision, sourceWriteEpoch, ManualFinancialMutationKind.Update, expected.Record.Identity.Screen, expected.Record.Identity.StockName) { Expected = expected; Replacement = replacement; } public override string ExpectedRowVersion => Expected.RowVersion; public ManualFinancialRawRecord Replacement { get; } internal ManualFinancialRawSnapshot Expected { get; } } public sealed class LegacyManualFinancialDeleteRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialDeleteRequest( object authority, long sourceRevision, long sourceWriteEpoch, ManualFinancialSnapshot expected) : base( authority, sourceRevision, sourceWriteEpoch, ManualFinancialMutationKind.DeleteOne, expected.Record.Identity.Screen, expected.Record.Identity.StockName) { Expected = expected; } public override string ExpectedRowVersion => Expected.RowVersion; internal ManualFinancialSnapshot Expected { get; } } public sealed class LegacyManualFinancialRawDeleteRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialRawDeleteRequest( object authority, long sourceRevision, long sourceWriteEpoch, ManualFinancialRawSnapshot expected) : base( authority, sourceRevision, sourceWriteEpoch, ManualFinancialMutationKind.DeleteOne, expected.Record.Identity.Screen, expected.Record.Identity.StockName) { Expected = expected; } public override string ExpectedRowVersion => Expected.RowVersion; internal ManualFinancialRawSnapshot Expected { get; } } public sealed class LegacyManualFinancialDeleteAllRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialDeleteAllRequest( object authority, long sourceRevision, long sourceWriteEpoch, LegacyManualFinancialScreenDefinition definition) : base( authority, sourceRevision, sourceWriteEpoch, ManualFinancialMutationKind.DeleteAll, definition.Screen, string.Empty) { ConfirmationToken = definition.DeleteAllConfirmationToken; } public string ConfirmationToken { get; } } public sealed class LegacyManualFinancialWorkflowDataException : Exception { public LegacyManualFinancialWorkflowDataException(string message) : base(message) { } } internal sealed class LegacyManualFinancialWriteSafetyState { private readonly object _gate = new(); private long _writeEpoch; private bool _writesQuarantined; private string? _quarantineReason; internal SemaphoreSlim WriteGate { get; } = new(1, 1); internal long CurrentEpoch { get { lock (_gate) { return _writeEpoch; } } } internal bool IsCurrent(long writeEpoch) { lock (_gate) { return writeEpoch == _writeEpoch; } } internal bool IsQuarantined(out string? reason) { lock (_gate) { reason = _quarantineReason; return _writesQuarantined; } } internal bool TryBeginWrite( long expectedEpoch, out long writeEpoch, out string reason) { lock (_gate) { writeEpoch = _writeEpoch; if (_writesQuarantined) { reason = $"Manual-financial writes are quarantined: {_quarantineReason}"; return false; } if (expectedEpoch != _writeEpoch) { reason = "The manual-financial write request belongs to a stale workflow state."; return false; } writeEpoch = checked(++_writeEpoch); reason = string.Empty; return true; } } internal string Quarantine(string reason) { lock (_gate) { _writesQuarantined = true; _quarantineReason ??= reason; return _quarantineReason; } } } /// /// C#-authoritative migration of Form/GraphE.cs. Web callers receive opaque row, /// stock and action ids; fixed Core contracts own INPUT_* identities, optimistic /// row versions and scene codes. Every write request is one-shot. An uncertain /// outcome latches a process-lifetime write quarantine and is never retried. /// public sealed class LegacyManualFinancialWorkflow { public const int MaximumListResults = LegacyManualFinancialScreenService.MaximumResults; public const int MaximumStockResults = LegacyStockSearchService.MaximumResults; private const string ValidationRowVersion = "0000000000000000000000000000000000000000000000000000000000000000"; private const string ValidationStockCode = "VALIDATION1"; private static readonly IReadOnlyList RegisteredScreens = CreateDefinitions(); private static readonly IReadOnlyDictionary ScreensByKind = new ReadOnlyDictionary( RegisteredScreens.ToDictionary(definition => definition.Screen)); private static readonly IReadOnlyDictionary ScreensByActionId = new ReadOnlyDictionary( RegisteredScreens.ToDictionary( definition => definition.ActionId, StringComparer.Ordinal)); private static readonly LegacyManualFinancialWriteSafetyState ProcessWriteSafety = new(); private readonly IManualFinancialScreenService _screenService; private readonly IManualFinancialRawScreenService? _rawScreenService; private readonly IStockSearchService _stockSearchService; private readonly LegacyManualFinancialWriteSafetyState _writeSafety; private readonly object _authority = new(); static LegacyManualFinancialWorkflow() { ValidateDefinitions(RegisteredScreens); } public LegacyManualFinancialWorkflow( IManualFinancialScreenService screenService, IStockSearchService stockSearchService) : this(screenService, stockSearchService, ProcessWriteSafety) { } internal LegacyManualFinancialWorkflow( IManualFinancialScreenService screenService, IStockSearchService stockSearchService, LegacyManualFinancialWriteSafetyState writeSafety) { _screenService = screenService ?? throw new ArgumentNullException(nameof(screenService)); _rawScreenService = screenService as IManualFinancialRawScreenService; _stockSearchService = stockSearchService ?? throw new ArgumentNullException(nameof(stockSearchService)); _writeSafety = writeSafety ?? throw new ArgumentNullException(nameof(writeSafety)); } public static IReadOnlyList Screens => RegisteredScreens; public static LegacyManualFinancialScreenDefinition GetScreen( ManualFinancialScreenKind screen) => ScreensByKind.TryGetValue(screen, out var definition) ? definition : throw new ArgumentOutOfRangeException(nameof(screen)); public LegacyManualFinancialWorkflowSnapshot CreateInitial( ManualFinancialScreenKind screen) => new(_authority, 0, GetScreen(screen)) { WriteEpoch = _writeSafety.CurrentEpoch, WritesQuarantined = IsQuarantined(out var reason), LastMessage = reason }; public async Task SearchAsync( LegacyManualFinancialWorkflowSnapshot snapshot, string query = "", CancellationToken cancellationToken = default) { snapshot = RequireOwned(snapshot); var normalizedQuery = NormalizeQuery(query, allowEmpty: true); var result = await _screenService.SearchAsync( snapshot.Screen, normalizedQuery, MaximumListResults, cancellationToken).ConfigureAwait(false); if (result is null || result.Screen != snapshot.Screen || !string.Equals(result.Query, normalizedQuery, StringComparison.Ordinal) || result.Items is null || result.Items.Count > MaximumListResults || result.UnreadableStockNames is null || result.RawItems is null || (result.RawItemsComplete ? result.RawItems.Count > MaximumListResults : result.Items.Count + result.UnreadableStockNames.Count > MaximumListResults) || result.RejectedItemCount is < 0 or > MaximumListResults || result.UnreadableStockNames.Count > result.RejectedItemCount) { throw InvalidData("The manual-financial list response is not correlated to its request."); } var rows = new List(result.RawItemsComplete ? result.RawItems.Count : result.Items.Count + result.UnreadableStockNames.Count); if (result.RawItemsComplete) { foreach (var item in result.RawItems) { var validated = ValidateRawSnapshot( item, snapshot.Screen, "raw list response"); rows.Add(new LegacyManualFinancialListRow( NewOpaqueId("mf-row"), validated)); } } else { foreach (var item in result.Items) { var validated = ValidateSnapshot(item, snapshot.Screen, "list response"); rows.Add(new LegacyManualFinancialListRow(NewOpaqueId("mf-row"), validated)); } foreach (var stockName in result.UnreadableStockNames) { rows.Add(new LegacyManualFinancialListRow( NewOpaqueId("mf-row"), ValidateListStockName(stockName))); } } rows.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.StockName, right.StockName)); var writesQuarantined = IsQuarantined(out var reason); var hiddenUnreadableCount = result.RejectedItemCount - result.UnreadableStockNames.Count; var listMessages = new List(); if (result.RejectedItemCount > 0) { listMessages.Add(hiddenUnreadableCount == 0 ? $"기존 원문 형식 데이터 {result.RejectedItemCount}행도 " + "목록에 표시되며 선택·편집할 수 있습니다." : $"기존 원문 형식 데이터 {result.UnreadableStockNames.Count}행은 " + $"목록에 표시했고, 이름을 읽을 수 없는 {hiddenUnreadableCount}행은 제외했습니다."); } if (result.IsTruncated) { listMessages.Add( $"검색 결과가 {MaximumListResults}행을 초과해 일부만 표시됩니다."); } if (!string.IsNullOrWhiteSpace(reason)) { listMessages.Add(reason); } var listMessage = listMessages.Count == 0 ? null : string.Join(' ', listMessages); return snapshot with { Revision = checked(snapshot.Revision + 1), Query = normalizedQuery, FindQuery = string.Empty, NameSortDirection = LegacyManualFinancialNameSortDirection.Ascending, Rows = Array.AsReadOnly(rows.ToArray()), ListIsTruncated = result.IsTruncated, ListRejectedItemCount = result.RejectedItemCount, SelectedRowId = null, Detail = null, RawDetail = null, IsDetailFresh = false, IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.ListLoaded, WritesQuarantined = writesQuarantined, LastMessage = listMessage, LastRequestId = null, LastReceipt = null }; } public LegacyManualFinancialWorkflowSnapshot ToggleNameSort( LegacyManualFinancialWorkflowSnapshot snapshot, long expectedRevision) { snapshot = RequireCurrentRevision(snapshot, expectedRevision); var direction = snapshot.NameSortDirection == LegacyManualFinancialNameSortDirection.Ascending ? LegacyManualFinancialNameSortDirection.Descending : LegacyManualFinancialNameSortDirection.Ascending; var rows = SortRows(snapshot.Rows, direction); return snapshot with { Revision = checked(snapshot.Revision + 1), Rows = rows, NameSortDirection = direction, LastMessage = null }; } public LegacyManualFinancialWorkflowSnapshot BeginLoad( LegacyManualFinancialWorkflowSnapshot snapshot, string resultId) { snapshot = RequireOwned(snapshot); var isCurrentRow = !string.IsNullOrWhiteSpace(resultId) && snapshot.Rows.Any(row => string.Equals( row.ResultId, resultId, StringComparison.Ordinal)); return snapshot with { Revision = checked(snapshot.Revision + 1), SelectedRowId = isCurrentRow ? resultId : null, Detail = null, RawDetail = null, IsDetailFresh = false, IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.ListLoaded, LastMessage = null }; } public LegacyManualFinancialWorkflowSnapshot FindByName( LegacyManualFinancialWorkflowSnapshot snapshot, string query, LegacyManualFinancialFindDirection direction, long expectedRevision) { snapshot = RequireCurrentRevision(snapshot, expectedRevision); if (!Enum.IsDefined(direction)) { throw new ArgumentOutOfRangeException(nameof(direction)); } var normalizedQuery = NormalizeQuery(query, allowEmpty: false); var selectedIndex = snapshot.SelectedRowId is null ? -1 : snapshot.Rows.ToList().FindIndex(row => string.Equals( row.ResultId, snapshot.SelectedRowId, StringComparison.Ordinal)); var matchIndex = -1; if (direction == LegacyManualFinancialFindDirection.Down) { for (var index = selectedIndex + 1; index < snapshot.Rows.Count; index++) { if (snapshot.Rows[index].StockName.Contains( normalizedQuery, StringComparison.OrdinalIgnoreCase)) { matchIndex = index; break; } } } else if (selectedIndex >= 0) { for (var index = selectedIndex - 1; index >= 0; index--) { if (snapshot.Rows[index].StockName.Contains( normalizedQuery, StringComparison.OrdinalIgnoreCase)) { matchIndex = index; break; } } } return snapshot with { Revision = checked(snapshot.Revision + 1), FindQuery = normalizedQuery, SelectedRowId = matchIndex >= 0 ? snapshot.Rows[matchIndex].ResultId : snapshot.SelectedRowId, Detail = matchIndex >= 0 ? null : snapshot.Detail, RawDetail = matchIndex >= 0 ? null : snapshot.RawDetail, IsDetailFresh = matchIndex < 0 && snapshot.IsDetailFresh, IsRawDetailFresh = matchIndex < 0 && snapshot.IsRawDetailFresh, StockQuery = matchIndex >= 0 ? string.Empty : snapshot.StockQuery, StockCandidates = matchIndex >= 0 ? Array.Empty() : snapshot.StockCandidates, StockSearchIsTruncated = matchIndex < 0 && snapshot.StockSearchIsTruncated, SelectedStockResultId = matchIndex >= 0 ? null : snapshot.SelectedStockResultId, Verified = matchIndex >= 0 ? null : snapshot.Verified, VerifiedForRowVersion = matchIndex >= 0 ? null : snapshot.VerifiedForRowVersion, Status = matchIndex >= 0 ? LegacyManualFinancialWorkflowStatus.ListLoaded : snapshot.Status, LastMessage = matchIndex >= 0 ? null : "No further matching stock was found." }; } public async Task LoadAsync( LegacyManualFinancialWorkflowSnapshot snapshot, string resultId, CancellationToken cancellationToken = default) { snapshot = RequireOwned(snapshot); var row = FindRow(snapshot.Rows, resultId); var identity = new ManualFinancialIdentity(snapshot.Screen, row.StockName); ManualFinancialSnapshot? validated; ManualFinancialRawSnapshot? validatedRaw = null; IReadOnlyList rows; if (row.RawSnapshot is not null && _rawScreenService is not null) { var raw = await _rawScreenService.GetRawAsync( row.RawSnapshot, cancellationToken).ConfigureAwait(false); validatedRaw = ValidateRawSnapshot(raw, snapshot.Screen, "raw detail response"); if (!EqualsIdentity(identity, validatedRaw.Record.Identity)) { throw InvalidData( "The raw manual-financial detail response is not correlated to its request."); } validated = validatedRaw.StrictSnapshot; rows = ReplaceRow(snapshot.Rows, resultId, validatedRaw); } else { var detail = await _screenService.GetAsync( identity, cancellationToken).ConfigureAwait(false); validated = ValidateSnapshot(detail, snapshot.Screen, "detail response"); if (!EqualsIdentity(identity, validated.Record.Identity)) { throw InvalidData( "The manual-financial detail response is not correlated to its request."); } rows = ReplaceRow(snapshot.Rows, resultId, validated); } var writesQuarantined = IsQuarantined(out var reason); var message = reason ?? (validated is null ? "기존 GraphE 저장값을 원문 그대로 불러왔습니다. 송출 시에는 장면 데이터 검증을 다시 수행합니다." : null); return snapshot with { Revision = checked(snapshot.Revision + 1), Rows = rows, SelectedRowId = resultId, Detail = validated, RawDetail = validatedRaw, IsDetailFresh = validated is not null, IsRawDetailFresh = validatedRaw is not null, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.DetailLoaded, WritesQuarantined = writesQuarantined, LastMessage = message, LastRequestId = null, LastReceipt = null }; } public LegacyManualFinancialWorkflowSnapshot BeginCreate( LegacyManualFinancialWorkflowSnapshot snapshot) { snapshot = RequireOwned(snapshot); return snapshot with { Revision = checked(snapshot.Revision + 1), SelectedRowId = null, Detail = null, RawDetail = null, IsDetailFresh = false, IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.ListLoaded, WritesQuarantined = IsQuarantined(out var reason), LastMessage = reason, LastRequestId = null, LastReceipt = null }; } public async Task SearchStocksAsync( LegacyManualFinancialWorkflowSnapshot snapshot, string stockName, CancellationToken cancellationToken = default) { snapshot = RequireOwned(snapshot); var normalizedName = NormalizeQuery(stockName, allowEmpty: true); // Form/GraphE.cs returned immediately from both the button and Enter // handlers when the stock-search text box was empty. Preserve the // complete editor/candidate state and avoid touching the provider. if (normalizedName.Length == 0) { return snapshot; } var result = await _stockSearchService.SearchAsync( normalizedName, MaximumStockResults, cancellationToken).ConfigureAwait(false); if (result is null || !string.Equals(result.Query, normalizedName, StringComparison.Ordinal) || result.Items is null || result.Items.Count > MaximumStockResults) { throw InvalidData("The stock-search response is not correlated to its request."); } var candidates = new List(result.Items.Count); foreach (var item in result.Items) { ValidateStockItem(item); candidates.Add(new LegacyManualFinancialStockRow( NewOpaqueId("mf-stock"), item with { })); } return snapshot with { Revision = checked(snapshot.Revision + 1), StockQuery = normalizedName, StockCandidates = Array.AsReadOnly(candidates.ToArray()), StockSearchIsTruncated = result.IsTruncated, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.StockCandidatesLoaded, WritesQuarantined = IsQuarantined(out var reason), LastMessage = reason, LastRequestId = null, LastReceipt = null }; } public LegacyManualFinancialWorkflowSnapshot SelectStock( LegacyManualFinancialWorkflowSnapshot snapshot, string stockResultId) { snapshot = RequireOwned(snapshot); if (string.IsNullOrWhiteSpace(stockResultId)) { return snapshot; } var row = FindStock(snapshot.StockCandidates, stockResultId); var identity = snapshot.RawDetail?.Record.Identity ?? snapshot.Detail?.Record.Identity ?? new ManualFinancialIdentity(snapshot.Screen, row.Item.Name); var candidates = snapshot.StockCandidates.Select(candidate => candidate.Item).ToArray(); var verified = ManualFinancialCutContracts.VerifyStock( identity, candidates, row.Item.Market, row.Item.Code); var verifiedForRowVersion = snapshot.SelectedRowVersion; return snapshot with { Revision = checked(snapshot.Revision + 1), SelectedStockResultId = stockResultId, Verified = verified, VerifiedForRowVersion = verifiedForRowVersion, Status = LegacyManualFinancialWorkflowStatus.StockVerified, WritesQuarantined = IsQuarantined(out var reason), LastMessage = reason, LastRequestId = null, LastReceipt = null }; } public LegacyManualFinancialCreateRequest CreateCreateRequest( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialRecord record) { snapshot = RequireWritable(snapshot); if (snapshot.Detail is not null) { throw new InvalidOperationException("BeginCreate must be used before creating a new record."); } var verified = snapshot.Verified ?? throw new InvalidOperationException( "An exact stock-master row must be verified before creating a record."); var validated = ValidateRecord(record, snapshot.Screen, verified, nameof(record)); return new LegacyManualFinancialCreateRequest( _authority, snapshot.Revision, snapshot.WriteEpoch, validated, verified); } public LegacyManualFinancialUpdateRequest CreateUpdateRequest( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialRecord replacement) { snapshot = RequireWritable(snapshot); var expected = RequireFreshDetail(snapshot); var verified = RequireVerifiedForDetail(snapshot, expected); var validated = ValidateRecord(replacement, snapshot.Screen, verified, nameof(replacement)); if (!EqualsIdentity(expected.Record.Identity, validated.Identity)) { throw new ArgumentException( "A manual-financial update cannot rename or change screens.", nameof(replacement)); } return new LegacyManualFinancialUpdateRequest( _authority, snapshot.Revision, snapshot.WriteEpoch, expected, validated, verified); } public LegacyManualFinancialRawUpdateRequest CreateRawUpdateRequest( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialRawRecord replacement) { snapshot = RequireWritable(snapshot); var expected = RequireFreshRawDetail(snapshot); var validated = ValidateRawRecord(replacement, snapshot.Screen, nameof(replacement)); if (!EqualsIdentity(expected.Record.Identity, validated.Identity)) { throw new ArgumentException( "A raw manual-financial update cannot rename or change screens.", nameof(replacement)); } return new LegacyManualFinancialRawUpdateRequest( _authority, snapshot.Revision, snapshot.WriteEpoch, expected, validated); } public LegacyManualFinancialWriteRequest CreateDeleteRequest( LegacyManualFinancialWorkflowSnapshot snapshot) { snapshot = RequireWritable(snapshot); if (snapshot.IsRawDetailFresh && snapshot.RawDetail is not null) { return new LegacyManualFinancialRawDeleteRequest( _authority, snapshot.Revision, snapshot.WriteEpoch, snapshot.RawDetail); } var expected = RequireFreshDetail(snapshot); return new LegacyManualFinancialDeleteRequest( _authority, snapshot.Revision, snapshot.WriteEpoch, expected); } public LegacyManualFinancialDeleteAllRequest CreateDeleteAllRequest( LegacyManualFinancialWorkflowSnapshot snapshot, string confirmationToken) { snapshot = RequireWritable(snapshot); if (!string.Equals( confirmationToken, snapshot.Definition.DeleteAllConfirmationToken, StringComparison.Ordinal)) { throw new ArgumentException( "The delete-all confirmation token is invalid.", nameof(confirmationToken)); } return new LegacyManualFinancialDeleteAllRequest( _authority, snapshot.Revision, snapshot.WriteEpoch, snapshot.Definition); } public async Task ExecuteAsync( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, CancellationToken cancellationToken = default) { snapshot = RequireWritable(snapshot); ArgumentNullException.ThrowIfNull(request); if (!ReferenceEquals(request.Authority, _authority) || request.SourceRevision != snapshot.Revision || request.SourceWriteEpoch != snapshot.WriteEpoch || request.Screen != snapshot.Screen) { throw new ArgumentException( "The manual-financial write request is stale or belongs to another workflow state.", nameof(request)); } cancellationToken.ThrowIfCancellationRequested(); if (!await _writeSafety.WriteGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) { throw new InvalidOperationException("Another manual-financial write is already in progress."); } try { snapshot = RequireWritable(snapshot); if (!_writeSafety.TryBeginWrite( snapshot.WriteEpoch, out var writeEpoch, out var rejectionReason)) { throw new InvalidOperationException(rejectionReason); } ManualFinancialMutationReceipt receipt; try { receipt = request switch { LegacyManualFinancialCreateRequest create => await _screenService.CreateAsync( create.Record, cancellationToken).ConfigureAwait(false), LegacyManualFinancialUpdateRequest update => await _screenService.UpdateAsync( update.Expected, update.Replacement, cancellationToken).ConfigureAwait(false), LegacyManualFinancialRawUpdateRequest update => await RequireRawService().UpdateRawAsync( update.Expected, update.Replacement, cancellationToken).ConfigureAwait(false), LegacyManualFinancialDeleteRequest delete => await _screenService.DeleteAsync( delete.Expected, cancellationToken).ConfigureAwait(false), LegacyManualFinancialRawDeleteRequest delete => await RequireRawService().DeleteRawAsync( delete.Expected, cancellationToken).ConfigureAwait(false), LegacyManualFinancialDeleteAllRequest => await _screenService.DeleteAllAsync( snapshot.Screen, cancellationToken).ConfigureAwait(false), _ => throw new ArgumentOutOfRangeException(nameof(request)) }; } catch (ManualFinancialMutationException exception) { return exception.OutcomeUnknown ? Quarantine(snapshot, request, writeEpoch, exception.Message) : Rejected(snapshot, request, writeEpoch, exception.Message); } catch (Exception exception) { return Quarantine( snapshot, request, writeEpoch, $"The {request.Kind} outcome is unknown: {exception.Message}"); } if (!IsCorrelatedReceipt(receipt, request)) { return Quarantine( snapshot, request, writeEpoch, "The write response was malformed or not correlated; its outcome is unknown."); } return request switch { LegacyManualFinancialCreateRequest or LegacyManualFinancialUpdateRequest => await CompleteSaveAsync( snapshot, request, receipt, writeEpoch, cancellationToken) .ConfigureAwait(false), LegacyManualFinancialRawUpdateRequest rawUpdate => await CompleteRawSaveAsync( snapshot, rawUpdate, receipt, writeEpoch, cancellationToken) .ConfigureAwait(false), LegacyManualFinancialDeleteRequest or LegacyManualFinancialRawDeleteRequest => CompleteDelete(snapshot, request, receipt, writeEpoch), LegacyManualFinancialDeleteAllRequest => CompleteDeleteAll(snapshot, request, receipt, writeEpoch), _ => throw new ArgumentOutOfRangeException(nameof(request)) }; } finally { _writeSafety.WriteGate.Release(); } } public LegacyManualFinancialPlaylistDraft MaterializePlaylistDraft( LegacyManualFinancialWorkflowSnapshot snapshot, string actionId) { snapshot = RequireOwned(snapshot); if (!ScreensByActionId.TryGetValue(actionId, out var definition) || definition.Screen != snapshot.Screen) { throw new ArgumentException("The manual-financial action id is invalid.", nameof(actionId)); } if (IsQuarantined(out _)) { throw new InvalidOperationException( "Playlist materialization is blocked while a write outcome is unknown."); } var identity = snapshot.SelectedIdentity ?? throw new InvalidOperationException( "A fresh manual-financial detail read is required."); var verified = RequireVerifiedForSelectedDetail(snapshot, identity); var raw = snapshot.IsRawDetailFresh ? snapshot.RawDetail : null; var detail = snapshot.IsDetailFresh ? snapshot.Detail : null; if (raw is null && detail is null) { throw new InvalidOperationException( "A fresh manual-financial detail read is required."); } var cut = ManualFinancialCutContracts.CreateSelection(identity, verified); return new LegacyManualFinancialPlaylistDraft( definition.ActionId, definition.BuilderKey, definition.Code, definition.Aliases, identity.StockName, definition.Label, "manual-financial", MarketName(verified.Market), verified.Code, verified.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq, cut, detail is null ? null : CloneSnapshot(detail), raw is null ? null : CloneRawSnapshot(raw)); } private async Task CompleteSaveAsync( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, ManualFinancialMutationReceipt receipt, long writeEpoch, CancellationToken cancellationToken) { var identity = new ManualFinancialIdentity(request.Screen, request.StockName); ManualFinancialSnapshot detail; try { detail = ValidateSnapshot( await _screenService.GetAsync(identity, cancellationToken).ConfigureAwait(false), request.Screen, "post-save read-back"); if (!EqualsIdentity(identity, detail.Record.Identity)) { throw InvalidData("The post-save read-back is not correlated to its write."); } } catch (Exception exception) { return snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, SelectedRowId = null, Detail = null, RawDetail = null, IsDetailFresh = false, IsRawDetailFresh = false, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.ReadBackRequired, WritesQuarantined = false, LastMessage = $"The write committed, but a fresh read-back is required: {exception.Message}", LastRequestId = request.RequestId, LastReceipt = receipt }; } var rows = UpsertRow(snapshot, detail, out var resultId); return snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, Rows = rows, SelectedRowId = resultId, Detail = detail, RawDetail = null, IsDetailFresh = true, IsRawDetailFresh = false, SelectedStockResultId = snapshot.SelectedStockResultId, Verified = request.VerifiedStock, VerifiedForRowVersion = detail.RowVersion, Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, WritesQuarantined = false, LastMessage = null, LastRequestId = request.RequestId, LastReceipt = receipt }; } private async Task CompleteRawSaveAsync( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialRawUpdateRequest request, ManualFinancialMutationReceipt receipt, long writeEpoch, CancellationToken cancellationToken) { ManualFinancialRawSnapshot raw; try { raw = ValidateRawSnapshot( await RequireRawService().GetRawAsync( request.Expected, cancellationToken).ConfigureAwait(false), request.Screen, "raw post-save read-back"); if (!EqualsIdentity(request.Replacement.Identity, raw.Record.Identity)) { throw InvalidData( "The raw post-save read-back is not correlated to its write."); } } catch (Exception exception) { return snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, SelectedRowId = null, Detail = null, RawDetail = null, IsDetailFresh = false, IsRawDetailFresh = false, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.ReadBackRequired, WritesQuarantined = false, LastMessage = $"The write committed, but a fresh raw read-back is required: {exception.Message}", LastRequestId = request.RequestId, LastReceipt = receipt }; } var resultId = snapshot.SelectedRowId ?? NewOpaqueId("mf-row"); var rows = snapshot.Rows.Any(row => string.Equals( row.ResultId, resultId, StringComparison.Ordinal)) ? ReplaceRow(snapshot.Rows, resultId, raw) : SortRows( snapshot.Rows.Append(new LegacyManualFinancialListRow(resultId, raw)), snapshot.NameSortDirection); return snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, Rows = rows, SelectedRowId = resultId, Detail = raw.StrictSnapshot, RawDetail = raw, IsDetailFresh = raw.StrictSnapshot is not null, IsRawDetailFresh = true, Verified = snapshot.Verified, VerifiedForRowVersion = snapshot.Verified is null ? null : raw.RowVersion, Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, WritesQuarantined = false, LastMessage = raw.IsPlayoutReady ? null : "기존 GraphE 원문 값으로 저장했습니다. 송출 시 장면 데이터 검증을 다시 수행합니다.", LastRequestId = request.RequestId, LastReceipt = receipt }; } private static LegacyManualFinancialWorkflowSnapshot CompleteDelete( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, ManualFinancialMutationReceipt receipt, long writeEpoch) { var selectedRowId = snapshot.SelectedRowId; var rows = snapshot.Rows .Where(row => selectedRowId is null || !string.Equals( row.ResultId, selectedRowId, StringComparison.Ordinal)) .ToArray(); return snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, Rows = Array.AsReadOnly(rows), SelectedRowId = null, Detail = null, RawDetail = null, IsDetailFresh = false, IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, WritesQuarantined = false, LastMessage = null, LastRequestId = request.RequestId, LastReceipt = receipt }; } private static LegacyManualFinancialWorkflowSnapshot CompleteDeleteAll( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, ManualFinancialMutationReceipt receipt, long writeEpoch) => snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, Rows = Array.Empty(), ListIsTruncated = false, ListRejectedItemCount = 0, SelectedRowId = null, Detail = null, RawDetail = null, IsDetailFresh = false, IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, WritesQuarantined = false, LastMessage = null, LastRequestId = request.RequestId, LastReceipt = receipt }; private LegacyManualFinancialWorkflowSnapshot Rejected( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, long writeEpoch, string message) => snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, Status = LegacyManualFinancialWorkflowStatus.WriteRejected, WritesQuarantined = false, LastMessage = message, LastRequestId = request.RequestId, LastReceipt = null }; private LegacyManualFinancialWorkflowSnapshot Quarantine( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, long writeEpoch, string message) { message = _writeSafety.Quarantine(message); return snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, Status = LegacyManualFinancialWorkflowStatus.OutcomeUnknown, WritesQuarantined = true, LastMessage = message, LastRequestId = request.RequestId, LastReceipt = null }; } private LegacyManualFinancialWorkflowSnapshot RequireOwned( LegacyManualFinancialWorkflowSnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); if (!ReferenceEquals(snapshot.Authority, _authority) || !ReferenceEquals(snapshot.Definition, GetScreen(snapshot.Screen))) { throw new ArgumentException( "The workflow snapshot belongs to another manual-financial workflow.", nameof(snapshot)); } if (!_writeSafety.IsCurrent(snapshot.WriteEpoch)) { throw new InvalidOperationException( "The manual-financial workflow snapshot is stale after a write attempt."); } return snapshot; } private LegacyManualFinancialWorkflowSnapshot RequireCurrentRevision( LegacyManualFinancialWorkflowSnapshot snapshot, long expectedRevision) { snapshot = RequireOwned(snapshot); if (expectedRevision < 0 || snapshot.Revision != expectedRevision) { throw new InvalidOperationException( "The manual-financial list command belongs to a stale view generation."); } return snapshot; } private LegacyManualFinancialWorkflowSnapshot RequireWritable( LegacyManualFinancialWorkflowSnapshot snapshot) { snapshot = RequireOwned(snapshot); if (!_screenService.CanMutate) { throw new InvalidOperationException( "Manual-financial screens are configured for read-only access."); } if (IsQuarantined(out var reason)) { throw new InvalidOperationException( $"Manual-financial writes are quarantined: {reason}"); } return snapshot; } private bool IsQuarantined(out string? reason) => _writeSafety.IsQuarantined(out reason); private IManualFinancialRawScreenService RequireRawService() => _rawScreenService ?? throw new InvalidOperationException( "Raw GraphE row compatibility is not available from this data service."); private static ManualFinancialSnapshot RequireFreshDetail( LegacyManualFinancialWorkflowSnapshot snapshot) => snapshot.IsDetailFresh && snapshot.Detail is not null ? snapshot.Detail : throw new InvalidOperationException( "A fresh manual-financial detail read is required."); private static ManualFinancialRawSnapshot RequireFreshRawDetail( LegacyManualFinancialWorkflowSnapshot snapshot) => snapshot.IsRawDetailFresh && snapshot.RawDetail is not null ? snapshot.RawDetail : throw new InvalidOperationException( "A fresh raw manual-financial detail read is required."); private static VerifiedManualFinancialStock RequireVerifiedForDetail( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialSnapshot detail) { if (snapshot.Verified is null || !string.Equals( snapshot.VerifiedForRowVersion, detail.RowVersion, StringComparison.Ordinal) || !string.Equals( snapshot.Verified.Name, detail.Record.Identity.StockName, StringComparison.Ordinal)) { throw new InvalidOperationException( "The selected detail requires a fresh exact stock-master verification."); } return snapshot.Verified; } private static VerifiedManualFinancialStock RequireVerifiedForSelectedDetail( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialIdentity identity) { if (snapshot.Verified is null || snapshot.SelectedRowVersion is not { } rowVersion || !string.Equals( snapshot.VerifiedForRowVersion, rowVersion, StringComparison.Ordinal) || !string.Equals( snapshot.Verified.Name, identity.StockName, StringComparison.Ordinal)) { throw new InvalidOperationException( "The selected detail requires a fresh exact stock-master verification."); } return snapshot.Verified; } private static LegacyManualFinancialListRow FindRow( IReadOnlyList rows, string resultId) { ArgumentException.ThrowIfNullOrWhiteSpace(resultId); return rows.SingleOrDefault(row => string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) ?? throw new ArgumentException("The manual-financial result id is invalid.", nameof(resultId)); } private static LegacyManualFinancialStockRow FindStock( IReadOnlyList rows, string resultId) { ArgumentException.ThrowIfNullOrWhiteSpace(resultId); return rows.SingleOrDefault(row => string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) ?? throw new ArgumentException("The stock result id is invalid.", nameof(resultId)); } private static IReadOnlyList ReplaceRow( IReadOnlyList rows, string resultId, ManualFinancialSnapshot detail) => Array.AsReadOnly(rows.Select(row => string.Equals(row.ResultId, resultId, StringComparison.Ordinal) ? new LegacyManualFinancialListRow(resultId, detail) : row) .ToArray()); private static IReadOnlyList ReplaceRow( IReadOnlyList rows, string resultId, ManualFinancialRawSnapshot detail) => Array.AsReadOnly(rows.Select(row => string.Equals(row.ResultId, resultId, StringComparison.Ordinal) ? new LegacyManualFinancialListRow(resultId, detail) : row) .ToArray()); private static IReadOnlyList UpsertRow( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialSnapshot detail, out string resultId) { var existing = snapshot.Rows.FirstOrDefault(row => string.Equals( row.StockName, detail.Record.Identity.StockName, StringComparison.Ordinal)); resultId = existing?.ResultId ?? NewOpaqueId("mf-row"); var rows = snapshot.Rows .Where(row => !string.Equals( row.StockName, detail.Record.Identity.StockName, StringComparison.Ordinal)) .ToList(); if (snapshot.Query.Length == 0 || detail.Record.Identity.StockName.Contains( snapshot.Query, StringComparison.OrdinalIgnoreCase)) { rows.Add(new LegacyManualFinancialListRow(resultId, detail)); } return SortRows(rows, snapshot.NameSortDirection); } private static IReadOnlyList SortRows( IEnumerable rows, LegacyManualFinancialNameSortDirection direction) { if (!Enum.IsDefined(direction)) { throw new ArgumentOutOfRangeException(nameof(direction)); } var result = rows.ToList(); result.Sort((left, right) => { var compared = StringComparer.Ordinal.Compare(left.StockName, right.StockName); return direction == LegacyManualFinancialNameSortDirection.Ascending ? compared : -compared; }); return Array.AsReadOnly(result.ToArray()); } private static bool IsCorrelatedReceipt( ManualFinancialMutationReceipt? receipt, LegacyManualFinancialWriteRequest request) => receipt is not null && receipt.OperationId != Guid.Empty && receipt.Kind == request.Kind && receipt.Screen == request.Screen && string.Equals(receipt.StockName, request.StockName, StringComparison.Ordinal) && (request.Kind == ManualFinancialMutationKind.DeleteAll ? receipt.AffectedRows >= 0 : receipt.AffectedRows == 1); private static ManualFinancialRawSnapshot ValidateRawSnapshot( ManualFinancialRawSnapshot? snapshot, ManualFinancialScreenKind screen, string source) { try { ArgumentNullException.ThrowIfNull(snapshot); var record = ValidateRawRecord(snapshot.Record, screen, nameof(snapshot)); if (snapshot.RowVersion is null || snapshot.RowVersion.Length != 64 || snapshot.RowVersion.Any(static character => !char.IsAsciiDigit(character) && character is not (>= 'A' and <= 'F')) || snapshot.Handle is null) { throw new ArgumentException("The raw row envelope is invalid."); } ManualFinancialSnapshot? strict = null; if (snapshot.StrictSnapshot is not null) { strict = ValidateSnapshot(snapshot.StrictSnapshot, screen, source); if (!EqualsIdentity(strict.Record.Identity, record.Identity) || !string.Equals( strict.RowVersion, snapshot.RowVersion, StringComparison.Ordinal)) { throw new ArgumentException( "The strict projection is not correlated to the raw row."); } } return new ManualFinancialRawSnapshot( record, snapshot.RowVersion, snapshot.Handle, strict); } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) { throw InvalidData($"The manual-financial {source} is invalid: {exception.Message}"); } } private static ManualFinancialRawRecord ValidateRawRecord( ManualFinancialRawRecord? record, ManualFinancialScreenKind screen, string parameterName) { ArgumentNullException.ThrowIfNull(record, parameterName); ArgumentNullException.ThrowIfNull(record.Identity, parameterName); if (record.Identity.Screen != screen || record.Identity.StockName is null || record.Identity.StockName.Length is < 1 or > 200 || record.Identity.StockName != record.Identity.StockName.Trim() || record.StorageValues is null) { throw new ArgumentException( "The raw manual-financial record identity is invalid.", parameterName); } var expectedCount = ManualFinancialCutContracts.Get(screen).StorageColumns.Count - 1; if (record.StorageValues.Count != expectedCount) { throw new ArgumentException( "The raw manual-financial record field count is invalid.", parameterName); } var values = new string[expectedCount]; for (var index = 0; index < values.Length; index++) { var value = record.StorageValues[index]; if (value is null || value.Length > 200 || value.Any(static character => char.IsSurrogate(character) || character == '\uFFFD')) { throw new ArgumentException( "A raw manual-financial field is invalid.", parameterName); } values[index] = value; } return new ManualFinancialRawRecord( new ManualFinancialIdentity(screen, record.Identity.StockName), Array.AsReadOnly(values)); } private static ManualFinancialSnapshot ValidateSnapshot( ManualFinancialSnapshot? snapshot, ManualFinancialScreenKind screen, string source) { try { ArgumentNullException.ThrowIfNull(snapshot); var copy = CloneSnapshot(snapshot); if (copy.Record.Identity.Screen != screen) { throw new ArgumentException("The response screen does not match."); } var validationStock = ValidationStock(copy.Record.Identity); _ = ManualFinancialCutContracts.CreateSelection(copy, validationStock); return copy; } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) { throw InvalidData($"The manual-financial {source} is invalid: {exception.Message}"); } } private static ManualFinancialRecord ValidateRecord( ManualFinancialRecord? record, ManualFinancialScreenKind screen, VerifiedManualFinancialStock stock, string parameterName) { ArgumentNullException.ThrowIfNull(record, parameterName); var copy = CloneRecord(record); if (copy.Identity.Screen != screen) { throw new ArgumentException( "The manual-financial record does not match its screen.", parameterName); } try { _ = ManualFinancialCutContracts.CreateSelection( new ManualFinancialSnapshot(copy, ValidationRowVersion), stock); } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) { throw new ArgumentException( $"The manual-financial record is invalid: {exception.Message}", parameterName, exception); } return copy; } private static ManualFinancialSnapshot CloneSnapshot(ManualFinancialSnapshot snapshot) => new(CloneRecord(snapshot.Record), snapshot.RowVersion); private static ManualFinancialRawSnapshot CloneRawSnapshot( ManualFinancialRawSnapshot snapshot) => new( new ManualFinancialRawRecord( new ManualFinancialIdentity( snapshot.Record.Identity.Screen, snapshot.Record.Identity.StockName), Array.AsReadOnly(snapshot.Record.StorageValues.ToArray())), snapshot.RowVersion, snapshot.Handle, snapshot.StrictSnapshot is null ? null : CloneSnapshot(snapshot.StrictSnapshot)); private static ManualFinancialRecord CloneRecord(ManualFinancialRecord record) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(record.Identity); var identity = new ManualFinancialIdentity( record.Identity.Screen, record.Identity.StockName); return record switch { ManualRevenueCompositionRecord revenue => new ManualRevenueCompositionRecord( identity, revenue.BaseDate, Array.AsReadOnly((revenue.Slices ?? throw new ArgumentException( "Revenue slices are required.", nameof(record))) .Select(slice => slice is null ? null : new ManualRevenueSlice( slice.Label, slice.PercentageText, slice.Percentage)) .ToArray())), ManualGrowthMetricsRecord growth => new ManualGrowthMetricsRecord( identity, CloneGrowth(growth.SalesGrowth), CloneGrowth(growth.OperatingProfitGrowth), CloneGrowth(growth.NetAssetGrowth), CloneGrowth(growth.NetIncomeGrowth), ClonePeriods(growth.Periods)), ManualSalesRecord sales => new ManualSalesRecord(identity, CloneQuarters(sales.Quarters)), ManualOperatingProfitRecord profit => new ManualOperatingProfitRecord(identity, CloneQuarters(profit.Quarters)), _ => throw new ArgumentException( "The manual-financial record type is invalid.", nameof(record)) }; } private static ManualGrowthSeriesValues CloneGrowth(ManualGrowthSeriesValues? values) { ArgumentNullException.ThrowIfNull(values); return new ManualGrowthSeriesValues( values.First, values.Second, values.Third, values.Fourth); } private static ManualGrowthPeriodLabels ClonePeriods(ManualGrowthPeriodLabels? periods) { ArgumentNullException.ThrowIfNull(periods); return new ManualGrowthPeriodLabels( periods.First, periods.Second, periods.Third, periods.Fourth); } private static IReadOnlyList CloneQuarters( IReadOnlyList? quarters) { ArgumentNullException.ThrowIfNull(quarters); return Array.AsReadOnly(quarters.Select(quarter => quarter is null ? throw new ArgumentException("A manual-financial quarter is invalid.") : new ManualQuarterValue(quarter.Quarter, quarter.Value)) .ToArray()); } private static VerifiedManualFinancialStock ValidationStock( ManualFinancialIdentity identity) { var candidate = new StockSearchItem( StockMarket.Kospi, DataSourceKind.Oracle, identity.StockName, ValidationStockCode); return ManualFinancialCutContracts.VerifyStock( identity, [candidate], candidate.Market, candidate.Code); } private static void ValidateStockItem(StockSearchItem? item) { if (item is null) { throw InvalidData("The stock-search response contains a null item."); } try { var identity = new ManualFinancialIdentity( ManualFinancialScreenKind.Sales, item.Name); _ = ManualFinancialCutContracts.VerifyStock( identity, [item], item.Market, item.Code); } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) { throw InvalidData($"The stock-search response is invalid: {exception.Message}"); } } private static string NormalizeQuery(string value, bool allowEmpty) { ArgumentNullException.ThrowIfNull(value); var normalized = value.Trim(); if ((!allowEmpty && normalized.Length == 0) || normalized.Length > 64 || normalized.Any(static character => char.IsControl(character) || char.IsSurrogate(character) || character == '\uFFFD')) { throw new ArgumentException("The search text is invalid.", nameof(value)); } return normalized; } private static string ValidateListStockName(string value) { if (value is null || value.Length is < 1 or > 200 || value != value.Trim() || value.Contains('^') || value.Any(static character => char.IsControl(character) || char.IsSurrogate(character) || character == '\uFFFD' || char.GetUnicodeCategory(character) is System.Globalization.UnicodeCategory.Format or System.Globalization.UnicodeCategory.LineSeparator or System.Globalization.UnicodeCategory.ParagraphSeparator)) { throw InvalidData( "The manual-financial unreadable-row identity is invalid."); } return value; } private static bool EqualsIdentity( ManualFinancialIdentity left, ManualFinancialIdentity right) => left.Screen == right.Screen && string.Equals(left.StockName, right.StockName, StringComparison.Ordinal); private static string MarketName(StockMarket market) => market switch { StockMarket.Kospi => "KOSPI", StockMarket.Kosdaq => "KOSDAQ", StockMarket.NxtKospi => "NXT_KOSPI", StockMarket.NxtKosdaq => "NXT_KOSDAQ", _ => throw new ArgumentOutOfRangeException(nameof(market)) }; private static string NewOpaqueId(string prefix) => prefix + "-" + Guid.NewGuid().ToString("N"); private static LegacyManualFinancialWorkflowDataException InvalidData(string message) => new(message); private static IReadOnlyList CreateDefinitions() => Array.AsReadOnly( [ new( ManualFinancialScreenKind.RevenueComposition, "revenue-composition", "주요매출 구성", "INPUT_PIE", "s5076", "5076", "REVENUE_COMPOSITION", "DELETE_ALL_INPUT_PIE"), new( ManualFinancialScreenKind.GrowthMetrics, "growth-metrics", "성장성 지표", "INPUT_GROW", "s5079", "5079", "GROWTH_METRICS", "DELETE_ALL_INPUT_GROW"), new( ManualFinancialScreenKind.Sales, "sales", "매출액", "INPUT_SELL", "s5080", "5080", "SALES", "DELETE_ALL_INPUT_SELL"), new( ManualFinancialScreenKind.OperatingProfit, "operating-profit", "영업이익", "INPUT_PROFIT", "s5081", "5081", "OPERATING_PROFIT", "DELETE_ALL_INPUT_PROFIT") ]); private static void ValidateDefinitions( IReadOnlyList definitions) { if (definitions.Count != 4 || definitions.Select(definition => definition.Screen).Distinct().Count() != 4 || definitions.Select(definition => definition.ActionId) .Distinct(StringComparer.Ordinal).Count() != 4) { throw new InvalidOperationException( "The manual-financial screen registry is incomplete or ambiguous."); } foreach (var definition in definitions) { var core = ManualFinancialCutContracts.Get(definition.Screen); if (!string.Equals(core.TableName, definition.TableName, StringComparison.Ordinal) || !string.Equals(core.BuilderKey, definition.BuilderKey, StringComparison.Ordinal) || !string.Equals(core.CutCode, definition.Code, StringComparison.Ordinal) || !string.Equals( core.CanonicalGraphicType, definition.CanonicalGraphicType, StringComparison.Ordinal)) { throw new InvalidOperationException( $"The {definition.Screen} application contract differs from Core."); } } } }