2047 lines
71 KiB
C#
2047 lines
71 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
|
using MMoneyCoderSharp.Data;
|
|
|
|
namespace MBN_STOCK_WEBVIEW;
|
|
|
|
public sealed partial class MainWindow
|
|
{
|
|
private const string ManualFinancialCorrelationQuarantineMessage =
|
|
"A manual-financial database write result could not be confirmed. Restart the app and reconcile INPUT_* before another write.";
|
|
|
|
private readonly SemaphoreSlim _manualFinancialWriteGate = new(1, 1);
|
|
private readonly object _manualFinancialReadLanesGate = new();
|
|
private readonly Dictionary<string, ManualFinancialReadRequest> _manualFinancialReadLanes =
|
|
new(StringComparer.Ordinal);
|
|
private IManualFinancialScreenService? _manualFinancialScreenService;
|
|
private CancellationTokenSource? _manualFinancialWriteCancellation;
|
|
private string? _manualFinancialInitializationError;
|
|
private string? _manualFinancialWriteQuarantineMessage;
|
|
private int _manualFinancialRuntimeState;
|
|
private int _manualFinancialWriteInFlight;
|
|
private int _manualFinancialWriteQuarantined;
|
|
private int _manualFinancialBrowserGeneration;
|
|
|
|
/// <summary>
|
|
/// Root integration hook. Call after InitializeDatabaseRuntime. Every request
|
|
/// handler also calls it lazily, so an omitted eager call fails closed.
|
|
/// </summary>
|
|
private void InitializeManualFinancialRuntime()
|
|
{
|
|
if (Volatile.Read(ref _manualFinancialRuntimeState) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var runtime = _databaseRuntime;
|
|
if (runtime is null ||
|
|
Interlocked.CompareExchange(ref _manualFinancialRuntimeState, 1, 0) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_manualFinancialScreenService = new LegacyManualFinancialScreenService(
|
|
runtime.Executor,
|
|
new OracleManualFinancialMutationExecutor(
|
|
runtime.ConnectionFactory,
|
|
runtime.Options.Resilience));
|
|
}
|
|
catch
|
|
{
|
|
_manualFinancialScreenService = null;
|
|
_manualFinancialInitializationError =
|
|
"The manual-financial Oracle boundary could not be initialized.";
|
|
Volatile.Write(ref _manualFinancialRuntimeState, -1);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Root WebMessage hook. True means the request belongs to this boundary,
|
|
/// including malformed payloads which receive an INVALID_REQUEST response.
|
|
/// </summary>
|
|
private bool TryHandleManualFinancialRequest(string? requestType, JsonElement payload)
|
|
{
|
|
switch (requestType)
|
|
{
|
|
case "request-manual-financial-list":
|
|
_ = HandleManualFinancialListRequestAsync(payload);
|
|
return true;
|
|
case "request-manual-financial-load":
|
|
_ = HandleManualFinancialLoadRequestAsync(payload);
|
|
return true;
|
|
case "create-manual-financial-record":
|
|
_ = HandleManualFinancialCreateRequestAsync(payload);
|
|
return true;
|
|
case "update-manual-financial-record":
|
|
_ = HandleManualFinancialUpdateRequestAsync(payload);
|
|
return true;
|
|
case "delete-manual-financial-record":
|
|
_ = HandleManualFinancialDeleteRequestAsync(payload);
|
|
return true;
|
|
case "delete-all-manual-financial-records":
|
|
_ = HandleManualFinancialDeleteAllRequestAsync(payload);
|
|
return true;
|
|
case "request-manual-financial-selection":
|
|
_ = HandleManualFinancialSelectionRequestAsync(payload);
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task HandleManualFinancialListRequestAsync(JsonElement payload)
|
|
{
|
|
if (!TryParseManualFinancialListRequest(
|
|
payload,
|
|
out var requestId,
|
|
out var screen,
|
|
out var query,
|
|
out var maximumResults))
|
|
{
|
|
PostManualFinancialInvalidRequest(
|
|
SafeManualFinancialRequestId(payload),
|
|
"list",
|
|
SafeManualFinancialScreen(payload),
|
|
query: SafeManualFinancialQuery(payload));
|
|
return;
|
|
}
|
|
|
|
InitializeManualFinancialRuntime();
|
|
var service = _manualFinancialScreenService;
|
|
if (service is null)
|
|
{
|
|
PostManualFinancialUnavailable(requestId, "list", ToWireValue(screen), query: query);
|
|
return;
|
|
}
|
|
|
|
await ExecuteManualFinancialReadAsync(
|
|
requestId,
|
|
"list",
|
|
screen,
|
|
query,
|
|
string.Empty,
|
|
async cancellationToken =>
|
|
{
|
|
var result = await service.SearchAsync(
|
|
screen,
|
|
query,
|
|
maximumResults,
|
|
cancellationToken);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return new ManualFinancialBridgeReply(
|
|
"manual-financial-list-results",
|
|
new
|
|
{
|
|
requestId,
|
|
screen = ToWireValue(screen),
|
|
query = result.Query,
|
|
result.RetrievedAt,
|
|
totalRowCount = result.Items.Count,
|
|
truncated = result.IsTruncated,
|
|
items = result.Items.Select(CreateManualFinancialSnapshotPayload)
|
|
});
|
|
});
|
|
}
|
|
|
|
private async Task HandleManualFinancialLoadRequestAsync(JsonElement payload)
|
|
{
|
|
if (!TryParseManualFinancialIdentityRequest(
|
|
payload,
|
|
["requestId", "screen", "stockName"],
|
|
out var requestId,
|
|
out var identity))
|
|
{
|
|
PostManualFinancialInvalidRequest(
|
|
SafeManualFinancialRequestId(payload),
|
|
"load",
|
|
SafeManualFinancialScreen(payload),
|
|
stockName: SafeManualFinancialStockName(payload));
|
|
return;
|
|
}
|
|
|
|
InitializeManualFinancialRuntime();
|
|
var service = _manualFinancialScreenService;
|
|
if (service is null)
|
|
{
|
|
PostManualFinancialUnavailable(
|
|
requestId,
|
|
"load",
|
|
ToWireValue(identity.Screen),
|
|
stockName: identity.StockName);
|
|
return;
|
|
}
|
|
|
|
await ExecuteManualFinancialReadAsync(
|
|
requestId,
|
|
"load",
|
|
identity.Screen,
|
|
string.Empty,
|
|
identity.StockName,
|
|
async cancellationToken =>
|
|
{
|
|
var snapshot = await service.GetAsync(identity, cancellationToken);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return new ManualFinancialBridgeReply(
|
|
"manual-financial-load-results",
|
|
new
|
|
{
|
|
requestId,
|
|
screen = ToWireValue(identity.Screen),
|
|
retrievedAt = DateTimeOffset.UtcNow,
|
|
snapshot = CreateManualFinancialSnapshotPayload(snapshot)
|
|
});
|
|
});
|
|
}
|
|
|
|
private async Task HandleManualFinancialSelectionRequestAsync(JsonElement payload)
|
|
{
|
|
if (!TryParseManualFinancialSelectionRequest(
|
|
payload,
|
|
out var requestId,
|
|
out var identity,
|
|
out var rowVersion,
|
|
out var stock))
|
|
{
|
|
PostManualFinancialInvalidRequest(
|
|
SafeManualFinancialRequestId(payload),
|
|
"selection",
|
|
SafeManualFinancialScreen(payload),
|
|
stockName: SafeManualFinancialStockName(payload));
|
|
return;
|
|
}
|
|
|
|
InitializeManualFinancialRuntime();
|
|
var service = _manualFinancialScreenService;
|
|
if (service is null || _stockSearchService is null)
|
|
{
|
|
PostManualFinancialUnavailable(
|
|
requestId,
|
|
"selection",
|
|
ToWireValue(identity.Screen),
|
|
stockName: identity.StockName);
|
|
return;
|
|
}
|
|
|
|
await ExecuteManualFinancialReadAsync(
|
|
requestId,
|
|
"selection",
|
|
identity.Screen,
|
|
string.Empty,
|
|
identity.StockName,
|
|
async cancellationToken =>
|
|
{
|
|
var snapshot = await service.GetAsync(identity, cancellationToken);
|
|
EnsureManualFinancialRowVersion(snapshot, rowVersion);
|
|
var verified = await ResolveManualFinancialStockAsync(
|
|
identity,
|
|
stock,
|
|
cancellationToken);
|
|
var result = ManualFinancialCutContracts.CreateSelection(snapshot, verified);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return new ManualFinancialBridgeReply(
|
|
"manual-financial-selection-results",
|
|
new
|
|
{
|
|
requestId,
|
|
screen = ToWireValue(identity.Screen),
|
|
stockName = identity.StockName,
|
|
rowVersion = snapshot.RowVersion,
|
|
result.BuilderKey,
|
|
code = result.CutCode,
|
|
aliases = new[] { result.CutCode },
|
|
selection = new
|
|
{
|
|
result.Selection.GroupCode,
|
|
result.Selection.Subject,
|
|
result.Selection.GraphicType,
|
|
result.Selection.Subtype,
|
|
result.Selection.DataCode
|
|
},
|
|
result.CurrentPage,
|
|
result.TotalPages
|
|
});
|
|
});
|
|
}
|
|
|
|
private async Task HandleManualFinancialCreateRequestAsync(JsonElement payload)
|
|
{
|
|
if (!TryParseManualFinancialCreateRequest(
|
|
payload,
|
|
out var requestId,
|
|
out var record,
|
|
out var stock))
|
|
{
|
|
PostManualFinancialInvalidRequest(
|
|
SafeManualFinancialRequestId(payload),
|
|
"create",
|
|
SafeManualFinancialScreen(payload),
|
|
stockName: SafeManualFinancialRecordStockName(payload));
|
|
return;
|
|
}
|
|
|
|
InitializeManualFinancialRuntime();
|
|
await ExecuteManualFinancialWriteAsync(
|
|
requestId,
|
|
"create",
|
|
record.Identity.Screen,
|
|
record.Identity.StockName,
|
|
async cancellationToken =>
|
|
{
|
|
_ = await ResolveManualFinancialStockAsync(
|
|
record.Identity,
|
|
stock,
|
|
cancellationToken);
|
|
},
|
|
(service, cancellationToken) => service.CreateAsync(record, cancellationToken));
|
|
}
|
|
|
|
private async Task HandleManualFinancialUpdateRequestAsync(JsonElement payload)
|
|
{
|
|
if (!TryParseManualFinancialUpdateRequest(
|
|
payload,
|
|
out var requestId,
|
|
out var replacement,
|
|
out var expectedRowVersion))
|
|
{
|
|
PostManualFinancialInvalidRequest(
|
|
SafeManualFinancialRequestId(payload),
|
|
"update",
|
|
SafeManualFinancialScreen(payload),
|
|
stockName: SafeManualFinancialStockName(payload));
|
|
return;
|
|
}
|
|
|
|
InitializeManualFinancialRuntime();
|
|
ManualFinancialSnapshot? expected = null;
|
|
await ExecuteManualFinancialWriteAsync(
|
|
requestId,
|
|
"update",
|
|
replacement.Identity.Screen,
|
|
replacement.Identity.StockName,
|
|
async cancellationToken =>
|
|
{
|
|
var service = RequireManualFinancialService();
|
|
expected = await service.GetAsync(replacement.Identity, cancellationToken);
|
|
EnsureManualFinancialRowVersion(expected, expectedRowVersion);
|
|
},
|
|
(service, cancellationToken) => service.UpdateAsync(
|
|
expected ?? throw new InvalidOperationException("Update preflight did not complete."),
|
|
replacement,
|
|
cancellationToken));
|
|
}
|
|
|
|
private async Task HandleManualFinancialDeleteRequestAsync(JsonElement payload)
|
|
{
|
|
if (!TryParseManualFinancialDeleteRequest(
|
|
payload,
|
|
out var requestId,
|
|
out var identity,
|
|
out var expectedRowVersion))
|
|
{
|
|
PostManualFinancialInvalidRequest(
|
|
SafeManualFinancialRequestId(payload),
|
|
"delete-one",
|
|
SafeManualFinancialScreen(payload),
|
|
stockName: SafeManualFinancialStockName(payload));
|
|
return;
|
|
}
|
|
|
|
InitializeManualFinancialRuntime();
|
|
ManualFinancialSnapshot? expected = null;
|
|
await ExecuteManualFinancialWriteAsync(
|
|
requestId,
|
|
"delete-one",
|
|
identity.Screen,
|
|
identity.StockName,
|
|
async cancellationToken =>
|
|
{
|
|
var service = RequireManualFinancialService();
|
|
expected = await service.GetAsync(identity, cancellationToken);
|
|
EnsureManualFinancialRowVersion(expected, expectedRowVersion);
|
|
},
|
|
(service, cancellationToken) => service.DeleteAsync(
|
|
expected ?? throw new InvalidOperationException("Delete preflight did not complete."),
|
|
cancellationToken));
|
|
}
|
|
|
|
private async Task HandleManualFinancialDeleteAllRequestAsync(JsonElement payload)
|
|
{
|
|
if (!TryParseManualFinancialDeleteAllRequest(
|
|
payload,
|
|
out var requestId,
|
|
out var screen))
|
|
{
|
|
PostManualFinancialInvalidRequest(
|
|
SafeManualFinancialRequestId(payload),
|
|
"delete-all",
|
|
SafeManualFinancialScreen(payload));
|
|
return;
|
|
}
|
|
|
|
InitializeManualFinancialRuntime();
|
|
await ExecuteManualFinancialWriteAsync(
|
|
requestId,
|
|
"delete-all",
|
|
screen,
|
|
string.Empty,
|
|
preflight: null,
|
|
(service, cancellationToken) => service.DeleteAllAsync(screen, cancellationToken));
|
|
}
|
|
|
|
private async Task ExecuteManualFinancialReadAsync(
|
|
string requestId,
|
|
string operation,
|
|
ManualFinancialScreenKind screen,
|
|
string query,
|
|
string stockName,
|
|
Func<CancellationToken, Task<ManualFinancialBridgeReply>> operationBody)
|
|
{
|
|
var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
|
_lifetimeCancellation.Token);
|
|
var requestToken = requestCancellation.Token;
|
|
var browserGeneration = Volatile.Read(ref _manualFinancialBrowserGeneration);
|
|
var request = new ManualFinancialReadRequest(
|
|
operation,
|
|
requestId,
|
|
screen,
|
|
query,
|
|
stockName,
|
|
browserGeneration,
|
|
requestCancellation);
|
|
RegisterManualFinancialRead(request);
|
|
var databaseActivityEntered = false;
|
|
|
|
try
|
|
{
|
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
|
|
{
|
|
requestCancellation.Cancel();
|
|
PostManualFinancialReadCancellationIfCurrent(
|
|
request,
|
|
"PLAYOUT_PRIORITY",
|
|
"The read was canceled because playout has database priority.");
|
|
return;
|
|
}
|
|
|
|
await _databaseActivityGate.WaitAsync(requestToken);
|
|
databaseActivityEntered = true;
|
|
requestToken.ThrowIfCancellationRequested();
|
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
|
|
{
|
|
requestCancellation.Cancel();
|
|
PostManualFinancialReadCancellationIfCurrent(
|
|
request,
|
|
"PLAYOUT_PRIORITY",
|
|
"The read was canceled because playout has database priority.");
|
|
return;
|
|
}
|
|
|
|
if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration))
|
|
{
|
|
requestCancellation.Cancel();
|
|
return;
|
|
}
|
|
|
|
var reply = await operationBody(requestToken);
|
|
requestToken.ThrowIfCancellationRequested();
|
|
if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration) ||
|
|
!TryCompleteManualFinancialReadIfCurrent(request))
|
|
{
|
|
return;
|
|
}
|
|
|
|
PostMessage(reply.MessageType, reply.Payload);
|
|
}
|
|
catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
|
|
{
|
|
PostManualFinancialReadCancellationIfCurrent(
|
|
request,
|
|
"PLAYOUT_PRIORITY",
|
|
"The read was canceled because playout has database priority.");
|
|
}
|
|
catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
|
|
{
|
|
PostManualFinancialReadCancellationIfCurrent(
|
|
request,
|
|
"PLAYOUT_PRIORITY",
|
|
"The read was canceled because playout has database priority.");
|
|
}
|
|
catch (Exception) when (requestToken.IsCancellationRequested)
|
|
{
|
|
PostManualFinancialReadCancellationIfCurrent(
|
|
request,
|
|
"PLAYOUT_PRIORITY",
|
|
"The read was canceled because playout has database priority.");
|
|
}
|
|
catch (ManualFinancialBridgeStaleException)
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"STALE_ROW",
|
|
"The manual-financial record changed. Reload before selecting it.",
|
|
retryable: false);
|
|
}
|
|
catch (ManualFinancialNotFoundException)
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"NOT_FOUND",
|
|
"The manual-financial record does not exist.",
|
|
retryable: false);
|
|
}
|
|
catch (ManualFinancialAmbiguousIdentityException)
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"AMBIGUOUS_IDENTITY",
|
|
"The manual-financial STOCK_NAME is duplicated.",
|
|
retryable: false);
|
|
}
|
|
catch (ManualFinancialDataException)
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"INVALID_DATA",
|
|
"Stored manual-financial data does not match the migrated contract.",
|
|
retryable: false);
|
|
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
|
}
|
|
catch (StockSearchDataException)
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"INVALID_STOCK_DATA",
|
|
"The stock master result is ambiguous or invalid.",
|
|
retryable: false);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"INVALID_REQUEST",
|
|
"The manual-financial request identity is invalid or ambiguous.",
|
|
retryable: false);
|
|
}
|
|
catch (DatabaseOperationException exception)
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"DATABASE_ERROR",
|
|
exception.Message,
|
|
retryable: true);
|
|
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
|
}
|
|
catch
|
|
{
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
"DATABASE_ERROR",
|
|
"The manual-financial database read failed.",
|
|
retryable: true);
|
|
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
|
|
}
|
|
finally
|
|
{
|
|
if (databaseActivityEntered)
|
|
{
|
|
_databaseActivityGate.Release();
|
|
}
|
|
|
|
ReleaseManualFinancialRead(request);
|
|
requestCancellation.Dispose();
|
|
}
|
|
}
|
|
|
|
private async Task ExecuteManualFinancialWriteAsync(
|
|
string requestId,
|
|
string operation,
|
|
ManualFinancialScreenKind screen,
|
|
string stockName,
|
|
Func<CancellationToken, Task>? preflight,
|
|
Func<IManualFinancialScreenService, CancellationToken, Task<ManualFinancialMutationReceipt>>
|
|
operationBody)
|
|
{
|
|
var service = _manualFinancialScreenService;
|
|
if (service is null || !service.CanMutate)
|
|
{
|
|
PostManualFinancialUnavailable(
|
|
requestId,
|
|
operation,
|
|
ToWireValue(screen),
|
|
stockName: stockName);
|
|
return;
|
|
}
|
|
|
|
if (IsManualFinancialWriteQuarantined())
|
|
{
|
|
PostManualFinancialQuarantined(requestId, operation, screen, stockName);
|
|
return;
|
|
}
|
|
|
|
if (!await _manualFinancialWriteGate.WaitAsync(0))
|
|
{
|
|
PostManualFinancialMutationError(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"WRITE_BUSY",
|
|
"Another manual-financial write is already running.",
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
var playoutGateEntered = false;
|
|
var databaseActivityEntered = false;
|
|
var mutationDispatched = false;
|
|
var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
|
_lifetimeCancellation.Token);
|
|
var requestToken = requestCancellation.Token;
|
|
var browserGeneration = Volatile.Read(ref _manualFinancialBrowserGeneration);
|
|
Volatile.Write(ref _manualFinancialWriteCancellation, requestCancellation);
|
|
|
|
try
|
|
{
|
|
playoutGateEntered = await _playoutCommandGate.WaitAsync(0, requestToken);
|
|
if (!playoutGateEntered || !CanStartManualFinancialWrite())
|
|
{
|
|
PostManualFinancialPlayoutActive(requestId, operation, screen, stockName);
|
|
return;
|
|
}
|
|
|
|
databaseActivityEntered = await _databaseActivityGate.WaitAsync(0, requestToken);
|
|
if (!databaseActivityEntered)
|
|
{
|
|
PostManualFinancialMutationError(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"DATABASE_BUSY",
|
|
"The database is busy. Reload before making a new explicit write request.",
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
requestToken.ThrowIfCancellationRequested();
|
|
if (!CanStartManualFinancialWrite())
|
|
{
|
|
PostManualFinancialPlayoutActive(requestId, operation, screen, stockName);
|
|
return;
|
|
}
|
|
|
|
if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration))
|
|
{
|
|
requestCancellation.Cancel();
|
|
return;
|
|
}
|
|
|
|
if (preflight is not null)
|
|
{
|
|
await preflight(requestToken);
|
|
requestToken.ThrowIfCancellationRequested();
|
|
}
|
|
|
|
if (!CanStartManualFinancialWrite() ||
|
|
browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration))
|
|
{
|
|
requestCancellation.Cancel();
|
|
return;
|
|
}
|
|
|
|
mutationDispatched = true;
|
|
Interlocked.Exchange(ref _manualFinancialWriteInFlight, 1);
|
|
var receipt = await operationBody(service, requestToken);
|
|
if (browserGeneration != Volatile.Read(ref _manualFinancialBrowserGeneration) ||
|
|
!ReferenceEquals(
|
|
Volatile.Read(ref _manualFinancialWriteCancellation),
|
|
requestCancellation))
|
|
{
|
|
LatchManualFinancialWriteQuarantine(
|
|
ManualFinancialCorrelationQuarantineMessage);
|
|
return;
|
|
}
|
|
|
|
PostMessage("manual-financial-mutation-result", new
|
|
{
|
|
requestId,
|
|
operationId = receipt.OperationId.ToString("N", CultureInfo.InvariantCulture),
|
|
operation,
|
|
screen = ToWireValue(receipt.Screen),
|
|
receipt.StockName,
|
|
receipt.CommittedAt,
|
|
receipt.AffectedRows
|
|
});
|
|
}
|
|
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
|
|
{
|
|
if (mutationDispatched)
|
|
{
|
|
LatchManualFinancialWriteQuarantine(
|
|
ManualFinancialCorrelationQuarantineMessage);
|
|
}
|
|
}
|
|
catch (ManualFinancialBridgeStaleException)
|
|
{
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"STALE_ROW",
|
|
"The record changed. Reload before submitting a new explicit write.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
catch (ManualFinancialNotFoundException)
|
|
{
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"NOT_FOUND",
|
|
"The manual-financial record no longer exists.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
catch (ManualFinancialAmbiguousIdentityException)
|
|
{
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"AMBIGUOUS_IDENTITY",
|
|
"The manual-financial STOCK_NAME is duplicated. No write was attempted.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
catch (ManualFinancialMutationRejectedException exception)
|
|
{
|
|
var (code, message) = exception.Reason switch
|
|
{
|
|
ManualFinancialMutationRejectionReason.DuplicateIdentity =>
|
|
("DUPLICATE_IDENTITY", "The stock already exists. Reload before editing it."),
|
|
ManualFinancialMutationRejectionReason.NotFoundOrChanged =>
|
|
("STALE_ROW", "The record changed or disappeared. Reload before editing it."),
|
|
_ =>
|
|
("AMBIGUOUS_IDENTITY", "The manual-financial identity is ambiguous.")
|
|
};
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
code,
|
|
message,
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
catch (ManualFinancialMutationException exception)
|
|
{
|
|
if (exception.OutcomeUnknown)
|
|
{
|
|
LatchManualFinancialWriteQuarantine(exception.Message);
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"OUTCOME_UNKNOWN",
|
|
ManualFinancialCorrelationQuarantineMessage,
|
|
outcomeUnknown: true,
|
|
requestCancellation);
|
|
}
|
|
else
|
|
{
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"WRITE_FAILED",
|
|
"The Oracle transaction rolled back and was not retried.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"INVALID_REQUEST",
|
|
"The manual-financial write identity or record is invalid.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
catch (StockSearchDataException)
|
|
{
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"INVALID_STOCK_DATA",
|
|
"The stock master result is ambiguous or invalid.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
if (mutationDispatched)
|
|
{
|
|
LatchManualFinancialWriteQuarantine(
|
|
ManualFinancialCorrelationQuarantineMessage);
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"OUTCOME_UNKNOWN",
|
|
ManualFinancialCorrelationQuarantineMessage,
|
|
outcomeUnknown: true,
|
|
requestCancellation);
|
|
}
|
|
else
|
|
{
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"CANCELLED",
|
|
"The write stopped before an Oracle transaction began.",
|
|
outcomeUnknown: false,
|
|
requestCancellation);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
if (mutationDispatched)
|
|
{
|
|
LatchManualFinancialWriteQuarantine(
|
|
ManualFinancialCorrelationQuarantineMessage);
|
|
}
|
|
|
|
PostManualFinancialWriteErrorIfCurrent(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
mutationDispatched ? "OUTCOME_UNKNOWN" : "WRITE_FAILED",
|
|
mutationDispatched
|
|
? ManualFinancialCorrelationQuarantineMessage
|
|
: "The write failed before an Oracle transaction began.",
|
|
outcomeUnknown: mutationDispatched,
|
|
requestCancellation);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _manualFinancialWriteInFlight, 0);
|
|
if (databaseActivityEntered)
|
|
{
|
|
_databaseActivityGate.Release();
|
|
}
|
|
|
|
if (playoutGateEntered)
|
|
{
|
|
_playoutCommandGate.Release();
|
|
}
|
|
|
|
Interlocked.CompareExchange(
|
|
ref _manualFinancialWriteCancellation,
|
|
null,
|
|
requestCancellation);
|
|
requestCancellation.Dispose();
|
|
_manualFinancialWriteGate.Release();
|
|
}
|
|
}
|
|
|
|
private bool CanStartManualFinancialWrite()
|
|
{
|
|
if (Volatile.Read(ref _playoutCommandInFlight) != 0 ||
|
|
Volatile.Read(ref _playoutShutdownStarted) != 0 ||
|
|
IsBrowserCorrelationQuarantined() ||
|
|
IsManualFinancialWriteQuarantined())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return CanStartOperatorMutation(IsManualFinancialWriteQuarantined());
|
|
}
|
|
|
|
private async Task<VerifiedManualFinancialStock> ResolveManualFinancialStockAsync(
|
|
ManualFinancialIdentity identity,
|
|
ManualFinancialStockWire stock,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var stockService = _stockSearchService ?? throw new InvalidOperationException(
|
|
"The domestic stock-search service is unavailable.");
|
|
if (!string.Equals(identity.StockName, stock.Name, StringComparison.Ordinal))
|
|
{
|
|
throw new ArgumentException("The supplied stock name does not match the record.");
|
|
}
|
|
|
|
var search = await stockService.SearchAsync(
|
|
identity.StockName,
|
|
LegacyStockSearchService.MaximumResults,
|
|
cancellationToken);
|
|
var verified = ManualFinancialCutContracts.VerifyStock(
|
|
identity,
|
|
search.Items,
|
|
stock.Market,
|
|
stock.Code);
|
|
if (verified.Source != stock.Source ||
|
|
!string.Equals(verified.Name, stock.Name, StringComparison.Ordinal))
|
|
{
|
|
throw new ArgumentException("The supplied stock provider identity is invalid.");
|
|
}
|
|
|
|
return verified;
|
|
}
|
|
|
|
private IManualFinancialScreenService RequireManualFinancialService() =>
|
|
_manualFinancialScreenService ?? throw new InvalidOperationException(
|
|
"The manual-financial service is unavailable.");
|
|
|
|
private static void EnsureManualFinancialRowVersion(
|
|
ManualFinancialSnapshot snapshot,
|
|
string expectedRowVersion)
|
|
{
|
|
if (!string.Equals(snapshot.RowVersion, expectedRowVersion, StringComparison.Ordinal))
|
|
{
|
|
throw new ManualFinancialBridgeStaleException();
|
|
}
|
|
}
|
|
|
|
private void RegisterManualFinancialRead(ManualFinancialReadRequest request)
|
|
{
|
|
ManualFinancialReadRequest? superseded;
|
|
var shouldPostSuperseded = false;
|
|
lock (_manualFinancialReadLanesGate)
|
|
{
|
|
_manualFinancialReadLanes.TryGetValue(request.Lane, out superseded);
|
|
_manualFinancialReadLanes[request.Lane] = request;
|
|
shouldPostSuperseded = superseded?.TryComplete() == true;
|
|
}
|
|
|
|
if (superseded is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
CancelRequest(superseded.Cancellation);
|
|
if (shouldPostSuperseded)
|
|
{
|
|
PostManualFinancialReadError(
|
|
superseded.RequestId,
|
|
superseded.Operation,
|
|
superseded.Screen,
|
|
superseded.Query,
|
|
superseded.StockName,
|
|
"CANCELLED",
|
|
"The read was canceled because a newer request replaced it.",
|
|
retryable: true);
|
|
}
|
|
}
|
|
|
|
private bool TryCompleteManualFinancialReadIfCurrent(ManualFinancialReadRequest request)
|
|
{
|
|
lock (_manualFinancialReadLanesGate)
|
|
{
|
|
return request.BrowserGeneration ==
|
|
Volatile.Read(ref _manualFinancialBrowserGeneration) &&
|
|
_manualFinancialReadLanes.TryGetValue(request.Lane, out var current) &&
|
|
ReferenceEquals(current, request) &&
|
|
request.TryComplete();
|
|
}
|
|
}
|
|
|
|
private void ReleaseManualFinancialRead(ManualFinancialReadRequest request)
|
|
{
|
|
lock (_manualFinancialReadLanesGate)
|
|
{
|
|
if (_manualFinancialReadLanes.TryGetValue(request.Lane, out var current) &&
|
|
ReferenceEquals(current, request))
|
|
{
|
|
_manualFinancialReadLanes.Remove(request.Lane);
|
|
}
|
|
}
|
|
}
|
|
|
|
private ManualFinancialReadRequest[] SnapshotManualFinancialReads(bool clear)
|
|
{
|
|
lock (_manualFinancialReadLanesGate)
|
|
{
|
|
var requests = _manualFinancialReadLanes.Values.ToArray();
|
|
if (clear)
|
|
{
|
|
_manualFinancialReadLanes.Clear();
|
|
foreach (var request in requests)
|
|
{
|
|
_ = request.TryComplete();
|
|
}
|
|
}
|
|
|
|
return requests;
|
|
}
|
|
}
|
|
|
|
private void PostManualFinancialReadCancellationIfCurrent(
|
|
ManualFinancialReadRequest request,
|
|
string code,
|
|
string message) =>
|
|
PostManualFinancialReadErrorIfCurrent(
|
|
request,
|
|
code,
|
|
message,
|
|
retryable: true);
|
|
|
|
private void PostManualFinancialReadErrorIfCurrent(
|
|
ManualFinancialReadRequest request,
|
|
string code,
|
|
string message,
|
|
bool retryable)
|
|
{
|
|
if (_lifetimeCancellation.IsCancellationRequested ||
|
|
!TryCompleteManualFinancialReadIfCurrent(request))
|
|
{
|
|
return;
|
|
}
|
|
|
|
PostManualFinancialReadError(
|
|
request.RequestId,
|
|
request.Operation,
|
|
request.Screen,
|
|
request.Query,
|
|
request.StockName,
|
|
code,
|
|
message,
|
|
retryable);
|
|
}
|
|
|
|
private void PostManualFinancialWriteErrorIfCurrent(
|
|
string requestId,
|
|
string operation,
|
|
ManualFinancialScreenKind screen,
|
|
string stockName,
|
|
string code,
|
|
string message,
|
|
bool outcomeUnknown,
|
|
CancellationTokenSource requestCancellation)
|
|
{
|
|
if (_lifetimeCancellation.IsCancellationRequested ||
|
|
!ReferenceEquals(
|
|
Volatile.Read(ref _manualFinancialWriteCancellation),
|
|
requestCancellation))
|
|
{
|
|
return;
|
|
}
|
|
|
|
PostManualFinancialMutationError(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
code,
|
|
message,
|
|
outcomeUnknown);
|
|
}
|
|
|
|
private void PostManualFinancialReadError(
|
|
string requestId,
|
|
string operation,
|
|
ManualFinancialScreenKind screen,
|
|
string query,
|
|
string stockName,
|
|
string code,
|
|
string message,
|
|
bool retryable)
|
|
{
|
|
if (operation == "list")
|
|
{
|
|
PostMessage("manual-financial-list-error", new
|
|
{
|
|
requestId,
|
|
screen = ToWireValue(screen),
|
|
query,
|
|
message
|
|
});
|
|
return;
|
|
}
|
|
|
|
PostMessage(
|
|
operation == "selection"
|
|
? "manual-financial-selection-error"
|
|
: "manual-financial-load-error",
|
|
new
|
|
{
|
|
requestId,
|
|
screen = ToWireValue(screen),
|
|
stockName,
|
|
code,
|
|
message,
|
|
retryable
|
|
});
|
|
}
|
|
|
|
private void PostManualFinancialInvalidRequest(
|
|
string requestId,
|
|
string operation,
|
|
string screen,
|
|
string query = "",
|
|
string stockName = "")
|
|
{
|
|
if (TryParseManualFinancialScreen(screen, out var parsedScreen))
|
|
{
|
|
if (operation is "list" or "load" or "selection")
|
|
{
|
|
PostManualFinancialReadError(
|
|
requestId,
|
|
operation,
|
|
parsedScreen,
|
|
query,
|
|
stockName,
|
|
"INVALID_REQUEST",
|
|
"The manual-financial request payload is invalid.",
|
|
retryable: false);
|
|
return;
|
|
}
|
|
|
|
PostManualFinancialMutationError(
|
|
requestId,
|
|
operation,
|
|
parsedScreen,
|
|
stockName,
|
|
"INVALID_REQUEST",
|
|
"The manual-financial write payload is invalid.",
|
|
outcomeUnknown: false);
|
|
return;
|
|
}
|
|
|
|
PostMessage("manual-financial-request-error", new
|
|
{
|
|
requestId,
|
|
operation,
|
|
code = "INVALID_REQUEST",
|
|
message = "The manual-financial request payload is invalid."
|
|
});
|
|
}
|
|
|
|
private void PostManualFinancialUnavailable(
|
|
string requestId,
|
|
string operation,
|
|
string screen,
|
|
string query = "",
|
|
string stockName = "")
|
|
{
|
|
if (TryParseManualFinancialScreen(screen, out var parsedScreen) &&
|
|
operation is "list" or "load" or "selection")
|
|
{
|
|
PostManualFinancialReadError(
|
|
requestId,
|
|
operation,
|
|
parsedScreen,
|
|
query,
|
|
stockName,
|
|
"DATABASE_UNAVAILABLE",
|
|
_manualFinancialInitializationError ??
|
|
_databaseInitializationError ??
|
|
"The manual-financial database boundary is unavailable.",
|
|
retryable: false);
|
|
return;
|
|
}
|
|
|
|
if (TryParseManualFinancialScreen(screen, out parsedScreen))
|
|
{
|
|
PostManualFinancialMutationError(
|
|
requestId,
|
|
operation,
|
|
parsedScreen,
|
|
stockName,
|
|
"DATABASE_UNAVAILABLE",
|
|
_manualFinancialInitializationError ??
|
|
_databaseInitializationError ??
|
|
"The manual-financial database boundary is unavailable.",
|
|
outcomeUnknown: false);
|
|
}
|
|
}
|
|
|
|
private void PostManualFinancialPlayoutActive(
|
|
string requestId,
|
|
string operation,
|
|
ManualFinancialScreenKind screen,
|
|
string stockName) =>
|
|
PostManualFinancialMutationError(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"PLAYOUT_ACTIVE",
|
|
"Manual-financial writes are allowed only while playout is fully IDLE.",
|
|
outcomeUnknown: false);
|
|
|
|
private void PostManualFinancialQuarantined(
|
|
string requestId,
|
|
string operation,
|
|
ManualFinancialScreenKind screen,
|
|
string stockName) =>
|
|
PostManualFinancialMutationError(
|
|
requestId,
|
|
operation,
|
|
screen,
|
|
stockName,
|
|
"OUTCOME_UNKNOWN",
|
|
GetOperatorMutationQuarantineMessage(
|
|
Volatile.Read(ref _manualFinancialWriteQuarantineMessage) ??
|
|
ManualFinancialCorrelationQuarantineMessage),
|
|
outcomeUnknown: true);
|
|
|
|
private void PostManualFinancialMutationError(
|
|
string requestId,
|
|
string operation,
|
|
ManualFinancialScreenKind screen,
|
|
string stockName,
|
|
string code,
|
|
string message,
|
|
bool outcomeUnknown)
|
|
{
|
|
PostMessage("manual-financial-mutation-error", new
|
|
{
|
|
requestId,
|
|
operation,
|
|
screen = ToWireValue(screen),
|
|
stockName,
|
|
code,
|
|
message,
|
|
retryable = false,
|
|
outcomeUnknown
|
|
});
|
|
}
|
|
|
|
private bool IsManualFinancialWriteQuarantined() =>
|
|
Volatile.Read(ref _manualFinancialWriteQuarantined) != 0 ||
|
|
IsOperatorMutationQuarantined();
|
|
|
|
private void LatchManualFinancialWriteQuarantine(string message)
|
|
{
|
|
LatchOperatorMutationQuarantine(message);
|
|
if (Interlocked.CompareExchange(ref _manualFinancialWriteQuarantined, 1, 0) == 0)
|
|
{
|
|
Volatile.Write(
|
|
ref _manualFinancialWriteQuarantineMessage,
|
|
string.IsNullOrWhiteSpace(message)
|
|
? ManualFinancialCorrelationQuarantineMessage
|
|
: message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Root browser-navigation/process-failure hook. Correlation loss during a
|
|
/// dispatched write permanently quarantines writes for this process.
|
|
/// </summary>
|
|
private void InvalidateManualFinancialBrowserRequests()
|
|
{
|
|
Interlocked.Increment(ref _manualFinancialBrowserGeneration);
|
|
if (Volatile.Read(ref _manualFinancialWriteInFlight) != 0)
|
|
{
|
|
LatchManualFinancialWriteQuarantine(
|
|
ManualFinancialCorrelationQuarantineMessage);
|
|
}
|
|
|
|
foreach (var request in SnapshotManualFinancialReads(clear: true))
|
|
{
|
|
CancelRequest(request.Cancellation);
|
|
}
|
|
|
|
CancelRequest(Interlocked.Exchange(ref _manualFinancialWriteCancellation, null));
|
|
}
|
|
|
|
/// <summary>Root playout-priority hook; writes own the playout gate.</summary>
|
|
private void CancelManualFinancialReadsForPlayout()
|
|
{
|
|
foreach (var request in SnapshotManualFinancialReads(clear: false))
|
|
{
|
|
CancelRequest(request.Cancellation);
|
|
}
|
|
}
|
|
|
|
/// <summary>Root window-close hook.</summary>
|
|
private void ShutdownManualFinancialRuntime()
|
|
{
|
|
if (Volatile.Read(ref _manualFinancialWriteInFlight) != 0)
|
|
{
|
|
LatchManualFinancialWriteQuarantine(
|
|
ManualFinancialCorrelationQuarantineMessage);
|
|
}
|
|
|
|
foreach (var request in SnapshotManualFinancialReads(clear: true))
|
|
{
|
|
CancelRequest(request.Cancellation);
|
|
}
|
|
|
|
CancelRequest(Interlocked.Exchange(ref _manualFinancialWriteCancellation, null));
|
|
_manualFinancialScreenService = null;
|
|
}
|
|
|
|
private static bool TryParseManualFinancialListRequest(
|
|
JsonElement payload,
|
|
out string requestId,
|
|
out ManualFinancialScreenKind screen,
|
|
out string query,
|
|
out int maximumResults)
|
|
{
|
|
requestId = string.Empty;
|
|
screen = default;
|
|
query = string.Empty;
|
|
maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults;
|
|
return payload.ValueKind == JsonValueKind.Object &&
|
|
HasOnlyProperties(payload, "requestId", "screen", "query", "maximumResults") &&
|
|
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
|
|
TryParseManualFinancialScreen(GetString(payload, "screen"), out screen) &&
|
|
TryGetManualFinancialQuery(payload, "query", out query) &&
|
|
TryGetOptionalManualFinancialLimit(payload, ref maximumResults);
|
|
}
|
|
|
|
private static bool TryParseManualFinancialIdentityRequest(
|
|
JsonElement payload,
|
|
string[] exactProperties,
|
|
out string requestId,
|
|
out ManualFinancialIdentity identity)
|
|
{
|
|
requestId = string.Empty;
|
|
identity = new ManualFinancialIdentity(default, string.Empty);
|
|
if (payload.ValueKind != JsonValueKind.Object ||
|
|
!HasOnlyProperties(payload, exactProperties) ||
|
|
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
|
|
!TryParseManualFinancialScreen(GetString(payload, "screen"), out var screen) ||
|
|
!TryGetManualFinancialText(
|
|
payload,
|
|
"stockName",
|
|
200,
|
|
allowEmpty: false,
|
|
allowUnderscore: true,
|
|
out var stockName))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
identity = new ManualFinancialIdentity(screen, stockName);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseManualFinancialCreateRequest(
|
|
JsonElement payload,
|
|
out string requestId,
|
|
out ManualFinancialRecord record,
|
|
out ManualFinancialStockWire stock)
|
|
{
|
|
requestId = string.Empty;
|
|
record = null!;
|
|
stock = default;
|
|
return payload.ValueKind == JsonValueKind.Object &&
|
|
HasOnlyProperties(payload, "requestId", "screen", "stock", "record") &&
|
|
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) &&
|
|
TryParseManualFinancialScreen(GetString(payload, "screen"), out var screen) &&
|
|
payload.TryGetProperty("record", out var recordElement) &&
|
|
TryParseManualFinancialRecord(recordElement, screen, out record) &&
|
|
payload.TryGetProperty("stock", out var stockElement) &&
|
|
TryParseManualFinancialStock(stockElement, record.Identity.StockName, out stock);
|
|
}
|
|
|
|
private static bool TryParseManualFinancialUpdateRequest(
|
|
JsonElement payload,
|
|
out string requestId,
|
|
out ManualFinancialRecord replacement,
|
|
out string expectedRowVersion)
|
|
{
|
|
requestId = string.Empty;
|
|
replacement = null!;
|
|
expectedRowVersion = string.Empty;
|
|
if (payload.ValueKind != JsonValueKind.Object ||
|
|
!HasOnlyProperties(
|
|
payload,
|
|
"requestId",
|
|
"screen",
|
|
"stockName",
|
|
"expectedRowVersion",
|
|
"record") ||
|
|
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
|
|
!TryParseManualFinancialScreen(GetString(payload, "screen"), out var screen) ||
|
|
!TryGetManualFinancialText(
|
|
payload,
|
|
"stockName",
|
|
200,
|
|
allowEmpty: false,
|
|
allowUnderscore: true,
|
|
out var stockName) ||
|
|
!TryGetManualFinancialRowVersion(payload, "expectedRowVersion", out expectedRowVersion) ||
|
|
!payload.TryGetProperty("record", out var recordElement) ||
|
|
!TryParseManualFinancialRecord(recordElement, screen, out replacement))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return string.Equals(replacement.Identity.StockName, stockName, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool TryParseManualFinancialDeleteRequest(
|
|
JsonElement payload,
|
|
out string requestId,
|
|
out ManualFinancialIdentity identity,
|
|
out string expectedRowVersion)
|
|
{
|
|
expectedRowVersion = string.Empty;
|
|
return TryParseManualFinancialIdentityRequest(
|
|
payload,
|
|
["requestId", "screen", "stockName", "expectedRowVersion"],
|
|
out requestId,
|
|
out identity) &&
|
|
TryGetManualFinancialRowVersion(
|
|
payload,
|
|
"expectedRowVersion",
|
|
out expectedRowVersion);
|
|
}
|
|
|
|
private static bool TryParseManualFinancialDeleteAllRequest(
|
|
JsonElement payload,
|
|
out string requestId,
|
|
out ManualFinancialScreenKind screen)
|
|
{
|
|
requestId = string.Empty;
|
|
screen = default;
|
|
if (payload.ValueKind != JsonValueKind.Object ||
|
|
!HasOnlyProperties(payload, "requestId", "screen", "confirmationToken") ||
|
|
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
|
|
!TryParseManualFinancialScreen(GetString(payload, "screen"), out screen))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var expected = "DELETE_ALL_" + ManualFinancialCutContracts.Get(screen).TableName;
|
|
return string.Equals(
|
|
GetString(payload, "confirmationToken"),
|
|
expected,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool TryParseManualFinancialSelectionRequest(
|
|
JsonElement payload,
|
|
out string requestId,
|
|
out ManualFinancialIdentity identity,
|
|
out string rowVersion,
|
|
out ManualFinancialStockWire stock)
|
|
{
|
|
rowVersion = string.Empty;
|
|
stock = default;
|
|
if (!TryParseManualFinancialIdentityRequest(
|
|
payload,
|
|
["requestId", "screen", "stockName", "rowVersion", "stock"],
|
|
out requestId,
|
|
out identity) ||
|
|
!TryGetManualFinancialRowVersion(payload, "rowVersion", out rowVersion) ||
|
|
!payload.TryGetProperty("stock", out var stockElement) ||
|
|
!TryParseManualFinancialStock(stockElement, identity.StockName, out stock))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseManualFinancialRecord(
|
|
JsonElement element,
|
|
ManualFinancialScreenKind screen,
|
|
out ManualFinancialRecord record)
|
|
{
|
|
record = null!;
|
|
if (element.ValueKind != JsonValueKind.Object ||
|
|
!TryGetManualFinancialText(
|
|
element,
|
|
"kind",
|
|
32,
|
|
allowEmpty: false,
|
|
allowUnderscore: false,
|
|
out var kind) ||
|
|
!string.Equals(kind, ToWireValue(screen), StringComparison.Ordinal) ||
|
|
!TryGetManualFinancialText(
|
|
element,
|
|
"stockName",
|
|
200,
|
|
allowEmpty: false,
|
|
allowUnderscore: true,
|
|
out var stockName))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var identity = new ManualFinancialIdentity(screen, stockName);
|
|
switch (screen)
|
|
{
|
|
case ManualFinancialScreenKind.RevenueComposition:
|
|
if (!HasOnlyProperties(element, "kind", "stockName", "baseDate", "slices") ||
|
|
!TryGetManualFinancialText(
|
|
element,
|
|
"baseDate",
|
|
200,
|
|
allowEmpty: true,
|
|
allowUnderscore: true,
|
|
out var baseDate) ||
|
|
!TryParseManualRevenueSlices(element, out var slices))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
record = new ManualRevenueCompositionRecord(identity, baseDate, slices);
|
|
return true;
|
|
case ManualFinancialScreenKind.GrowthMetrics:
|
|
if (!HasOnlyProperties(
|
|
element,
|
|
"kind",
|
|
"stockName",
|
|
"salesGrowth",
|
|
"operatingProfitGrowth",
|
|
"netAssetGrowth",
|
|
"netIncomeGrowth",
|
|
"periods") ||
|
|
!TryParseManualGrowthSeries(element, "salesGrowth", out var salesGrowth) ||
|
|
!TryParseManualGrowthSeries(
|
|
element,
|
|
"operatingProfitGrowth",
|
|
out var operatingProfitGrowth) ||
|
|
!TryParseManualGrowthSeries(element, "netAssetGrowth", out var netAssetGrowth) ||
|
|
!TryParseManualGrowthSeries(element, "netIncomeGrowth", out var netIncomeGrowth) ||
|
|
!TryParseManualPeriods(element, out var periods))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
record = new ManualGrowthMetricsRecord(
|
|
identity,
|
|
salesGrowth,
|
|
operatingProfitGrowth,
|
|
netAssetGrowth,
|
|
netIncomeGrowth,
|
|
periods);
|
|
return true;
|
|
case ManualFinancialScreenKind.Sales:
|
|
case ManualFinancialScreenKind.OperatingProfit:
|
|
if (!HasOnlyProperties(element, "kind", "stockName", "quarters") ||
|
|
!TryParseManualQuarters(element, out var quarters))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
record = screen == ManualFinancialScreenKind.Sales
|
|
? new ManualSalesRecord(identity, quarters)
|
|
: new ManualOperatingProfitRecord(identity, quarters);
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryParseManualRevenueSlices(
|
|
JsonElement record,
|
|
out IReadOnlyList<ManualRevenueSlice?> slices)
|
|
{
|
|
slices = Array.Empty<ManualRevenueSlice?>();
|
|
if (!record.TryGetProperty("slices", out var element) ||
|
|
element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 5)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var result = new List<ManualRevenueSlice?>(5);
|
|
foreach (var slice in element.EnumerateArray())
|
|
{
|
|
if (slice.ValueKind == JsonValueKind.Null)
|
|
{
|
|
result.Add(null);
|
|
continue;
|
|
}
|
|
|
|
if (slice.ValueKind != JsonValueKind.Object ||
|
|
!HasOnlyProperties(slice, "label", "percentageText", "percentage") ||
|
|
!TryGetManualFinancialText(
|
|
slice,
|
|
"label",
|
|
200,
|
|
allowEmpty: false,
|
|
allowUnderscore: false,
|
|
out var label) ||
|
|
!TryGetManualFinancialText(
|
|
slice,
|
|
"percentageText",
|
|
64,
|
|
allowEmpty: false,
|
|
allowUnderscore: false,
|
|
out var percentageText) ||
|
|
!slice.TryGetProperty("percentage", out var percentageElement) ||
|
|
percentageElement.ValueKind != JsonValueKind.Number ||
|
|
!percentageElement.TryGetDouble(out var percentage) ||
|
|
!double.IsFinite(percentage))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
result.Add(new ManualRevenueSlice(label, percentageText, percentage));
|
|
}
|
|
|
|
slices = result;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseManualGrowthSeries(
|
|
JsonElement record,
|
|
string propertyName,
|
|
out ManualGrowthSeriesValues values)
|
|
{
|
|
values = new ManualGrowthSeriesValues(null, null, null, null);
|
|
if (!record.TryGetProperty(propertyName, out var element) ||
|
|
element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 4)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var parsed = new double?[4];
|
|
var index = 0;
|
|
foreach (var value in element.EnumerateArray())
|
|
{
|
|
if (value.ValueKind == JsonValueKind.Null)
|
|
{
|
|
parsed[index++] = null;
|
|
continue;
|
|
}
|
|
|
|
if (value.ValueKind != JsonValueKind.Number ||
|
|
!value.TryGetDouble(out var number) || !double.IsFinite(number))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
parsed[index++] = number;
|
|
}
|
|
|
|
values = new ManualGrowthSeriesValues(parsed[0], parsed[1], parsed[2], parsed[3]);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseManualPeriods(
|
|
JsonElement record,
|
|
out ManualGrowthPeriodLabels periods)
|
|
{
|
|
periods = new ManualGrowthPeriodLabels(string.Empty, string.Empty, string.Empty, string.Empty);
|
|
if (!record.TryGetProperty("periods", out var element) ||
|
|
element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 4)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var parsed = new string[4];
|
|
var index = 0;
|
|
foreach (var value in element.EnumerateArray())
|
|
{
|
|
if (value.ValueKind != JsonValueKind.String ||
|
|
!IsSafeManualFinancialText(
|
|
value.GetString(),
|
|
200,
|
|
allowEmpty: true,
|
|
allowUnderscore: false,
|
|
out parsed[index++]))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
periods = new ManualGrowthPeriodLabels(parsed[0], parsed[1], parsed[2], parsed[3]);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseManualQuarters(
|
|
JsonElement record,
|
|
out IReadOnlyList<ManualQuarterValue> quarters)
|
|
{
|
|
quarters = Array.Empty<ManualQuarterValue>();
|
|
if (!record.TryGetProperty("quarters", out var element) ||
|
|
element.ValueKind != JsonValueKind.Array || element.GetArrayLength() != 6)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var result = new List<ManualQuarterValue>(6);
|
|
foreach (var item in element.EnumerateArray())
|
|
{
|
|
if (item.ValueKind != JsonValueKind.Object ||
|
|
!HasOnlyProperties(item, "quarter", "value") ||
|
|
!TryGetManualFinancialText(
|
|
item,
|
|
"quarter",
|
|
200,
|
|
allowEmpty: false,
|
|
allowUnderscore: false,
|
|
out var quarter) ||
|
|
!item.TryGetProperty("value", out var valueElement) ||
|
|
valueElement.ValueKind != JsonValueKind.Number ||
|
|
!valueElement.TryGetInt32(out var value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
result.Add(new ManualQuarterValue(quarter, value));
|
|
}
|
|
|
|
quarters = result;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseManualFinancialStock(
|
|
JsonElement element,
|
|
string expectedName,
|
|
out ManualFinancialStockWire stock)
|
|
{
|
|
stock = default;
|
|
if (element.ValueKind != JsonValueKind.Object ||
|
|
!HasOnlyProperties(element, "market", "source", "name", "code") ||
|
|
!TryParseStockMarket(GetString(element, "market"), out var market) ||
|
|
!TryParseDataSource(GetString(element, "source"), out var source) ||
|
|
!TryGetManualFinancialText(
|
|
element,
|
|
"name",
|
|
200,
|
|
allowEmpty: false,
|
|
allowUnderscore: true,
|
|
out var name) ||
|
|
!TryGetManualFinancialStockCode(element, "code", out var code) ||
|
|
!string.Equals(name, expectedName, StringComparison.Ordinal) ||
|
|
(market is StockMarket.Kospi or StockMarket.Kosdaq
|
|
? source != DataSourceKind.Oracle
|
|
: source != DataSourceKind.MariaDb))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
stock = new ManualFinancialStockWire(market, source, name, code);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryGetOptionalManualFinancialLimit(
|
|
JsonElement payload,
|
|
ref int maximumResults)
|
|
{
|
|
if (!payload.TryGetProperty("maximumResults", out var element))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return element.ValueKind == JsonValueKind.Number &&
|
|
element.TryGetInt32(out maximumResults) &&
|
|
maximumResults is >= 1 and <= LegacyManualFinancialScreenService.MaximumResults;
|
|
}
|
|
|
|
private static bool TryGetManualFinancialQuery(
|
|
JsonElement payload,
|
|
string propertyName,
|
|
out string query)
|
|
{
|
|
query = string.Empty;
|
|
if (!payload.TryGetProperty(propertyName, out var element) ||
|
|
element.ValueKind != JsonValueKind.String)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var value = element.GetString();
|
|
return value is not null &&
|
|
value.Length <= LegacyManualFinancialScreenService.MaximumQueryLength &&
|
|
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
|
|
IsSafeManualFinancialText(
|
|
value,
|
|
LegacyManualFinancialScreenService.MaximumQueryLength,
|
|
allowEmpty: true,
|
|
allowUnderscore: true,
|
|
out query);
|
|
}
|
|
|
|
private static bool TryGetManualFinancialText(
|
|
JsonElement payload,
|
|
string propertyName,
|
|
int maximumLength,
|
|
bool allowEmpty,
|
|
bool allowUnderscore,
|
|
out string value)
|
|
{
|
|
value = string.Empty;
|
|
return payload.TryGetProperty(propertyName, out var element) &&
|
|
element.ValueKind == JsonValueKind.String &&
|
|
IsSafeManualFinancialText(
|
|
element.GetString(),
|
|
maximumLength,
|
|
allowEmpty,
|
|
allowUnderscore,
|
|
out value);
|
|
}
|
|
|
|
private static bool IsSafeManualFinancialText(
|
|
string? input,
|
|
int maximumLength,
|
|
bool allowEmpty,
|
|
bool allowUnderscore,
|
|
out string value)
|
|
{
|
|
value = input ?? string.Empty;
|
|
if (input is null || (!allowEmpty && input.Length == 0) ||
|
|
input.Length > maximumLength || input != input.Trim() ||
|
|
input.Contains('^') || (!allowUnderscore && input.Contains('_')))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var character in input)
|
|
{
|
|
var category = CharUnicodeInfo.GetUnicodeCategory(character);
|
|
if (char.IsControl(character) || char.IsSurrogate(character) ||
|
|
character == '\uFFFD' || category is
|
|
UnicodeCategory.Format or
|
|
UnicodeCategory.LineSeparator or
|
|
UnicodeCategory.ParagraphSeparator)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool TryGetManualFinancialRowVersion(
|
|
JsonElement payload,
|
|
string propertyName,
|
|
out string value)
|
|
{
|
|
value = GetString(payload, propertyName) ?? string.Empty;
|
|
return value.Length == 64 && value.All(static character =>
|
|
char.IsAsciiDigit(character) || character is >= 'A' and <= 'F');
|
|
}
|
|
|
|
private static bool TryGetManualFinancialStockCode(
|
|
JsonElement payload,
|
|
string propertyName,
|
|
out string value)
|
|
{
|
|
value = GetString(payload, propertyName) ?? string.Empty;
|
|
return value.Length is >= 1 and <= 32 && value.All(char.IsAsciiLetterOrDigit);
|
|
}
|
|
|
|
private static bool TryParseManualFinancialScreen(
|
|
string? value,
|
|
out ManualFinancialScreenKind screen)
|
|
{
|
|
screen = value switch
|
|
{
|
|
"revenue-composition" => ManualFinancialScreenKind.RevenueComposition,
|
|
"growth-metrics" => ManualFinancialScreenKind.GrowthMetrics,
|
|
"sales" => ManualFinancialScreenKind.Sales,
|
|
"operating-profit" => ManualFinancialScreenKind.OperatingProfit,
|
|
_ => default
|
|
};
|
|
return value is "revenue-composition" or "growth-metrics" or
|
|
"sales" or "operating-profit";
|
|
}
|
|
|
|
private static string ToWireValue(ManualFinancialScreenKind screen) => screen switch
|
|
{
|
|
ManualFinancialScreenKind.RevenueComposition => "revenue-composition",
|
|
ManualFinancialScreenKind.GrowthMetrics => "growth-metrics",
|
|
ManualFinancialScreenKind.Sales => "sales",
|
|
ManualFinancialScreenKind.OperatingProfit => "operating-profit",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(screen))
|
|
};
|
|
|
|
private static bool TryParseStockMarket(string? value, out StockMarket market)
|
|
{
|
|
market = value switch
|
|
{
|
|
"kospi" => StockMarket.Kospi,
|
|
"kosdaq" => StockMarket.Kosdaq,
|
|
"nxt-kospi" => StockMarket.NxtKospi,
|
|
"nxt-kosdaq" => StockMarket.NxtKosdaq,
|
|
_ => default
|
|
};
|
|
return value is "kospi" or "kosdaq" or "nxt-kospi" or "nxt-kosdaq";
|
|
}
|
|
|
|
private static bool TryParseDataSource(string? value, out DataSourceKind source)
|
|
{
|
|
source = value switch
|
|
{
|
|
"oracle" => DataSourceKind.Oracle,
|
|
"mariaDb" => DataSourceKind.MariaDb,
|
|
_ => default
|
|
};
|
|
return value is "oracle" or "mariaDb";
|
|
}
|
|
|
|
private static object CreateManualFinancialSnapshotPayload(ManualFinancialSnapshot snapshot) =>
|
|
new
|
|
{
|
|
stockName = snapshot.Record.Identity.StockName,
|
|
snapshot.RowVersion,
|
|
record = CreateManualFinancialRecordPayload(snapshot.Record)
|
|
};
|
|
|
|
private static object CreateManualFinancialRecordPayload(ManualFinancialRecord record) =>
|
|
record switch
|
|
{
|
|
ManualRevenueCompositionRecord revenue => new
|
|
{
|
|
kind = ToWireValue(revenue.Identity.Screen),
|
|
stockName = revenue.Identity.StockName,
|
|
revenue.BaseDate,
|
|
slices = revenue.Slices.Select(slice => slice is null
|
|
? null
|
|
: (object)new
|
|
{
|
|
slice.Label,
|
|
slice.PercentageText,
|
|
slice.Percentage
|
|
})
|
|
},
|
|
ManualGrowthMetricsRecord growth => new
|
|
{
|
|
kind = ToWireValue(growth.Identity.Screen),
|
|
stockName = growth.Identity.StockName,
|
|
salesGrowth = growth.SalesGrowth.ToArray(),
|
|
operatingProfitGrowth = growth.OperatingProfitGrowth.ToArray(),
|
|
netAssetGrowth = growth.NetAssetGrowth.ToArray(),
|
|
netIncomeGrowth = growth.NetIncomeGrowth.ToArray(),
|
|
periods = growth.Periods.ToArray()
|
|
},
|
|
ManualSalesRecord sales => new
|
|
{
|
|
kind = ToWireValue(sales.Identity.Screen),
|
|
stockName = sales.Identity.StockName,
|
|
quarters = sales.Quarters.Select(quarter => new
|
|
{
|
|
quarter.Quarter,
|
|
quarter.Value
|
|
})
|
|
},
|
|
ManualOperatingProfitRecord profit => new
|
|
{
|
|
kind = ToWireValue(profit.Identity.Screen),
|
|
stockName = profit.Identity.StockName,
|
|
quarters = profit.Quarters.Select(quarter => new
|
|
{
|
|
quarter.Quarter,
|
|
quarter.Value
|
|
})
|
|
},
|
|
_ => throw new ArgumentOutOfRangeException(nameof(record))
|
|
};
|
|
|
|
private static string SafeManualFinancialRequestId(JsonElement payload) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)
|
|
? requestId
|
|
: string.Empty;
|
|
|
|
private static string SafeManualFinancialScreen(JsonElement payload)
|
|
{
|
|
var value = payload.ValueKind == JsonValueKind.Object
|
|
? GetString(payload, "screen")
|
|
: null;
|
|
return TryParseManualFinancialScreen(value, out var screen)
|
|
? ToWireValue(screen)
|
|
: string.Empty;
|
|
}
|
|
|
|
private static string SafeManualFinancialQuery(JsonElement payload) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
TryGetManualFinancialQuery(payload, "query", out var query)
|
|
? query
|
|
: string.Empty;
|
|
|
|
private static string SafeManualFinancialStockName(JsonElement payload) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
TryGetManualFinancialText(
|
|
payload,
|
|
"stockName",
|
|
200,
|
|
allowEmpty: false,
|
|
allowUnderscore: true,
|
|
out var stockName)
|
|
? stockName
|
|
: string.Empty;
|
|
|
|
private static string SafeManualFinancialRecordStockName(JsonElement payload)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object ||
|
|
!payload.TryGetProperty("record", out var record) ||
|
|
record.ValueKind != JsonValueKind.Object)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
return SafeManualFinancialStockName(record);
|
|
}
|
|
|
|
private readonly record struct ManualFinancialBridgeReply(string MessageType, object Payload);
|
|
|
|
private sealed class ManualFinancialReadRequest
|
|
{
|
|
private int _terminalState;
|
|
|
|
public ManualFinancialReadRequest(
|
|
string lane,
|
|
string requestId,
|
|
ManualFinancialScreenKind screen,
|
|
string query,
|
|
string stockName,
|
|
int browserGeneration,
|
|
CancellationTokenSource cancellation)
|
|
{
|
|
Lane = lane;
|
|
RequestId = requestId;
|
|
Operation = lane;
|
|
Screen = screen;
|
|
Query = query;
|
|
StockName = stockName;
|
|
BrowserGeneration = browserGeneration;
|
|
Cancellation = cancellation;
|
|
}
|
|
|
|
public string Lane { get; }
|
|
|
|
public string RequestId { get; }
|
|
|
|
public string Operation { get; }
|
|
|
|
public ManualFinancialScreenKind Screen { get; }
|
|
|
|
public string Query { get; }
|
|
|
|
public string StockName { get; }
|
|
|
|
public int BrowserGeneration { get; }
|
|
|
|
public CancellationTokenSource Cancellation { get; }
|
|
|
|
public bool TryComplete() => Interlocked.CompareExchange(ref _terminalState, 1, 0) == 0;
|
|
}
|
|
|
|
private readonly record struct ManualFinancialStockWire(
|
|
StockMarket Market,
|
|
DataSourceKind Source,
|
|
string Name,
|
|
string Code);
|
|
|
|
private sealed class ManualFinancialBridgeStaleException : Exception;
|
|
}
|