using System.Collections.Concurrent; using System.Collections.ObjectModel; 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; } public string ResultId { get; } public string StockName => Snapshot.Record.Identity.StockName; public string RowVersion => Snapshot.RowVersion; internal ManualFinancialSnapshot Snapshot { get; } } 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); 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 string? SelectedRowId { get; internal init; } public ManualFinancialRecord? SelectedRecord => Detail?.Record; public string? SelectedRowVersion => Detail?.RowVersion; public bool IsDetailFresh { get; internal init; } 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 CanSave => !WritesQuarantined && 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 && IsDetailFresh && Detail is not null; public bool CanMaterializePlaylist => CanDelete && Verified is not null && string.Equals(VerifiedForRowVersion, Detail!.RowVersion, StringComparison.Ordinal) && string.Equals( Verified.Name, Detail.Record.Identity.StockName, StringComparison.Ordinal); internal object Authority { get; } internal long WriteEpoch { get; init; } internal ManualFinancialSnapshot? Detail { get; init; } 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; } 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 string ExpectedRowVersion => Expected.RowVersion; public ManualFinancialRecord Replacement { get; } internal ManualFinancialSnapshot Expected { get; } internal override VerifiedManualFinancialStock VerifiedStock => Stock; private VerifiedManualFinancialStock Stock { 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 string ExpectedRowVersion => Expected.RowVersion; internal ManualFinancialSnapshot 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 = 200; public const int MaximumStockResults = 100; 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 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)); _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) { throw InvalidData("The manual-financial list response is not correlated to its request."); } var rows = new List(result.Items.Count); var identities = new HashSet(StringComparer.Ordinal); foreach (var item in result.Items) { var validated = ValidateSnapshot(item, snapshot.Screen, "list response"); if (!identities.Add(validated.Record.Identity.StockName)) { throw InvalidData("The manual-financial list contains an ambiguous STOCK_NAME identity."); } rows.Add(new LegacyManualFinancialListRow(NewOpaqueId("mf-row"), validated)); } rows.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.StockName, right.StockName)); return snapshot with { Revision = checked(snapshot.Revision + 1), Query = normalizedQuery, FindQuery = string.Empty, NameSortDirection = LegacyManualFinancialNameSortDirection.Ascending, Rows = Array.AsReadOnly(rows.ToArray()), ListIsTruncated = result.IsTruncated, SelectedRowId = null, Detail = null, IsDetailFresh = 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 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 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, IsDetailFresh = matchIndex < 0 && snapshot.IsDetailFresh, 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 = row.Snapshot.Record.Identity; var detail = await _screenService.GetAsync(identity, cancellationToken).ConfigureAwait(false); var 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."); } var rows = ReplaceRow(snapshot.Rows, resultId, validated); return snapshot with { Revision = checked(snapshot.Revision + 1), Rows = rows, SelectedRowId = resultId, Detail = validated, IsDetailFresh = true, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, SelectedStockResultId = null, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.DetailLoaded, WritesQuarantined = IsQuarantined(out var reason), LastMessage = reason, LastRequestId = null, LastReceipt = null }; } public LegacyManualFinancialWorkflowSnapshot BeginCreate( LegacyManualFinancialWorkflowSnapshot snapshot) { snapshot = RequireOwned(snapshot); return snapshot with { Revision = checked(snapshot.Revision + 1), SelectedRowId = null, Detail = null, IsDetailFresh = 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: false); 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); var row = FindStock(snapshot.StockCandidates, stockResultId); var 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.Detail is null ? null : snapshot.Detail.RowVersion; 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 LegacyManualFinancialDeleteRequest CreateDeleteRequest( LegacyManualFinancialWorkflowSnapshot snapshot) { snapshot = RequireWritable(snapshot); 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), LegacyManualFinancialDeleteRequest delete => await _screenService.DeleteAsync( 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), LegacyManualFinancialDeleteRequest => 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 detail = RequireFreshDetail(snapshot); var verified = RequireVerifiedForDetail(snapshot, detail); var cut = ManualFinancialCutContracts.CreateSelection(detail, verified); return new LegacyManualFinancialPlaylistDraft( definition.ActionId, definition.BuilderKey, definition.Code, definition.Aliases, detail.Record.Identity.StockName, definition.Label, "manual-financial", MarketName(verified.Market), verified.Code, verified.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq, cut, CloneSnapshot(detail)); } 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, IsDetailFresh = 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, IsDetailFresh = true, SelectedStockResultId = snapshot.SelectedStockResultId, Verified = request.VerifiedStock, VerifiedForRowVersion = detail.RowVersion, Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, WritesQuarantined = false, LastMessage = null, LastRequestId = request.RequestId, LastReceipt = receipt }; } private static LegacyManualFinancialWorkflowSnapshot CompleteDelete( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, ManualFinancialMutationReceipt receipt, long writeEpoch) { var rows = snapshot.Rows .Where(row => !string.Equals( row.StockName, request.StockName, StringComparison.Ordinal)) .ToArray(); return snapshot with { Revision = checked(snapshot.Revision + 1), WriteEpoch = writeEpoch, Rows = Array.AsReadOnly(rows), SelectedRowId = null, Detail = null, IsDetailFresh = 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, SelectedRowId = null, Detail = null, IsDetailFresh = 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 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 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 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 UpsertRow( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialSnapshot detail, out string resultId) { var existing = snapshot.Rows.SingleOrDefault(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 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 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 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."); } } } }