2248 lines
81 KiB
C#
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.");
|
|
}
|
|
}
|
|
}
|
|
}
|