1647 lines
58 KiB
C#
1647 lines
58 KiB
C#
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<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;
|
|
}
|
|
|
|
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<string> 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<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 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<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 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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 = 200;
|
|
public const int MaximumStockResults = 100;
|
|
|
|
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 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<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)
|
|
{
|
|
throw InvalidData("The manual-financial list response is not correlated to its request.");
|
|
}
|
|
|
|
var rows = new List<LegacyManualFinancialListRow>(result.Items.Count);
|
|
var identities = new HashSet<string>(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<LegacyManualFinancialStockRow>(),
|
|
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<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 = 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<LegacyManualFinancialStockRow>(),
|
|
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<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: 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<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);
|
|
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<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),
|
|
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<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,
|
|
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<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,
|
|
SelectedRowId = null,
|
|
Detail = null,
|
|
IsDetailFresh = 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 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<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> 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<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 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<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 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.");
|
|
}
|
|
}
|
|
}
|
|
}
|