Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs

2248 lines
81 KiB
C#

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<string> 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; }
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<string> 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<string> 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<string> 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<LegacyManualFinancialListRow>();
StockQuery = string.Empty;
StockCandidates = Array.Empty<LegacyManualFinancialStockRow>();
}
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<LegacyManualFinancialListRow> 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<LegacyManualFinancialStockRow> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
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<LegacyManualFinancialScreenDefinition>
RegisteredScreens = CreateDefinitions();
private static readonly IReadOnlyDictionary<ManualFinancialScreenKind,
LegacyManualFinancialScreenDefinition> ScreensByKind =
new ReadOnlyDictionary<ManualFinancialScreenKind, LegacyManualFinancialScreenDefinition>(
RegisteredScreens.ToDictionary(definition => definition.Screen));
private static readonly IReadOnlyDictionary<string, LegacyManualFinancialScreenDefinition>
ScreensByActionId =
new ReadOnlyDictionary<string, LegacyManualFinancialScreenDefinition>(
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<LegacyManualFinancialScreenDefinition> 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<LegacyManualFinancialWorkflowSnapshot> 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<LegacyManualFinancialListRow>(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<string>();
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<LegacyManualFinancialStockRow>(),
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<LegacyManualFinancialStockRow>(),
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<LegacyManualFinancialStockRow>()
: 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<LegacyManualFinancialWorkflowSnapshot> 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<LegacyManualFinancialListRow> 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<LegacyManualFinancialStockRow>(),
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<LegacyManualFinancialStockRow>(),
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<LegacyManualFinancialWorkflowSnapshot> 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<LegacyManualFinancialStockRow>(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<LegacyManualFinancialWorkflowSnapshot> 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<LegacyManualFinancialWorkflowSnapshot> 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<LegacyManualFinancialWorkflowSnapshot> 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<LegacyManualFinancialStockRow>(),
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<LegacyManualFinancialListRow>(),
ListIsTruncated = false,
ListRejectedItemCount = 0,
SelectedRowId = null,
Detail = null,
RawDetail = null,
IsDetailFresh = false,
IsRawDetailFresh = false,
StockQuery = string.Empty,
StockCandidates = Array.Empty<LegacyManualFinancialStockRow>(),
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<LegacyManualFinancialListRow> 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<LegacyManualFinancialStockRow> 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<LegacyManualFinancialListRow> ReplaceRow(
IReadOnlyList<LegacyManualFinancialListRow> 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<LegacyManualFinancialListRow> ReplaceRow(
IReadOnlyList<LegacyManualFinancialListRow> 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<LegacyManualFinancialListRow> 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<LegacyManualFinancialListRow> SortRows(
IEnumerable<LegacyManualFinancialListRow> 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<ManualQuarterValue> CloneQuarters(
IReadOnlyList<ManualQuarterValue>? 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<LegacyManualFinancialScreenDefinition> CreateDefinitions() =>
Array.AsReadOnly<LegacyManualFinancialScreenDefinition>(
[
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<LegacyManualFinancialScreenDefinition> 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.");
}
}
}
}