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

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.");
}
}
}
}