mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
feat: complete SH-133 procurement reconciliation
This commit is contained in:
parent
bdffe77e42
commit
e3c37e54b4
28 changed files with 6255 additions and 24 deletions
199
Api.SeaHavenIndustries.Tests/ProcurementWorkOrderClientTests.cs
Normal file
199
Api.SeaHavenIndustries.Tests/ProcurementWorkOrderClientTests.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
using System.Net;
|
||||||
|
using Amazon.Runtime;
|
||||||
|
using Api.SeaHavenIndustries.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class ProcurementWorkOrderClientTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Signs_every_retry_with_session_credentials_and_reads_typed_page()
|
||||||
|
{
|
||||||
|
var handler = new RecordingHandler(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
|
||||||
|
Json(HttpStatusCode.OK,
|
||||||
|
"""{"items":[{"work_order_id":"123","updated_at":"2026-07-24T12:00:00Z","wo_status":"new"}],"next_cursor":"opaque"}"""));
|
||||||
|
var credentials = new RecordingCredentialsProvider();
|
||||||
|
var client = Create(handler, credentials);
|
||||||
|
|
||||||
|
var page = await client.GetWorkOrdersAsync(null, 100, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("123", Assert.Single(page.Items).WorkOrderId);
|
||||||
|
Assert.Equal("opaque", page.NextCursor);
|
||||||
|
Assert.Equal(2, handler.Requests.Count);
|
||||||
|
Assert.Equal(2, credentials.CallCount);
|
||||||
|
Assert.Equal(
|
||||||
|
"AWS4-HMAC-SHA256 Credential=access/20260724/us-east-1/execute-api/aws4_request, "
|
||||||
|
+ "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, "
|
||||||
|
+ "Signature=32f2ce331e25cf7e165126108eab8a0e7313fd051647df89ee8e658cfe4a967a",
|
||||||
|
handler.Requests[0].Authorization);
|
||||||
|
Assert.All(handler.Requests, request =>
|
||||||
|
{
|
||||||
|
Assert.Equal("procurement-api.seahaven.com", request.Uri.Host);
|
||||||
|
Assert.Contains("limit=100", request.Uri.Query);
|
||||||
|
Assert.StartsWith("AWS4-HMAC-SHA256 Credential=access/", request.Authorization);
|
||||||
|
Assert.Contains("/us-east-1/execute-api/aws4_request", request.Authorization);
|
||||||
|
Assert.Equal("session-token", request.SecurityToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Encodes_cursor_and_uses_separate_comment_path()
|
||||||
|
{
|
||||||
|
var handler = new RecordingHandler(Json(HttpStatusCode.OK,
|
||||||
|
"""{"items":[{"work_order_id":"123","comment_id":"c1","text":"mock"}],"next_cursor":null}"""));
|
||||||
|
var client = Create(handler, new RecordingCredentialsProvider());
|
||||||
|
|
||||||
|
var page = await client.GetCommentsAsync("123", "a/b+c=", 50, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("c1", Assert.Single(page.Items).CommentId);
|
||||||
|
Assert.Equal(
|
||||||
|
"/work-orders/123/comments?cursor=a%2Fb%2Bc%3D&limit=50",
|
||||||
|
Assert.Single(handler.Requests).Uri.PathAndQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Retries_transient_network_failure_and_accepts_legacy_null_updated_at()
|
||||||
|
{
|
||||||
|
var handler = new ThrowingThenSuccessHandler(Json(HttpStatusCode.OK,
|
||||||
|
"""{"items":[{"work_order_id":"123","updated_at":null,"created_at":null}],"next_cursor":null}"""));
|
||||||
|
var credentials = new RecordingCredentialsProvider();
|
||||||
|
var client = Create(handler, credentials);
|
||||||
|
|
||||||
|
var page = await client.GetWorkOrdersAsync(null, 100, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(Assert.Single(page.Items).UpdatedAt);
|
||||||
|
Assert.Equal(2, handler.CallCount);
|
||||||
|
Assert.Equal(2, credentials.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_unapproved_origin_and_oversized_response()
|
||||||
|
{
|
||||||
|
var options = Options("https://attacker.invalid");
|
||||||
|
var client = new ProcurementWorkOrderClient(
|
||||||
|
new HttpClient(new RecordingHandler(Json(HttpStatusCode.OK, """{"items":[]}"""))),
|
||||||
|
new RecordingCredentialsProvider(),
|
||||||
|
options,
|
||||||
|
TimeProvider.System);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => client.GetWorkOrdersAsync(null, 100, CancellationToken.None));
|
||||||
|
|
||||||
|
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(new byte[1025])
|
||||||
|
});
|
||||||
|
var bounded = Create(handler, new RecordingCredentialsProvider(), maxResponseBytes: 1024);
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(
|
||||||
|
() => bounded.GetWorkOrdersAsync(null, 100, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcurementWorkOrderClient Create(
|
||||||
|
HttpMessageHandler handler,
|
||||||
|
IProcurementAwsCredentialsProvider credentials,
|
||||||
|
int maxResponseBytes = 4096) =>
|
||||||
|
new(
|
||||||
|
new HttpClient(handler),
|
||||||
|
credentials,
|
||||||
|
Options("https://procurement-api.seahaven.com", maxResponseBytes),
|
||||||
|
new FixedTimeProvider());
|
||||||
|
|
||||||
|
private static IOptionsMonitor<WorkOrderReconciliationOptions> Options(
|
||||||
|
string baseUrl,
|
||||||
|
int maxResponseBytes = 4096) =>
|
||||||
|
new TestOptions(new WorkOrderReconciliationOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
BaseUrl = baseUrl,
|
||||||
|
MaxRetries = 1,
|
||||||
|
RetryBaseDelayMilliseconds = 0,
|
||||||
|
MaxResponseBytes = maxResponseBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
private static HttpResponseMessage Json(HttpStatusCode status, string json) =>
|
||||||
|
new(status) { Content = new StringContent(json) };
|
||||||
|
|
||||||
|
private sealed class RecordingCredentialsProvider : IProcurementAwsCredentialsProvider
|
||||||
|
{
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<ImmutableCredentials> GetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
return Task.FromResult(new ImmutableCredentials(
|
||||||
|
"access",
|
||||||
|
"secret",
|
||||||
|
"session-token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Queue<HttpResponseMessage> _responses;
|
||||||
|
|
||||||
|
public RecordingHandler(params HttpResponseMessage[] responses)
|
||||||
|
{
|
||||||
|
_responses = new Queue<HttpResponseMessage>(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<RecordedRequest> Requests { get; } = new();
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Requests.Add(new RecordedRequest(
|
||||||
|
request.RequestUri!,
|
||||||
|
request.Headers.TryGetValues("Authorization", out var authorization)
|
||||||
|
? authorization.Single()
|
||||||
|
: string.Empty,
|
||||||
|
request.Headers.TryGetValues("X-Amz-Security-Token", out var values)
|
||||||
|
? values.Single()
|
||||||
|
: null));
|
||||||
|
return Task.FromResult(_responses.Dequeue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingThenSuccessHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpResponseMessage _response;
|
||||||
|
|
||||||
|
public ThrowingThenSuccessHandler(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
_response = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
return CallCount == 1
|
||||||
|
? Task.FromException<HttpResponseMessage>(
|
||||||
|
new HttpRequestException("transient mock failure"))
|
||||||
|
: Task.FromResult(_response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RecordedRequest(Uri Uri, string Authorization, string? SecurityToken);
|
||||||
|
|
||||||
|
private sealed class TestOptions : IOptionsMonitor<WorkOrderReconciliationOptions>
|
||||||
|
{
|
||||||
|
public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value;
|
||||||
|
public WorkOrderReconciliationOptions CurrentValue { get; }
|
||||||
|
public WorkOrderReconciliationOptions Get(string? name) => CurrentValue;
|
||||||
|
public IDisposable? OnChange(
|
||||||
|
Action<WorkOrderReconciliationOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() =>
|
||||||
|
DateTimeOffset.Parse("2026-07-24T12:00:00Z");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class WorkOrderReconciliationControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_trigger_returns_durable_run_and_status_is_queryable()
|
||||||
|
{
|
||||||
|
var runId = Guid.NewGuid();
|
||||||
|
var service = new StubService(runId);
|
||||||
|
var controller = new WorkOrderReconciliationController(service);
|
||||||
|
|
||||||
|
var trigger = Assert.IsType<AcceptedResult>(
|
||||||
|
await controller.Trigger(CancellationToken.None));
|
||||||
|
Assert.Equal(StatusCodes.Status202Accepted, trigger.StatusCode);
|
||||||
|
Assert.Equal(runId, service.LastRunId);
|
||||||
|
var status = Assert.IsType<OkObjectResult>(
|
||||||
|
await controller.Status(CancellationToken.None));
|
||||||
|
Assert.IsType<WorkOrderReconciliationStatus>(status.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disabled_trigger_returns_sanitized_unavailable_response()
|
||||||
|
{
|
||||||
|
var controller = new WorkOrderReconciliationController(new StubService(Guid.Empty));
|
||||||
|
|
||||||
|
var result = Assert.IsType<ObjectResult>(
|
||||||
|
await controller.Trigger(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode);
|
||||||
|
Assert.DoesNotContain("exception", result.Value!.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubService : IWorkOrderReconciliationService
|
||||||
|
{
|
||||||
|
private readonly Guid _runId;
|
||||||
|
public StubService(Guid runId) => _runId = runId;
|
||||||
|
public Guid LastRunId { get; private set; }
|
||||||
|
|
||||||
|
public Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastRunId = _runId;
|
||||||
|
return Task.FromResult(new WorkOrderReconciliationTriggerResult(
|
||||||
|
_runId != Guid.Empty,
|
||||||
|
_runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkOrderReconciliationStatus> GetStatusAsync(
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(new WorkOrderReconciliationStatus(
|
||||||
|
_runId,
|
||||||
|
"Pending",
|
||||||
|
"admin",
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null));
|
||||||
|
|
||||||
|
public Task<bool> RunPendingAsync(CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[Route("api/admin/work-order-reconciliation")]
|
||||||
|
public sealed class WorkOrderReconciliationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderReconciliationService _service;
|
||||||
|
|
||||||
|
public WorkOrderReconciliationController(IWorkOrderReconciliationService service)
|
||||||
|
{
|
||||||
|
_service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Trigger(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _service.TriggerAsync("admin", cancellationToken);
|
||||||
|
if (!result.Queued && result.RunId == Guid.Empty)
|
||||||
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "disabled" });
|
||||||
|
|
||||||
|
return Accepted(new { run_id = result.RunId, queued = result.Queued });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Status(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await _service.GetStatusAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.HostedServices
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderReconciliationHostedService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IOptionsMonitor<WorkOrderReconciliationOptions> _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<WorkOrderReconciliationHostedService> _logger;
|
||||||
|
|
||||||
|
public WorkOrderReconciliationHostedService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptionsMonitor<WorkOrderReconciliationOptions> options,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<WorkOrderReconciliationHostedService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_options = options;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var nextScheduledAt = _timeProvider.GetUtcNow();
|
||||||
|
if (_options.CurrentValue.Enabled && _options.CurrentValue.RunOnStartup)
|
||||||
|
await TriggerAsync("startup", stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
if (options.Enabled)
|
||||||
|
{
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
if (now >= nextScheduledAt)
|
||||||
|
{
|
||||||
|
await TriggerAsync("schedule", stoppingToken);
|
||||||
|
nextScheduledAt = now.AddMinutes(options.ScheduleMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunPendingAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(
|
||||||
|
TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 300)),
|
||||||
|
_timeProvider,
|
||||||
|
stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TriggerAsync(string reason, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationService>();
|
||||||
|
await service.TriggerAsync(reason, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not queue procurement reconciliation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunPendingAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationService>();
|
||||||
|
await service.RunPendingAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not execute procurement reconciliation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Amazon.Runtime;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Infrastructure
|
||||||
|
{
|
||||||
|
internal interface IProcurementAwsCredentialsProvider
|
||||||
|
{
|
||||||
|
Task<ImmutableCredentials> GetAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DefaultProcurementAwsCredentialsProvider
|
||||||
|
: IProcurementAwsCredentialsProvider
|
||||||
|
{
|
||||||
|
public async Task<ImmutableCredentials> GetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var credentials = FallbackCredentialsFactory.GetCredentials();
|
||||||
|
return await credentials.GetCredentialsAsync().WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ProcurementWorkOrderClient : IProcurementWorkOrderClient
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = false,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IProcurementAwsCredentialsProvider _credentials;
|
||||||
|
private readonly IOptionsMonitor<WorkOrderReconciliationOptions> _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public ProcurementWorkOrderClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IProcurementAwsCredentialsProvider credentials,
|
||||||
|
IOptionsMonitor<WorkOrderReconciliationOptions> options,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_credentials = credentials;
|
||||||
|
_options = options;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
|
||||||
|
string? cursor,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
GetPageAsync<ProcurementWorkOrder>("/work-orders", cursor, limit, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
|
||||||
|
string workOrderId,
|
||||||
|
string? cursor,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (workOrderId.Length is 0 or > 64 || workOrderId.Any(c => c is < '0' or > '9'))
|
||||||
|
throw new ArgumentException("Work-order ID must be numeric.", nameof(workOrderId));
|
||||||
|
|
||||||
|
return GetPageAsync<ProcurementWorkOrderComment>(
|
||||||
|
$"/work-orders/{workOrderId}/comments",
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ProcurementPage<T>> GetPageAsync<T>(
|
||||||
|
string path,
|
||||||
|
string? cursor,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
if (limit is < 1 or > 500)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(limit));
|
||||||
|
if (cursor is { Length: > 0 } && cursor.Length > options.MaxCursorLength)
|
||||||
|
throw new ArgumentException("Cursor is too long.", nameof(cursor));
|
||||||
|
|
||||||
|
var origin = ValidateOrigin(options.BaseUrl);
|
||||||
|
var query = new List<KeyValuePair<string, string>>
|
||||||
|
{
|
||||||
|
new("limit", limit.ToString(CultureInfo.InvariantCulture))
|
||||||
|
};
|
||||||
|
if (cursor != null)
|
||||||
|
query.Add(new("cursor", cursor));
|
||||||
|
var canonicalQuery = string.Join(
|
||||||
|
"&",
|
||||||
|
query.OrderBy(k => k.Key, StringComparer.Ordinal)
|
||||||
|
.Select(k => $"{Encode(k.Key)}={Encode(k.Value)}"));
|
||||||
|
var uri = new Uri(origin, $"{path}?{canonicalQuery}");
|
||||||
|
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(TimeSpan.FromSeconds(options.RequestTimeoutSeconds));
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
await SignAsync(request, path, canonicalQuery, options.Region, timeout.Token);
|
||||||
|
response = await _httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
timeout.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (
|
||||||
|
!cancellationToken.IsCancellationRequested
|
||||||
|
&& attempt < options.MaxRetries)
|
||||||
|
{
|
||||||
|
await DelayBeforeRetryAsync(attempt, options, cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException) when (attempt < options.MaxRetries)
|
||||||
|
{
|
||||||
|
await DelayBeforeRetryAsync(attempt, options, cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if ((response.StatusCode == HttpStatusCode.TooManyRequests
|
||||||
|
|| (int)response.StatusCode >= 500)
|
||||||
|
&& attempt < options.MaxRetries)
|
||||||
|
{
|
||||||
|
await DelayBeforeRetryAsync(attempt, options, cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Procurement API returned HTTP {(int)response.StatusCode}.",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
|
||||||
|
var bytes = await ReadBoundedAsync(
|
||||||
|
response.Content,
|
||||||
|
options.MaxResponseBytes,
|
||||||
|
timeout.Token);
|
||||||
|
PageEnvelope<T>? envelope;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
envelope = JsonSerializer.Deserialize<PageEnvelope<T>>(bytes, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Procurement API returned invalid JSON.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope?.Items == null)
|
||||||
|
throw new InvalidDataException("Procurement API response is missing items.");
|
||||||
|
if (envelope.NextCursor is { Length: 0 }
|
||||||
|
|| envelope.NextCursor?.Length > options.MaxCursorLength)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Procurement API response has an invalid cursor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateItems(envelope.Items);
|
||||||
|
return new ProcurementPage<T>(envelope.Items, envelope.NextCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateItems<T>(IEnumerable<T> items)
|
||||||
|
{
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
switch (item)
|
||||||
|
{
|
||||||
|
case ProcurementWorkOrder workOrder
|
||||||
|
when !IsNumericId(workOrder.WorkOrderId)
|
||||||
|
|| !ValidStatus(workOrder.WoStatus)
|
||||||
|
|| !ValidRecordType(workOrder.RecordType):
|
||||||
|
throw new InvalidDataException("Procurement API returned an invalid work order.");
|
||||||
|
case ProcurementWorkOrderComment comment
|
||||||
|
when !IsNumericId(comment.WorkOrderId)
|
||||||
|
|| string.IsNullOrWhiteSpace(comment.CommentId)
|
||||||
|
|| comment.CommentId.Length > 450:
|
||||||
|
throw new InvalidDataException("Procurement API returned an invalid comment.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNumericId(string? value) =>
|
||||||
|
value is { Length: > 0 and <= 64 }
|
||||||
|
&& value.All(c => c is >= '0' and <= '9');
|
||||||
|
|
||||||
|
private static bool ValidStatus(string? value) =>
|
||||||
|
value == null
|
||||||
|
|| value is "new" or "assigned" or "in_progress" or "on_hold"
|
||||||
|
or "completed" or "cancelled" or "unknown";
|
||||||
|
|
||||||
|
private static bool ValidRecordType(string? value) =>
|
||||||
|
value == null
|
||||||
|
|| value is "new_work_order" or "update" or "comment" or "cancellation";
|
||||||
|
|
||||||
|
private async Task DelayBeforeRetryAsync(
|
||||||
|
int attempt,
|
||||||
|
WorkOrderReconciliationOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Delay(
|
||||||
|
TimeSpan.FromMilliseconds(
|
||||||
|
options.RetryBaseDelayMilliseconds * (1 << Math.Min(attempt, 8))),
|
||||||
|
_timeProvider,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SignAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
string path,
|
||||||
|
string canonicalQuery,
|
||||||
|
string region,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var credentials = await _credentials.GetAsync(cancellationToken);
|
||||||
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
var amzDate = now.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
|
||||||
|
var date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||||
|
const string payloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Amz-Date", amzDate);
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Amz-Content-Sha256", payloadHash);
|
||||||
|
|
||||||
|
var signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
||||||
|
var canonicalHeaders = $"host:{request.RequestUri!.Host}\n"
|
||||||
|
+ $"x-amz-content-sha256:{payloadHash}\n"
|
||||||
|
+ $"x-amz-date:{amzDate}\n";
|
||||||
|
if (credentials.UseToken)
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Amz-Security-Token", credentials.Token);
|
||||||
|
signedHeaders += ";x-amz-security-token";
|
||||||
|
canonicalHeaders += $"x-amz-security-token:{credentials.Token}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalRequest = $"GET\n{path}\n{canonicalQuery}\n"
|
||||||
|
+ $"{canonicalHeaders}\n{signedHeaders}\n{payloadHash}";
|
||||||
|
var scope = $"{date}/{region}/execute-api/aws4_request";
|
||||||
|
var stringToSign = "AWS4-HMAC-SHA256\n"
|
||||||
|
+ $"{amzDate}\n{scope}\n{Sha256Hex(canonicalRequest)}";
|
||||||
|
var signingKey = DeriveSigningKey(credentials.SecretKey, date, region, "execute-api");
|
||||||
|
var signature = Convert.ToHexString(
|
||||||
|
HMACSHA256.HashData(signingKey, Encoding.UTF8.GetBytes(stringToSign)))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
CryptographicOperations.ZeroMemory(signingKey);
|
||||||
|
request.Headers.TryAddWithoutValidation(
|
||||||
|
"Authorization",
|
||||||
|
$"AWS4-HMAC-SHA256 Credential={credentials.AccessKey}/{scope}, "
|
||||||
|
+ $"SignedHeaders={signedHeaders}, Signature={signature}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri ValidateOrigin(string value)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||||
|
|| uri.Scheme != Uri.UriSchemeHttps
|
||||||
|
|| !string.Equals(
|
||||||
|
uri.Host,
|
||||||
|
"procurement-api.seahaven.com",
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !uri.IsDefaultPort
|
||||||
|
|| uri.AbsolutePath != "/"
|
||||||
|
|| uri.Query.Length > 0
|
||||||
|
|| uri.UserInfo.Length > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Procurement API base URL is not the allowed origin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ReadBoundedAsync(
|
||||||
|
HttpContent content,
|
||||||
|
int maximumBytes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (content.Headers.ContentLength > maximumBytes)
|
||||||
|
throw new InvalidDataException("Procurement API response is too large.");
|
||||||
|
|
||||||
|
await using var source = await content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
using var destination = new MemoryStream();
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var read = await source.ReadAsync(buffer, cancellationToken);
|
||||||
|
if (read == 0)
|
||||||
|
return destination.ToArray();
|
||||||
|
if (destination.Length + read > maximumBytes)
|
||||||
|
throw new InvalidDataException("Procurement API response is too large.");
|
||||||
|
destination.Write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DeriveSigningKey(
|
||||||
|
string secret,
|
||||||
|
string date,
|
||||||
|
string region,
|
||||||
|
string service)
|
||||||
|
{
|
||||||
|
var dateKey = HMACSHA256.HashData(
|
||||||
|
Encoding.UTF8.GetBytes($"AWS4{secret}"),
|
||||||
|
Encoding.UTF8.GetBytes(date));
|
||||||
|
var regionKey = HMACSHA256.HashData(dateKey, Encoding.UTF8.GetBytes(region));
|
||||||
|
CryptographicOperations.ZeroMemory(dateKey);
|
||||||
|
var serviceKey = HMACSHA256.HashData(regionKey, Encoding.UTF8.GetBytes(service));
|
||||||
|
CryptographicOperations.ZeroMemory(regionKey);
|
||||||
|
var signingKey = HMACSHA256.HashData(serviceKey, Encoding.UTF8.GetBytes("aws4_request"));
|
||||||
|
CryptographicOperations.ZeroMemory(serviceKey);
|
||||||
|
return signingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sha256Hex(string value) =>
|
||||||
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string Encode(string value) =>
|
||||||
|
Uri.EscapeDataString(value).Replace("%7E", "~", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private sealed class PageEnvelope<T>
|
||||||
|
{
|
||||||
|
public List<T>? Items { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("next_cursor")]
|
||||||
|
public string? NextCursor { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,16 @@ builder.Services.AddSingleton<Amazon.SecretsManager.IAmazonSecretsManager>(_ =>
|
||||||
builder.Services.AddSingleton<
|
builder.Services.AddSingleton<
|
||||||
SeaHaven.Services.Interfaces.IWorkOrderWebhookSecretProvider,
|
SeaHaven.Services.Interfaces.IWorkOrderWebhookSecretProvider,
|
||||||
Api.SeaHavenIndustries.Infrastructure.AwsWorkOrderWebhookSecretProvider>();
|
Api.SeaHavenIndustries.Infrastructure.AwsWorkOrderWebhookSecretProvider>();
|
||||||
|
builder.Services.AddSingleton<
|
||||||
|
Api.SeaHavenIndustries.Infrastructure.IProcurementAwsCredentialsProvider,
|
||||||
|
Api.SeaHavenIndustries.Infrastructure.DefaultProcurementAwsCredentialsProvider>();
|
||||||
|
builder.Services.AddHttpClient<
|
||||||
|
SeaHaven.Services.Interfaces.IProcurementWorkOrderClient,
|
||||||
|
Api.SeaHavenIndustries.Infrastructure.ProcurementWorkOrderClient>()
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.Configure<WorkOrderJobsOptions>(
|
builder.Services.Configure<WorkOrderJobsOptions>(
|
||||||
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
||||||
|
|
@ -95,6 +105,26 @@ builder.Services.AddOptions<SeaHaven.Services.Configuration.WorkOrderWebhookOpti
|
||||||
&& !string.IsNullOrWhiteSpace(kvp.Value))),
|
&& !string.IsNullOrWhiteSpace(kvp.Value))),
|
||||||
"WorkOrderWebhook requires a non-empty key ID to secret ARN map when enabled.")
|
"WorkOrderWebhook requires a non-empty key ID to secret ARN map when enabled.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
builder.Services.AddOptions<SeaHaven.Services.Configuration.WorkOrderReconciliationOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection(
|
||||||
|
SeaHaven.Services.Configuration.WorkOrderReconciliationOptions.SectionName))
|
||||||
|
.Validate(
|
||||||
|
o => !o.Enabled
|
||||||
|
|| (o.BaseUrl == "https://procurement-api.seahaven.com"
|
||||||
|
&& o.Region == "us-east-1"
|
||||||
|
&& o.PageSize is >= 1 and <= 500
|
||||||
|
&& o.MaxPages is >= 1 and <= 100_000
|
||||||
|
&& o.MaxCursorLength is >= 1 and <= 16_384
|
||||||
|
&& o.RequestTimeoutSeconds is >= 1 and <= 120
|
||||||
|
&& o.MaxRetries is >= 0 and <= 8
|
||||||
|
&& o.RetryBaseDelayMilliseconds is >= 0 and <= 10_000
|
||||||
|
&& o.MaxResponseBytes is >= 1 and <= 16_777_216
|
||||||
|
&& o.PollSeconds is >= 1 and <= 300
|
||||||
|
&& o.ScheduleMinutes is >= 1 and <= 10_080
|
||||||
|
&& o.LeaseSeconds is >= 30 and <= 3_600),
|
||||||
|
"WorkOrderReconciliation configuration is invalid.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
builder.Services.AddHostedService<WorkOrderReconciliationHostedService>();
|
||||||
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
||||||
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
||||||
.Validate(
|
.Validate(
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,22 @@
|
||||||
"Region": "",
|
"Region": "",
|
||||||
"KeySecrets": {}
|
"KeySecrets": {}
|
||||||
},
|
},
|
||||||
|
"WorkOrderReconciliation": {
|
||||||
|
"Enabled": false,
|
||||||
|
"RunOnStartup": true,
|
||||||
|
"BaseUrl": "https://procurement-api.seahaven.com",
|
||||||
|
"Region": "us-east-1",
|
||||||
|
"PageSize": 100,
|
||||||
|
"MaxPages": 10000,
|
||||||
|
"MaxCursorLength": 4096,
|
||||||
|
"RequestTimeoutSeconds": 30,
|
||||||
|
"MaxRetries": 3,
|
||||||
|
"RetryBaseDelayMilliseconds": 100,
|
||||||
|
"MaxResponseBytes": 4194304,
|
||||||
|
"PollSeconds": 10,
|
||||||
|
"ScheduleMinutes": 60,
|
||||||
|
"LeaseSeconds": 120
|
||||||
|
},
|
||||||
"Sync": {
|
"Sync": {
|
||||||
"Enabled": true
|
"Enabled": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ namespace Data.SeaHavenIndustries
|
||||||
builder.Entity<WorkOrder>()
|
builder.Entity<WorkOrder>()
|
||||||
.Property(w => w.RowVersion)
|
.Property(w => w.RowVersion)
|
||||||
.IsRowVersion();
|
.IsRowVersion();
|
||||||
|
if (Database.IsSqlServer())
|
||||||
|
builder.HasSequence<long>("WorkOrderInternalNumberSequence");
|
||||||
|
|
||||||
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
|
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
|
||||||
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
|
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
|
||||||
|
|
@ -97,6 +99,32 @@ namespace Data.SeaHavenIndustries
|
||||||
.HasIndex(d => d.DeliveryId)
|
.HasIndex(d => d.DeliveryId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
builder.Entity<Comments>()
|
||||||
|
.Property(c => c.ExternalSource)
|
||||||
|
.HasMaxLength(128);
|
||||||
|
builder.Entity<Comments>()
|
||||||
|
.Property(c => c.ExternalVersionHash)
|
||||||
|
.HasMaxLength(64);
|
||||||
|
builder.Entity<Comments>()
|
||||||
|
.Property(c => c.ExternalSourceEmailS3Key)
|
||||||
|
.HasMaxLength(2048);
|
||||||
|
|
||||||
|
builder.Entity<Locations>()
|
||||||
|
.Property(l => l.ExternalSource)
|
||||||
|
.HasMaxLength(128);
|
||||||
|
builder.Entity<Locations>()
|
||||||
|
.Property(l => l.ExternalLocationId)
|
||||||
|
.HasMaxLength(450);
|
||||||
|
builder.Entity<WorkOrderExternalReceipt>()
|
||||||
|
.HasIndex(r => new { r.Source, r.Kind, r.ExternalId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId");
|
||||||
|
|
||||||
|
builder.Entity<WorkOrderReconciliationJob>()
|
||||||
|
.HasIndex(j => j.State);
|
||||||
|
builder.Entity<WorkOrderReconciliationJob>()
|
||||||
|
.Property(j => j.Id)
|
||||||
|
.ValueGeneratedNever();
|
||||||
|
|
||||||
builder.Entity<Dispatch>()
|
builder.Entity<Dispatch>()
|
||||||
.Property(d => d.RowVersion)
|
.Property(d => d.RowVersion)
|
||||||
|
|
@ -145,6 +173,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
|
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
|
||||||
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
|
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
|
||||||
public DbSet<WorkOrderWebhookDelivery> WorkOrderWebhookDeliveries { get; set; }
|
public DbSet<WorkOrderWebhookDelivery> WorkOrderWebhookDeliveries { get; set; }
|
||||||
|
public DbSet<WorkOrderReconciliationJob> WorkOrderReconciliationJobs { get; set; }
|
||||||
|
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
||||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||||
public DbSet<Vendor> Vendors { get; set; }
|
public DbSet<Vendor> Vendors { get; set; }
|
||||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||||
|
|
@ -206,6 +236,12 @@ namespace Data.SeaHavenIndustries
|
||||||
if (entry.State == EntityState.Modified)
|
if (entry.State == EntityState.Modified)
|
||||||
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var entry in ChangeTracker.Entries<WorkOrderReconciliationJob>())
|
||||||
|
{
|
||||||
|
if (entry.State == EntityState.Modified)
|
||||||
|
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] IncrementRowVersion(byte[]? current)
|
private static byte[] IncrementRowVersion(byte[]? current)
|
||||||
|
|
|
||||||
3779
Data.SeaHavenIndustries/Migrations/20260725005530_SH133_WorkOrderReconciliation.Designer.cs
generated
Normal file
3779
Data.SeaHavenIndustries/Migrations/20260725005530_SH133_WorkOrderReconciliation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,221 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SH133_WorkOrderReconciliation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
DECLARE @nextValue bigint =
|
||||||
|
COALESCE((
|
||||||
|
SELECT MAX(TRY_CONVERT(bigint, [InternalWONumber])) + 1
|
||||||
|
FROM [workOrders]
|
||||||
|
WHERE [InternalWONumber] IS NOT NULL
|
||||||
|
), 1);
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.sequences
|
||||||
|
WHERE [name] = N'WorkOrderInternalNumberSequence'
|
||||||
|
AND [schema_id] = SCHEMA_ID(N'dbo')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
DECLARE @sql nvarchar(max) =
|
||||||
|
N'CREATE SEQUENCE [dbo].[WorkOrderInternalNumberSequence] AS bigint START WITH '
|
||||||
|
+ CONVERT(nvarchar(20), @nextValue)
|
||||||
|
+ N' INCREMENT BY 1;';
|
||||||
|
EXEC sp_executesql @sql;
|
||||||
|
END
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalAssignedTo",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(450)",
|
||||||
|
maxLength: 450,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalRecordType",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalSource",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ExternalUpdatedAt",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "datetimeoffset",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalVersionHash",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "nvarchar(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalLocationId",
|
||||||
|
table: "Locations",
|
||||||
|
type: "nvarchar(450)",
|
||||||
|
maxLength: 450,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalSource",
|
||||||
|
table: "Locations",
|
||||||
|
type: "nvarchar(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalSource",
|
||||||
|
table: "Comments",
|
||||||
|
type: "nvarchar(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalSourceEmailS3Key",
|
||||||
|
table: "Comments",
|
||||||
|
type: "nvarchar(2048)",
|
||||||
|
maxLength: 2048,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ExternalUpdatedAt",
|
||||||
|
table: "Comments",
|
||||||
|
type: "datetimeoffset",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalVersionHash",
|
||||||
|
table: "Comments",
|
||||||
|
type: "nvarchar(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WorkOrderExternalReceipts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Source = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
ExternalId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
VersionHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
ProcessedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WorkOrderExternalReceipts", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WorkOrderReconciliationJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false),
|
||||||
|
RunId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
FenceToken = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
State = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
Reason = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||||
|
RequestedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
LeaseExpiresAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
WorkOrdersProcessed = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CommentsProcessed = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ErrorCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WorkOrderReconciliationJobs", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WorkOrderExternalReceipts_Source_Kind_ExternalId",
|
||||||
|
table: "WorkOrderExternalReceipts",
|
||||||
|
columns: new[] { "Source", "Kind", "ExternalId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WorkOrderReconciliationJobs_State",
|
||||||
|
table: "WorkOrderReconciliationJobs",
|
||||||
|
column: "State");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WorkOrderExternalReceipts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WorkOrderReconciliationJobs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalAssignedTo",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalRecordType",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalSource",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalUpdatedAt",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalVersionHash",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalLocationId",
|
||||||
|
table: "Locations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalSource",
|
||||||
|
table: "Locations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalSource",
|
||||||
|
table: "Comments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalSourceEmailS3Key",
|
||||||
|
table: "Comments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalUpdatedAt",
|
||||||
|
table: "Comments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalVersionHash",
|
||||||
|
table: "Comments");
|
||||||
|
|
||||||
|
migrationBuilder.DropSequence(
|
||||||
|
name: "WorkOrderInternalNumberSequence");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.HasSequence("WorkOrderInternalNumberSequence");
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -409,6 +411,21 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("ExternalCommentId")
|
b.Property<string>("ExternalCommentId")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalSource")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalSourceEmailS3Key")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExternalUpdatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalVersionHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeleted")
|
b.Property<bool?>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
|
@ -1449,6 +1466,14 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalLocationId")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalSource")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeleted")
|
b.Property<bool?>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
|
@ -2338,9 +2363,28 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<DateTime?>("DueDate")
|
b.Property<DateTime?>("DueDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalAssignedTo")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("ExternalLastOccurredAt")
|
b.Property<DateTimeOffset?>("ExternalLastOccurredAt")
|
||||||
.HasColumnType("datetimeoffset");
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalRecordType")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalSource")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExternalUpdatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalVersionHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<string>("ExternalWorkOrderId")
|
b.Property<string>("ExternalWorkOrderId")
|
||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
@ -2713,6 +2757,49 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("WorkOrderContacts");
|
b.ToTable("WorkOrderContacts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderExternalReceipt", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ExternalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ProcessedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("VersionHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Source", "Kind", "ExternalId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId");
|
||||||
|
|
||||||
|
b.ToTable("WorkOrderExternalReceipts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -2742,6 +2829,60 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("WorkOrderFieldLocks");
|
b.ToTable("WorkOrderFieldLocks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderReconciliationJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("CommentsProcessed")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CompletedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorCode")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FenceToken")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LeaseExpiresAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RequestedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<byte[]>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("rowversion");
|
||||||
|
|
||||||
|
b.Property<Guid>("RunId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<int>("WorkOrdersProcessed")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("State");
|
||||||
|
|
||||||
|
b.ToTable("WorkOrderReconciliationJobs");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? RecordType { get; set; }
|
public string? RecordType { get; set; }
|
||||||
public string? CommentType { get; set; }
|
public string? CommentType { get; set; }
|
||||||
public string? ExternalCommentId { get; set; }
|
public string? ExternalCommentId { get; set; }
|
||||||
|
public string? ExternalSource { get; set; }
|
||||||
|
public DateTimeOffset? ExternalUpdatedAt { get; set; }
|
||||||
|
public string? ExternalVersionHash { get; set; }
|
||||||
|
public string? ExternalSourceEmailS3Key { get; set; }
|
||||||
public string? Documents { get; set; }
|
public string? Documents { get; set; }
|
||||||
public string? UserId { get; set; }
|
public string? UserId { get; set; }
|
||||||
[ForeignKey(nameof(UserId))]
|
[ForeignKey(nameof(UserId))]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? PhoneNumber { get; set; }
|
public string? PhoneNumber { get; set; }
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
public string? ExternalSource { get; set; }
|
||||||
|
public string? ExternalLocationId { get; set; }
|
||||||
|
|
||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public ICollection<Template>? Templates { get; set; }
|
public ICollection<Template>? Templates { get; set; }
|
||||||
|
|
|
||||||
19
Data.SeaHavenIndustries/Models/WorkOrderExternalReceipt.cs
Normal file
19
Data.SeaHavenIndustries/Models/WorkOrderExternalReceipt.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderExternalReceipt
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
[MaxLength(128)]
|
||||||
|
public required string Source { get; set; }
|
||||||
|
[MaxLength(32)]
|
||||||
|
public required string Kind { get; set; }
|
||||||
|
[MaxLength(450)]
|
||||||
|
public required string ExternalId { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
[MaxLength(64)]
|
||||||
|
public required string VersionHash { get; set; }
|
||||||
|
public DateTimeOffset ProcessedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Data.SeaHavenIndustries/Models/WorkOrderReconciliationJob.cs
Normal file
25
Data.SeaHavenIndustries/Models/WorkOrderReconciliationJob.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderReconciliationJob
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public Guid RunId { get; set; }
|
||||||
|
public Guid? FenceToken { get; set; }
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string State { get; set; } = "Idle";
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
public DateTimeOffset? RequestedAt { get; set; }
|
||||||
|
public DateTimeOffset? StartedAt { get; set; }
|
||||||
|
public DateTimeOffset? LeaseExpiresAt { get; set; }
|
||||||
|
public DateTimeOffset? CompletedAt { get; set; }
|
||||||
|
public int WorkOrdersProcessed { get; set; }
|
||||||
|
public int CommentsProcessed { get; set; }
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? ErrorCode { get; set; }
|
||||||
|
[Timestamp]
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,15 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? InternalWONumber { get; set; }
|
public string? InternalWONumber { get; set; }
|
||||||
public string? ExternalWorkOrderId { get; set; }
|
public string? ExternalWorkOrderId { get; set; }
|
||||||
public DateTimeOffset? ExternalLastOccurredAt { get; set; }
|
public DateTimeOffset? ExternalLastOccurredAt { get; set; }
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? ExternalSource { get; set; }
|
||||||
|
public DateTimeOffset? ExternalUpdatedAt { get; set; }
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? ExternalVersionHash { get; set; }
|
||||||
|
[MaxLength(450)]
|
||||||
|
public string? ExternalAssignedTo { get; set; }
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? ExternalRecordType { get; set; }
|
||||||
public string? WorkerOrderNumber { get; set; }
|
public string? WorkerOrderNumber { get; set; }
|
||||||
public string? WorkerOrderTitle { get; set; }
|
public string? WorkerOrderTitle { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderReconciliationDataService : IWorkOrderReconciliationDataService
|
||||||
|
{
|
||||||
|
private const int SingletonId = 1;
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public WorkOrderReconciliationDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReconciliationJobSnapshot> EnqueueAsync(
|
||||||
|
string reason,
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await GetOrCreateAsync(cancellationToken);
|
||||||
|
if (job.State is not ("Pending" or "Running"))
|
||||||
|
{
|
||||||
|
job.RunId = Guid.NewGuid();
|
||||||
|
job.FenceToken = null;
|
||||||
|
job.State = "Pending";
|
||||||
|
job.Reason = reason[..Math.Min(reason.Length, 128)];
|
||||||
|
job.RequestedAt = now;
|
||||||
|
job.StartedAt = null;
|
||||||
|
job.LeaseExpiresAt = null;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.WorkOrdersProcessed = 0;
|
||||||
|
job.CommentsProcessed = 0;
|
||||||
|
job.ErrorCode = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
job = await _context.WorkOrderReconciliationJobs
|
||||||
|
.SingleAsync(j => j.Id == SingletonId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Snapshot(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReconciliationLease?> TryAcquirePendingAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await GetOrCreateAsync(cancellationToken);
|
||||||
|
var leaseExpired = job.State == "Running"
|
||||||
|
&& job.LeaseExpiresAt.HasValue
|
||||||
|
&& job.LeaseExpiresAt <= now;
|
||||||
|
if (job.State != "Pending" && !leaseExpired)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
job.State = "Running";
|
||||||
|
job.StartedAt ??= now;
|
||||||
|
job.LeaseExpiresAt = now.Add(leaseDuration);
|
||||||
|
job.FenceToken = Guid.NewGuid();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return new ReconciliationLease(job.RunId, job.FenceToken.Value);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReconciliationJobSnapshot> GetStatusAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _context.WorkOrderReconciliationJobs
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(j => j.Id == SingletonId, cancellationToken);
|
||||||
|
return job == null
|
||||||
|
? new ReconciliationJobSnapshot(null, "Idle", null, null, null, null, 0, 0, null)
|
||||||
|
: Snapshot(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RenewLeaseAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset now,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _context.WorkOrderReconciliationJobs.SingleOrDefaultAsync(
|
||||||
|
j => j.Id == SingletonId
|
||||||
|
&& j.RunId == runId
|
||||||
|
&& j.FenceToken == fenceToken
|
||||||
|
&& j.State == "Running",
|
||||||
|
cancellationToken);
|
||||||
|
if (job == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
job.LeaseExpiresAt = now.Add(leaseDuration);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset completedAt,
|
||||||
|
int workOrders,
|
||||||
|
int comments,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
FinishAsync(runId, fenceToken, completedAt, "Succeeded", workOrders, comments, null, cancellationToken);
|
||||||
|
|
||||||
|
public Task FailAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset completedAt,
|
||||||
|
string errorCode,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
FinishAsync(runId, fenceToken, completedAt, "Failed", 0, 0, errorCode, cancellationToken);
|
||||||
|
|
||||||
|
private async Task FinishAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset completedAt,
|
||||||
|
string state,
|
||||||
|
int workOrders,
|
||||||
|
int comments,
|
||||||
|
string? errorCode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _context.WorkOrderReconciliationJobs
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
j => j.Id == SingletonId
|
||||||
|
&& j.RunId == runId
|
||||||
|
&& j.FenceToken == fenceToken
|
||||||
|
&& j.State == "Running",
|
||||||
|
cancellationToken);
|
||||||
|
if (job == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.State = state;
|
||||||
|
job.CompletedAt = completedAt;
|
||||||
|
job.LeaseExpiresAt = null;
|
||||||
|
job.WorkOrdersProcessed = workOrders;
|
||||||
|
job.CommentsProcessed = comments;
|
||||||
|
job.ErrorCode = errorCode;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WorkOrderReconciliationJob> GetOrCreateAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _context.WorkOrderReconciliationJobs
|
||||||
|
.SingleOrDefaultAsync(j => j.Id == SingletonId, cancellationToken);
|
||||||
|
if (job != null)
|
||||||
|
return job;
|
||||||
|
|
||||||
|
job = new WorkOrderReconciliationJob { Id = SingletonId, RunId = Guid.NewGuid() };
|
||||||
|
_context.WorkOrderReconciliationJobs.Add(job);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
return await _context.WorkOrderReconciliationJobs
|
||||||
|
.SingleAsync(j => j.Id == SingletonId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReconciliationJobSnapshot Snapshot(WorkOrderReconciliationJob job) =>
|
||||||
|
new(
|
||||||
|
job.RunId,
|
||||||
|
job.State,
|
||||||
|
job.Reason,
|
||||||
|
job.RequestedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt,
|
||||||
|
job.WorkOrdersProcessed,
|
||||||
|
job.CommentsProcessed,
|
||||||
|
job.ErrorCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
public sealed class WorkOrderWebhookDataService : IWorkOrderWebhookDataService
|
public sealed class WorkOrderWebhookDataService : IWorkOrderWebhookDataService
|
||||||
{
|
{
|
||||||
|
private static readonly SemaphoreSlim InMemoryNumberLock = new(1, 1);
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
public WorkOrderWebhookDataService(ApplicationDbContext context)
|
public WorkOrderWebhookDataService(ApplicationDbContext context)
|
||||||
|
|
@ -31,17 +32,13 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
if (workOrder == null)
|
if (workOrder == null)
|
||||||
{
|
{
|
||||||
var nextInternalNumber = Math.Max(
|
var internalNumber = await AllocateInternalNumberAsync(cancellationToken);
|
||||||
await _context.workOrders.MaxAsync(
|
|
||||||
w => (int?)w.Id,
|
|
||||||
cancellationToken) ?? 0,
|
|
||||||
0)
|
|
||||||
+ 1;
|
|
||||||
workOrder = new WorkOrder
|
workOrder = new WorkOrder
|
||||||
{
|
{
|
||||||
ExternalWorkOrderId = mutation.ExternalWorkOrderId,
|
ExternalWorkOrderId = mutation.ExternalWorkOrderId,
|
||||||
|
ExternalSource = mutation.Source,
|
||||||
WorkerOrderNumber = mutation.WorkerOrderNumber,
|
WorkerOrderNumber = mutation.WorkerOrderNumber,
|
||||||
InternalWONumber = nextInternalNumber.ToString("D11"),
|
InternalWONumber = internalNumber.ToString("D11"),
|
||||||
WorkerOrderTitle = mutation.Title ?? mutation.Description
|
WorkerOrderTitle = mutation.Title ?? mutation.Description
|
||||||
?? $"Imported work order {mutation.ExternalWorkOrderId}",
|
?? $"Imported work order {mutation.ExternalWorkOrderId}",
|
||||||
Description = mutation.Description,
|
Description = mutation.Description,
|
||||||
|
|
@ -53,22 +50,36 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
var staleState = mutation.IsStateEvent
|
var staleState = mutation.IsStateEvent
|
||||||
&& workOrder.ExternalLastOccurredAt.HasValue
|
&& !IsNewer(
|
||||||
&& mutation.OccurredAt < workOrder.ExternalLastOccurredAt.Value;
|
mutation.UpdatedAt,
|
||||||
|
mutation.VersionHash,
|
||||||
|
workOrder.ExternalUpdatedAt,
|
||||||
|
workOrder.ExternalVersionHash);
|
||||||
|
|
||||||
if (mutation.IsStateEvent && !staleState)
|
if (mutation.IsStateEvent && !staleState)
|
||||||
{
|
{
|
||||||
var locationKey = mutation.SiteCode ?? mutation.Building;
|
var locationKey = mutation.SiteCode ?? mutation.Building;
|
||||||
if (!string.IsNullOrWhiteSpace(locationKey))
|
if (!string.IsNullOrWhiteSpace(locationKey))
|
||||||
{
|
{
|
||||||
|
var locationIsNewer = await UpsertReceiptAsync(
|
||||||
|
mutation.Source,
|
||||||
|
"location",
|
||||||
|
locationKey,
|
||||||
|
mutation.UpdatedAt,
|
||||||
|
mutation.VersionHash,
|
||||||
|
mutation.ProcessedAt,
|
||||||
|
cancellationToken);
|
||||||
var location = await _context.Locations
|
var location = await _context.Locations
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
l => l.Name == locationKey || l.Title == locationKey,
|
l => l.ExternalSource == mutation.Source
|
||||||
|
&& l.ExternalLocationId == locationKey,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (location == null)
|
if (location == null)
|
||||||
{
|
{
|
||||||
location = new Locations
|
location = new Locations
|
||||||
{
|
{
|
||||||
|
ExternalSource = mutation.Source,
|
||||||
|
ExternalLocationId = locationKey,
|
||||||
Name = locationKey,
|
Name = locationKey,
|
||||||
Title = mutation.Building,
|
Title = mutation.Building,
|
||||||
Address1 = mutation.Address,
|
Address1 = mutation.Address,
|
||||||
|
|
@ -76,6 +87,13 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
};
|
};
|
||||||
_context.Locations.Add(location);
|
_context.Locations.Add(location);
|
||||||
}
|
}
|
||||||
|
else if (locationIsNewer)
|
||||||
|
{
|
||||||
|
location.Name = locationKey;
|
||||||
|
location.Title = mutation.Building;
|
||||||
|
location.Address1 = mutation.Address;
|
||||||
|
location.Status = "Active";
|
||||||
|
}
|
||||||
|
|
||||||
workOrder.Locations = location;
|
workOrder.Locations = location;
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +104,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
||||||
workOrder.Severity = mutation.Severity;
|
workOrder.Severity = mutation.Severity;
|
||||||
workOrder.Priority = mutation.Priority;
|
workOrder.Priority = mutation.Priority;
|
||||||
|
workOrder.ExternalAssignedTo = mutation.AssignedTo;
|
||||||
|
workOrder.ExternalRecordType = mutation.RecordType;
|
||||||
workOrder.Customer = mutation.Customer;
|
workOrder.Customer = mutation.Customer;
|
||||||
workOrder.SiteCode = mutation.SiteCode;
|
workOrder.SiteCode = mutation.SiteCode;
|
||||||
workOrder.Building = mutation.Building;
|
workOrder.Building = mutation.Building;
|
||||||
|
|
@ -93,27 +113,51 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
workOrder.DateReported = mutation.DateReported;
|
workOrder.DateReported = mutation.DateReported;
|
||||||
workOrder.ScheduledStart = mutation.ScheduledStart;
|
workOrder.ScheduledStart = mutation.ScheduledStart;
|
||||||
workOrder.Source = mutation.Source;
|
workOrder.Source = mutation.Source;
|
||||||
|
workOrder.SourceEmailS3Key = mutation.SourceEmailS3Key;
|
||||||
|
workOrder.ExternalSource = mutation.Source;
|
||||||
workOrder.istemplate = false;
|
workOrder.istemplate = false;
|
||||||
workOrder.ExternalLastOccurredAt = mutation.OccurredAt;
|
workOrder.ExternalLastOccurredAt = mutation.OccurredAt;
|
||||||
|
workOrder.ExternalUpdatedAt = mutation.UpdatedAt;
|
||||||
|
workOrder.ExternalVersionHash = mutation.VersionHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mutation.CommentId != null)
|
if (mutation.CommentId != null)
|
||||||
{
|
{
|
||||||
var commentExists = await _context.Comments
|
var receiptIsNewer = await UpsertReceiptAsync(
|
||||||
.AnyAsync(c => c.ExternalCommentId == mutation.CommentId, cancellationToken);
|
mutation.Source,
|
||||||
|
"comment",
|
||||||
|
mutation.CommentId,
|
||||||
|
mutation.UpdatedAt,
|
||||||
|
mutation.VersionHash,
|
||||||
|
mutation.ProcessedAt,
|
||||||
|
cancellationToken);
|
||||||
|
var comment = await _context.Comments
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
c => c.ExternalSource == mutation.Source
|
||||||
|
&& c.ExternalCommentId == mutation.CommentId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
if (!commentExists)
|
if (comment == null)
|
||||||
{
|
{
|
||||||
_context.Comments.Add(new Comments
|
comment = new Comments
|
||||||
{
|
{
|
||||||
|
ExternalSource = mutation.Source,
|
||||||
ExternalCommentId = mutation.CommentId,
|
ExternalCommentId = mutation.CommentId,
|
||||||
Commenttext = mutation.CommentText,
|
|
||||||
Commenter = mutation.Commenter,
|
|
||||||
CommentType = mutation.CommentType,
|
|
||||||
RecordType = "WorkOrder",
|
RecordType = "WorkOrder",
|
||||||
WorkOrder = workOrder,
|
WorkOrder = workOrder,
|
||||||
CreatedDate = mutation.OccurredAt.UtcDateTime
|
CreatedDate = mutation.OccurredAt.UtcDateTime,
|
||||||
});
|
};
|
||||||
|
_context.Comments.Add(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiptIsNewer)
|
||||||
|
{
|
||||||
|
comment.Commenttext = mutation.CommentText;
|
||||||
|
comment.Commenter = mutation.Commenter;
|
||||||
|
comment.CommentType = mutation.CommentType;
|
||||||
|
comment.ExternalUpdatedAt = mutation.UpdatedAt;
|
||||||
|
comment.ExternalVersionHash = mutation.VersionHash;
|
||||||
|
comment.ExternalSourceEmailS3Key = mutation.SourceEmailS3Key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,6 +191,85 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<long> AllocateInternalNumberAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_context.Database.IsSqlServer())
|
||||||
|
{
|
||||||
|
return await _context.Database
|
||||||
|
.SqlQueryRaw<long>(
|
||||||
|
"SELECT NEXT VALUE FOR dbo.WorkOrderInternalNumberSequence AS [Value]")
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await InMemoryNumberLock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var values = await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => w.InternalWONumber != null)
|
||||||
|
.Select(w => w.InternalWONumber!)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return values
|
||||||
|
.Select(v => long.TryParse(v, out var parsed) ? parsed : 0L)
|
||||||
|
.DefaultIfEmpty()
|
||||||
|
.Max() + 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
InMemoryNumberLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> UpsertReceiptAsync(
|
||||||
|
string source,
|
||||||
|
string kind,
|
||||||
|
string externalId,
|
||||||
|
DateTimeOffset updatedAt,
|
||||||
|
string versionHash,
|
||||||
|
DateTimeOffset processedAt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var receipt = await _context.WorkOrderExternalReceipts.SingleOrDefaultAsync(
|
||||||
|
r => r.Source == source && r.Kind == kind && r.ExternalId == externalId,
|
||||||
|
cancellationToken);
|
||||||
|
if (receipt == null)
|
||||||
|
{
|
||||||
|
_context.WorkOrderExternalReceipts.Add(new WorkOrderExternalReceipt
|
||||||
|
{
|
||||||
|
Source = source,
|
||||||
|
Kind = kind,
|
||||||
|
ExternalId = externalId,
|
||||||
|
UpdatedAt = updatedAt,
|
||||||
|
VersionHash = versionHash,
|
||||||
|
ProcessedAt = processedAt
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsNewer(updatedAt, versionHash, receipt.UpdatedAt, receipt.VersionHash))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
receipt.UpdatedAt = updatedAt;
|
||||||
|
receipt.VersionHash = versionHash;
|
||||||
|
receipt.ProcessedAt = processedAt;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNewer(
|
||||||
|
DateTimeOffset candidateUpdatedAt,
|
||||||
|
string candidateHash,
|
||||||
|
DateTimeOffset? currentUpdatedAt,
|
||||||
|
string? currentHash)
|
||||||
|
{
|
||||||
|
if (!currentUpdatedAt.HasValue)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var timestampComparison = candidateUpdatedAt.CompareTo(currentUpdatedAt.Value);
|
||||||
|
return timestampComparison > 0
|
||||||
|
|| (timestampComparison == 0
|
||||||
|
&& string.CompareOrdinal(candidateHash, currentHash ?? string.Empty) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkOrderWebhookPersistenceResult ExistingDeliveryResult(
|
private static WorkOrderWebhookPersistenceResult ExistingDeliveryResult(
|
||||||
WorkOrderWebhookDelivery delivery,
|
WorkOrderWebhookDelivery delivery,
|
||||||
string bodySha256)
|
string bodySha256)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderReconciliationDataService
|
||||||
|
{
|
||||||
|
Task<ReconciliationJobSnapshot> EnqueueAsync(
|
||||||
|
string reason,
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<ReconciliationLease?> TryAcquirePendingAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<ReconciliationJobSnapshot> GetStatusAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<bool> RenewLeaseAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset now,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task CompleteAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset completedAt,
|
||||||
|
int workOrders,
|
||||||
|
int comments,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task FailAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset completedAt,
|
||||||
|
string errorCode,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ReconciliationLease(Guid RunId, Guid FenceToken);
|
||||||
|
|
||||||
|
public sealed record ReconciliationJobSnapshot(
|
||||||
|
Guid? RunId,
|
||||||
|
string State,
|
||||||
|
string? Reason,
|
||||||
|
DateTimeOffset? RequestedAt,
|
||||||
|
DateTimeOffset? StartedAt,
|
||||||
|
DateTimeOffset? CompletedAt,
|
||||||
|
int WorkOrdersProcessed,
|
||||||
|
int CommentsProcessed,
|
||||||
|
string? ErrorCode);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
public required string ExternalWorkOrderId { get; init; }
|
public required string ExternalWorkOrderId { get; init; }
|
||||||
public required string WorkerOrderNumber { get; init; }
|
public required string WorkerOrderNumber { get; init; }
|
||||||
public required string Source { get; init; }
|
public required string Source { get; init; }
|
||||||
|
public required DateTimeOffset UpdatedAt { get; init; }
|
||||||
|
public required string VersionHash { get; init; }
|
||||||
public bool IsStateEvent { get; init; }
|
public bool IsStateEvent { get; init; }
|
||||||
public bool IsCancelled { get; init; }
|
public bool IsCancelled { get; init; }
|
||||||
public string? Title { get; init; }
|
public string? Title { get; init; }
|
||||||
|
|
@ -24,6 +26,9 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
public string? Status { get; init; }
|
public string? Status { get; init; }
|
||||||
public string? Severity { get; init; }
|
public string? Severity { get; init; }
|
||||||
public string? Priority { get; init; }
|
public string? Priority { get; init; }
|
||||||
|
public string? AssignedTo { get; init; }
|
||||||
|
public string? RecordType { get; init; }
|
||||||
|
public string? SourceEmailS3Key { get; init; }
|
||||||
public string? Customer { get; init; }
|
public string? Customer { get; init; }
|
||||||
public string? SiteCode { get; init; }
|
public string? SiteCode { get; init; }
|
||||||
public string? Building { get; init; }
|
public string? Building { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace SeaHaven.Services.Configuration
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderReconciliationOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "WorkOrderReconciliation";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public bool RunOnStartup { get; set; } = true;
|
||||||
|
public string BaseUrl { get; set; } = "https://procurement-api.seahaven.com";
|
||||||
|
public string Region { get; set; } = "us-east-1";
|
||||||
|
public int PageSize { get; set; } = 100;
|
||||||
|
public int MaxPages { get; set; } = 10_000;
|
||||||
|
public int MaxCursorLength { get; set; } = 4_096;
|
||||||
|
public int RequestTimeoutSeconds { get; set; } = 30;
|
||||||
|
public int MaxRetries { get; set; } = 3;
|
||||||
|
public int RetryBaseDelayMilliseconds { get; set; } = 100;
|
||||||
|
public int MaxResponseBytes { get; set; } = 4_194_304;
|
||||||
|
public int PollSeconds { get; set; } = 10;
|
||||||
|
public int ScheduleMinutes { get; set; } = 60;
|
||||||
|
public int LeaseSeconds { get; set; } = 120;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
SeaHaven.Services/Helpers/WorkOrderExternalVersion.cs
Normal file
22
SeaHaven.Services/Helpers/WorkOrderExternalVersion.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderExternalVersion
|
||||||
|
{
|
||||||
|
public static string Compute(params string?[] values)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
var normalized = value ?? string.Empty;
|
||||||
|
buffer.Append(normalized.Length).Append(':').Append(normalized).Append('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ToHexString(
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(buffer.ToString())))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService
|
||||||
|
{
|
||||||
|
private const string Source = "procurement";
|
||||||
|
private readonly IProcurementWorkOrderClient _client;
|
||||||
|
private readonly IWorkOrderWebhookDataService _workOrders;
|
||||||
|
private readonly IWorkOrderReconciliationDataService _jobs;
|
||||||
|
private readonly IOptionsMonitor<WorkOrderReconciliationOptions> _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<WorkOrderReconciliationService> _logger;
|
||||||
|
|
||||||
|
public WorkOrderReconciliationService(
|
||||||
|
IProcurementWorkOrderClient client,
|
||||||
|
IWorkOrderWebhookDataService workOrders,
|
||||||
|
IWorkOrderReconciliationDataService jobs,
|
||||||
|
IOptionsMonitor<WorkOrderReconciliationOptions> options,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<WorkOrderReconciliationService> logger)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_workOrders = workOrders;
|
||||||
|
_jobs = jobs;
|
||||||
|
_options = options;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.CurrentValue.Enabled)
|
||||||
|
return new WorkOrderReconciliationTriggerResult(false, Guid.Empty);
|
||||||
|
|
||||||
|
var before = await _jobs.GetStatusAsync(cancellationToken);
|
||||||
|
var queued = await _jobs.EnqueueAsync(reason, _timeProvider.GetUtcNow(), cancellationToken);
|
||||||
|
return new WorkOrderReconciliationTriggerResult(
|
||||||
|
before.State is not ("Pending" or "Running"),
|
||||||
|
queued.RunId ?? Guid.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderReconciliationStatus> GetStatusAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var status = await _jobs.GetStatusAsync(cancellationToken);
|
||||||
|
return new WorkOrderReconciliationStatus(
|
||||||
|
status.RunId,
|
||||||
|
status.State,
|
||||||
|
status.Reason,
|
||||||
|
status.RequestedAt,
|
||||||
|
status.StartedAt,
|
||||||
|
status.CompletedAt,
|
||||||
|
status.WorkOrdersProcessed,
|
||||||
|
status.CommentsProcessed,
|
||||||
|
status.ErrorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RunPendingAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
if (!options.Enabled)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var leaseDuration = TimeSpan.FromSeconds(options.LeaseSeconds);
|
||||||
|
var lease = await _jobs.TryAcquirePendingAsync(
|
||||||
|
_timeProvider.GetUtcNow(),
|
||||||
|
leaseDuration,
|
||||||
|
cancellationToken);
|
||||||
|
if (lease == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (workOrders, comments) = await ReconcileAsync(
|
||||||
|
lease,
|
||||||
|
options,
|
||||||
|
leaseDuration,
|
||||||
|
cancellationToken);
|
||||||
|
await _jobs.CompleteAsync(
|
||||||
|
lease.RunId,
|
||||||
|
lease.FenceToken,
|
||||||
|
_timeProvider.GetUtcNow(),
|
||||||
|
workOrders,
|
||||||
|
comments,
|
||||||
|
cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Procurement work-order reconciliation run {RunId} failed.",
|
||||||
|
lease.RunId);
|
||||||
|
await _jobs.FailAsync(
|
||||||
|
lease.RunId,
|
||||||
|
lease.FenceToken,
|
||||||
|
_timeProvider.GetUtcNow(),
|
||||||
|
"reconciliation_failed",
|
||||||
|
cancellationToken);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(int WorkOrders, int Comments)> ReconcileAsync(
|
||||||
|
ReconciliationLease lease,
|
||||||
|
WorkOrderReconciliationOptions options,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cursor = (string?)null;
|
||||||
|
var seenCursors = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var workOrderCount = 0;
|
||||||
|
var commentCount = 0;
|
||||||
|
|
||||||
|
for (var pageNumber = 0; pageNumber < options.MaxPages; pageNumber++)
|
||||||
|
{
|
||||||
|
var page = await _client.GetWorkOrdersAsync(cursor, options.PageSize, cancellationToken);
|
||||||
|
foreach (var item in page.Items)
|
||||||
|
{
|
||||||
|
await _workOrders.ApplyAsync(Map(item), cancellationToken);
|
||||||
|
workOrderCount++;
|
||||||
|
commentCount += await ReconcileCommentsAsync(
|
||||||
|
item.WorkOrderId,
|
||||||
|
lease,
|
||||||
|
options,
|
||||||
|
leaseDuration,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
|
||||||
|
cursor = ValidateNextCursor(page.NextCursor, options.MaxCursorLength, seenCursors);
|
||||||
|
if (cursor == null)
|
||||||
|
return (workOrderCount, commentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Procurement work-order page limit exceeded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ReconcileCommentsAsync(
|
||||||
|
string workOrderId,
|
||||||
|
ReconciliationLease lease,
|
||||||
|
WorkOrderReconciliationOptions options,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? cursor = null;
|
||||||
|
var seenCursors = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var count = 0;
|
||||||
|
for (var pageNumber = 0; pageNumber < options.MaxPages; pageNumber++)
|
||||||
|
{
|
||||||
|
var page = await _client.GetCommentsAsync(
|
||||||
|
workOrderId,
|
||||||
|
cursor,
|
||||||
|
options.PageSize,
|
||||||
|
cancellationToken);
|
||||||
|
foreach (var comment in page.Items)
|
||||||
|
{
|
||||||
|
await _workOrders.ApplyAsync(Map(comment), cancellationToken);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
|
||||||
|
cursor = ValidateNextCursor(page.NextCursor, options.MaxCursorLength, seenCursors);
|
||||||
|
if (cursor == null)
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Procurement comment page limit exceeded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureLeaseAsync(
|
||||||
|
ReconciliationLease lease,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await _jobs.RenewLeaseAsync(
|
||||||
|
lease.RunId,
|
||||||
|
lease.FenceToken,
|
||||||
|
_timeProvider.GetUtcNow(),
|
||||||
|
leaseDuration,
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Reconciliation lease was lost.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkOrderWebhookMutation Map(ProcurementWorkOrder item)
|
||||||
|
{
|
||||||
|
var updatedAt = item.UpdatedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
|
||||||
|
var hash = WorkOrderExternalVersion.Compute(
|
||||||
|
item.WorkOrderId,
|
||||||
|
item.WoStatus,
|
||||||
|
item.Description,
|
||||||
|
item.Customer,
|
||||||
|
item.SiteCode,
|
||||||
|
item.Building,
|
||||||
|
item.Address,
|
||||||
|
item.Severity,
|
||||||
|
item.Priority,
|
||||||
|
item.AssignedTo,
|
||||||
|
Format(item.DateReported),
|
||||||
|
Format(item.ScheduledStart),
|
||||||
|
Format(item.DueDate),
|
||||||
|
item.RecordType,
|
||||||
|
Format(item.CreatedAt),
|
||||||
|
item.SourceEmailS3Key,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
return new WorkOrderWebhookMutation
|
||||||
|
{
|
||||||
|
DeliveryId = ReceiptId("work-order", item.WorkOrderId, updatedAt, hash),
|
||||||
|
EventType = "reconciliation.work_order",
|
||||||
|
OccurredAt = item.UpdatedAt ?? item.CreatedAt ?? updatedAt,
|
||||||
|
UpdatedAt = updatedAt,
|
||||||
|
ProcessedAt = _timeProvider.GetUtcNow(),
|
||||||
|
BodySha256 = hash,
|
||||||
|
VersionHash = hash,
|
||||||
|
ExternalWorkOrderId = item.WorkOrderId,
|
||||||
|
WorkerOrderNumber = item.WorkOrderId,
|
||||||
|
Source = Source,
|
||||||
|
IsStateEvent = true,
|
||||||
|
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
|
||||||
|
Description = item.Description,
|
||||||
|
Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus),
|
||||||
|
Severity = item.Severity,
|
||||||
|
Priority = item.Priority ?? WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
|
||||||
|
AssignedTo = item.AssignedTo,
|
||||||
|
RecordType = item.RecordType,
|
||||||
|
SourceEmailS3Key = item.SourceEmailS3Key,
|
||||||
|
Customer = item.Customer,
|
||||||
|
SiteCode = item.SiteCode,
|
||||||
|
Building = item.Building,
|
||||||
|
Address = item.Address,
|
||||||
|
DueDate = item.DueDate?.UtcDateTime,
|
||||||
|
DateReported = item.DateReported?.UtcDateTime,
|
||||||
|
ScheduledStart = item.ScheduledStart?.UtcDateTime,
|
||||||
|
CreatedAt = item.CreatedAt?.UtcDateTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkOrderWebhookMutation Map(ProcurementWorkOrderComment item)
|
||||||
|
{
|
||||||
|
var updatedAt = item.IngestedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
|
||||||
|
var hash = WorkOrderExternalVersion.Compute(
|
||||||
|
item.WorkOrderId,
|
||||||
|
item.CommentId,
|
||||||
|
item.RecordType,
|
||||||
|
item.Commenter,
|
||||||
|
item.Text,
|
||||||
|
Format(item.CreatedAt),
|
||||||
|
Format(item.IngestedAt),
|
||||||
|
item.SourceEmailS3Key);
|
||||||
|
return new WorkOrderWebhookMutation
|
||||||
|
{
|
||||||
|
DeliveryId = ReceiptId("comment", item.CommentId, updatedAt, hash),
|
||||||
|
EventType = "reconciliation.comment",
|
||||||
|
OccurredAt = item.CreatedAt ?? updatedAt,
|
||||||
|
UpdatedAt = updatedAt,
|
||||||
|
ProcessedAt = _timeProvider.GetUtcNow(),
|
||||||
|
BodySha256 = hash,
|
||||||
|
VersionHash = hash,
|
||||||
|
ExternalWorkOrderId = item.WorkOrderId,
|
||||||
|
WorkerOrderNumber = item.WorkOrderId,
|
||||||
|
Source = Source,
|
||||||
|
IsStateEvent = false,
|
||||||
|
CommentId = item.CommentId,
|
||||||
|
CommentText = item.Text,
|
||||||
|
Commenter = item.Commenter,
|
||||||
|
CommentType = item.RecordType,
|
||||||
|
SourceEmailS3Key = item.SourceEmailS3Key
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateNextCursor(
|
||||||
|
string? cursor,
|
||||||
|
int maxLength,
|
||||||
|
HashSet<string> seen)
|
||||||
|
{
|
||||||
|
if (cursor == null)
|
||||||
|
return null;
|
||||||
|
if (cursor.Length == 0 || cursor.Length > maxLength || !seen.Add(cursor))
|
||||||
|
throw new InvalidOperationException("Procurement API returned an invalid cursor.");
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReceiptId(
|
||||||
|
string kind,
|
||||||
|
string externalId,
|
||||||
|
DateTimeOffset updatedAt,
|
||||||
|
string hash)
|
||||||
|
{
|
||||||
|
var raw = $"{kind}:{externalId}:{updatedAt.ToUniversalTime():O}:{hash}";
|
||||||
|
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
return $"reconcile:{digest}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Format(DateTimeOffset? value) =>
|
||||||
|
value?.ToUniversalTime().ToString("O");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -245,8 +245,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
|| string.IsNullOrWhiteSpace(envelope.EventType)
|
|| string.IsNullOrWhiteSpace(envelope.EventType)
|
||||||
|| envelope.EventType.Length > 64
|
|| envelope.EventType.Length > 64
|
||||||
|| envelope.OccurredAt == null
|
|| envelope.OccurredAt == null
|
||||||
|| string.IsNullOrWhiteSpace(envelope.Source)
|
|| !string.Equals(envelope.Source, "procurement", StringComparison.Ordinal)
|
||||||
|| envelope.Source.Length > 128
|
|
||||||
|| envelope.Replay == null
|
|| envelope.Replay == null
|
||||||
|| envelope.Data == null
|
|| envelope.Data == null
|
||||||
|| string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId)
|
|| string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId)
|
||||||
|
|
@ -261,6 +260,34 @@ namespace SeaHaven.Services.Implementation
|
||||||
|| string.IsNullOrWhiteSpace(envelope.Data.Text)))
|
|| string.IsNullOrWhiteSpace(envelope.Data.Text)))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
var updatedAt = envelope.Data.UpdatedAt
|
||||||
|
?? envelope.Data.IngestedAt
|
||||||
|
?? envelope.OccurredAt;
|
||||||
|
if (updatedAt == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant();
|
||||||
|
var versionHash = WorkOrderExternalVersion.Compute(
|
||||||
|
envelope.Data.WorkOrderId,
|
||||||
|
envelope.Data.WoStatus ?? envelope.Data.Status,
|
||||||
|
envelope.Data.Description,
|
||||||
|
envelope.Data.Customer,
|
||||||
|
envelope.Data.SiteCode,
|
||||||
|
envelope.Data.Building,
|
||||||
|
envelope.Data.Address,
|
||||||
|
envelope.Data.Severity,
|
||||||
|
envelope.Data.Priority,
|
||||||
|
envelope.Data.AssignedTo,
|
||||||
|
envelope.Data.DateReported?.ToUniversalTime().ToString("O"),
|
||||||
|
envelope.Data.ScheduledStart?.ToUniversalTime().ToString("O"),
|
||||||
|
envelope.Data.DueDate?.ToUniversalTime().ToString("O"),
|
||||||
|
envelope.Data.RecordType,
|
||||||
|
envelope.Data.CreatedAt?.ToUniversalTime().ToString("O"),
|
||||||
|
envelope.Data.SourceEmailS3Key,
|
||||||
|
envelope.Data.CommentId,
|
||||||
|
envelope.Data.Commenter,
|
||||||
|
envelope.Data.Text,
|
||||||
|
envelope.Data.IngestedAt?.ToUniversalTime().ToString("O"));
|
||||||
var statusSource = envelope.Data.WoStatus ?? envelope.Data.Status;
|
var statusSource = envelope.Data.WoStatus ?? envelope.Data.Status;
|
||||||
mutation = new WorkOrderWebhookMutation
|
mutation = new WorkOrderWebhookMutation
|
||||||
{
|
{
|
||||||
|
|
@ -268,17 +295,23 @@ namespace SeaHaven.Services.Implementation
|
||||||
EventType = envelope.EventType,
|
EventType = envelope.EventType,
|
||||||
OccurredAt = envelope.OccurredAt.Value,
|
OccurredAt = envelope.OccurredAt.Value,
|
||||||
ProcessedAt = _timeProvider.GetUtcNow(),
|
ProcessedAt = _timeProvider.GetUtcNow(),
|
||||||
BodySha256 = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant(),
|
BodySha256 = bodyHash,
|
||||||
ExternalWorkOrderId = envelope.Data.WorkOrderId,
|
ExternalWorkOrderId = envelope.Data.WorkOrderId,
|
||||||
WorkerOrderNumber = envelope.Data.WorkOrderId,
|
WorkerOrderNumber = envelope.Data.WorkOrderId,
|
||||||
Source = envelope.Source,
|
Source = envelope.Source!,
|
||||||
|
UpdatedAt = updatedAt.Value,
|
||||||
|
VersionHash = versionHash,
|
||||||
IsStateEvent = !isComment,
|
IsStateEvent = !isComment,
|
||||||
IsCancelled = envelope.EventType == "work_order.cancelled",
|
IsCancelled = envelope.EventType == "work_order.cancelled",
|
||||||
Title = envelope.Data.Title,
|
Title = envelope.Data.Title,
|
||||||
Description = envelope.Data.Description,
|
Description = envelope.Data.Description,
|
||||||
Status = WorkOrderIngestFieldMapper.MapStatus(statusSource),
|
Status = WorkOrderIngestFieldMapper.MapStatus(statusSource),
|
||||||
Severity = envelope.Data.Severity,
|
Severity = envelope.Data.Severity,
|
||||||
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity),
|
Priority = envelope.Data.Priority
|
||||||
|
?? WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity),
|
||||||
|
AssignedTo = envelope.Data.AssignedTo,
|
||||||
|
RecordType = envelope.Data.RecordType,
|
||||||
|
SourceEmailS3Key = envelope.Data.SourceEmailS3Key,
|
||||||
Customer = envelope.Data.Customer,
|
Customer = envelope.Data.Customer,
|
||||||
SiteCode = envelope.Data.SiteCode,
|
SiteCode = envelope.Data.SiteCode,
|
||||||
Building = envelope.Data.Building,
|
Building = envelope.Data.Building,
|
||||||
|
|
@ -338,7 +371,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public string? Severity { get; set; }
|
public string? Severity { get; set; }
|
||||||
|
public string? Priority { get; set; }
|
||||||
public string? Customer { get; set; }
|
public string? Customer { get; set; }
|
||||||
|
[JsonPropertyName("assigned_to")]
|
||||||
|
public string? AssignedTo { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("site_code")]
|
[JsonPropertyName("site_code")]
|
||||||
public string? SiteCode { get; set; }
|
public string? SiteCode { get; set; }
|
||||||
|
|
@ -358,6 +394,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
[JsonPropertyName("created_at")]
|
[JsonPropertyName("created_at")]
|
||||||
public DateTimeOffset? CreatedAt { get; set; }
|
public DateTimeOffset? CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("updated_at")]
|
||||||
|
public DateTimeOffset? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ingested_at")]
|
||||||
|
public DateTimeOffset? IngestedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("record_type")]
|
||||||
|
public string? RecordType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("source_email_s3_key")]
|
||||||
|
public string? SourceEmailS3Key { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("comment_id")]
|
[JsonPropertyName("comment_id")]
|
||||||
public string? CommentId { get; set; }
|
public string? CommentId { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
53
SeaHaven.Services/Interfaces/IProcurementWorkOrderClient.cs
Normal file
53
SeaHaven.Services/Interfaces/IProcurementWorkOrderClient.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IProcurementWorkOrderClient
|
||||||
|
{
|
||||||
|
Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
|
||||||
|
string? cursor,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
|
||||||
|
string workOrderId,
|
||||||
|
string? cursor,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProcurementPage<T>(
|
||||||
|
IReadOnlyList<T> Items,
|
||||||
|
string? NextCursor);
|
||||||
|
|
||||||
|
public sealed record ProcurementWorkOrder
|
||||||
|
{
|
||||||
|
public required string WorkOrderId { get; init; }
|
||||||
|
public string? WoStatus { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
public string? Customer { get; init; }
|
||||||
|
public string? SiteCode { get; init; }
|
||||||
|
public string? Building { get; init; }
|
||||||
|
public string? Address { get; init; }
|
||||||
|
public string? Severity { get; init; }
|
||||||
|
public string? Priority { get; init; }
|
||||||
|
public string? AssignedTo { get; init; }
|
||||||
|
public DateTimeOffset? DateReported { get; init; }
|
||||||
|
public DateTimeOffset? ScheduledStart { get; init; }
|
||||||
|
public DateTimeOffset? DueDate { get; init; }
|
||||||
|
public string? RecordType { get; init; }
|
||||||
|
public DateTimeOffset? CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? UpdatedAt { get; init; }
|
||||||
|
public string? SourceEmailS3Key { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProcurementWorkOrderComment
|
||||||
|
{
|
||||||
|
public required string WorkOrderId { get; init; }
|
||||||
|
public required string CommentId { get; init; }
|
||||||
|
public string? RecordType { get; init; }
|
||||||
|
public string? Commenter { get; init; }
|
||||||
|
public string? Text { get; init; }
|
||||||
|
public DateTimeOffset? CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? IngestedAt { get; init; }
|
||||||
|
public string? SourceEmailS3Key { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderReconciliationService
|
||||||
|
{
|
||||||
|
Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<WorkOrderReconciliationStatus> GetStatusAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<bool> RunPendingAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record WorkOrderReconciliationTriggerResult(bool Queued, Guid RunId);
|
||||||
|
|
||||||
|
public sealed record WorkOrderReconciliationStatus(
|
||||||
|
Guid? RunId,
|
||||||
|
string State,
|
||||||
|
string? Reason,
|
||||||
|
DateTimeOffset? RequestedAt,
|
||||||
|
DateTimeOffset? StartedAt,
|
||||||
|
DateTimeOffset? CompletedAt,
|
||||||
|
int WorkOrdersProcessed,
|
||||||
|
int CommentsProcessed,
|
||||||
|
string? ErrorCode);
|
||||||
|
}
|
||||||
362
SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs
Normal file
362
SeaHavenIndustries.Tests/WorkOrderReconciliationTests.cs
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class WorkOrderReconciliationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Full_scan_paginates_work_orders_and_each_comment_collection()
|
||||||
|
{
|
||||||
|
var client = new MockClient(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ProcurementPage<ProcurementWorkOrder>(
|
||||||
|
new[] { WorkOrder("2", "new") },
|
||||||
|
"next"),
|
||||||
|
new ProcurementPage<ProcurementWorkOrder>(
|
||||||
|
new[] { WorkOrder("1", "cancelled") },
|
||||||
|
null)
|
||||||
|
},
|
||||||
|
new Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>>
|
||||||
|
{
|
||||||
|
["2"] = new Queue<ProcurementPage<ProcurementWorkOrderComment>>(new[]
|
||||||
|
{
|
||||||
|
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||||
|
new[] { Comment("2", "c2") },
|
||||||
|
"comments-next"),
|
||||||
|
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||||
|
Array.Empty<ProcurementWorkOrderComment>(),
|
||||||
|
null)
|
||||||
|
}),
|
||||||
|
["1"] = new Queue<ProcurementPage<ProcurementWorkOrderComment>>(new[]
|
||||||
|
{
|
||||||
|
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||||
|
new[] { Comment("1", "c1") },
|
||||||
|
null)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
var mutations = new RecordingWorkOrders();
|
||||||
|
var jobs = new RecordingJobs();
|
||||||
|
var service = Create(client, mutations, jobs);
|
||||||
|
|
||||||
|
Assert.True(await service.RunPendingAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(4, mutations.Items.Count);
|
||||||
|
Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1" && m.IsCancelled);
|
||||||
|
Assert.Contains(mutations.Items, m => m.CommentId == "c2");
|
||||||
|
Assert.Equal(2, jobs.CompletedWorkOrders);
|
||||||
|
Assert.Equal(2, jobs.CompletedComments);
|
||||||
|
Assert.True(jobs.RenewCount >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repeated_cursor_fails_bounded_run_instead_of_looping()
|
||||||
|
{
|
||||||
|
var page = new ProcurementPage<ProcurementWorkOrder>(
|
||||||
|
Array.Empty<ProcurementWorkOrder>(),
|
||||||
|
"same");
|
||||||
|
var service = Create(
|
||||||
|
new MockClient(new[] { page, page }, new()),
|
||||||
|
new RecordingWorkOrders(),
|
||||||
|
new RecordingJobs());
|
||||||
|
|
||||||
|
Assert.False(await service.RunPendingAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Legacy_records_without_timestamps_use_stable_epoch_freshness()
|
||||||
|
{
|
||||||
|
var client = new MockClient(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ProcurementPage<ProcurementWorkOrder>(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ProcurementWorkOrder
|
||||||
|
{
|
||||||
|
WorkOrderId = "42",
|
||||||
|
Description = "Legacy mock"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null)
|
||||||
|
},
|
||||||
|
new Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>>
|
||||||
|
{
|
||||||
|
["42"] = new Queue<ProcurementPage<ProcurementWorkOrderComment>>(new[]
|
||||||
|
{
|
||||||
|
new ProcurementPage<ProcurementWorkOrderComment>(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ProcurementWorkOrderComment
|
||||||
|
{
|
||||||
|
WorkOrderId = "42",
|
||||||
|
CommentId = "legacy-comment",
|
||||||
|
Text = "Legacy comment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
var mutations = new RecordingWorkOrders();
|
||||||
|
var service = Create(client, mutations, new RecordingJobs());
|
||||||
|
|
||||||
|
Assert.True(await service.RunPendingAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(2, mutations.Items.Count);
|
||||||
|
Assert.All(mutations.Items, mutation =>
|
||||||
|
Assert.Equal(DateTimeOffset.UnixEpoch, mutation.UpdatedAt));
|
||||||
|
Assert.All(mutations.Items, mutation =>
|
||||||
|
Assert.StartsWith("reconcile:", mutation.DeliveryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Durable_job_survives_scope_restart_and_rejects_stale_fence()
|
||||||
|
{
|
||||||
|
var database = $"reconciliation-{Guid.NewGuid()}";
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(database)
|
||||||
|
.Options;
|
||||||
|
Guid runId;
|
||||||
|
Guid fence;
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
var idProperty = context.Model
|
||||||
|
.FindEntityType(typeof(WorkOrderReconciliationJob))!
|
||||||
|
.FindProperty(nameof(WorkOrderReconciliationJob.Id))!;
|
||||||
|
Assert.Equal(
|
||||||
|
Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never,
|
||||||
|
idProperty.ValueGenerated);
|
||||||
|
var data = new WorkOrderReconciliationDataService(context);
|
||||||
|
var queued = await data.EnqueueAsync(
|
||||||
|
"admin",
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
CancellationToken.None);
|
||||||
|
runId = queued.RunId!.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var context = new ApplicationDbContext(options))
|
||||||
|
{
|
||||||
|
var data = new WorkOrderReconciliationDataService(context);
|
||||||
|
var lease = await data.TryAcquirePendingAsync(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
TimeSpan.FromMinutes(2),
|
||||||
|
CancellationToken.None);
|
||||||
|
fence = lease!.FenceToken;
|
||||||
|
Assert.Equal(runId, lease.RunId);
|
||||||
|
await data.CompleteAsync(
|
||||||
|
runId,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
99,
|
||||||
|
99,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.Equal("Running", (await data.GetStatusAsync(CancellationToken.None)).State);
|
||||||
|
await data.CompleteAsync(
|
||||||
|
runId,
|
||||||
|
fence,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
CancellationToken.None);
|
||||||
|
var status = await data.GetStatusAsync(CancellationToken.None);
|
||||||
|
Assert.Equal("Succeeded", status.State);
|
||||||
|
Assert.Equal(2, status.WorkOrdersProcessed);
|
||||||
|
Assert.Equal(3, status.CommentsProcessed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Equal_timestamp_converges_to_lexicographically_greater_version()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase($"convergence-{Guid.NewGuid()}")
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
var data = new WorkOrderWebhookDataService(context);
|
||||||
|
var time = DateTimeOffset.Parse("2026-07-24T12:00:00Z");
|
||||||
|
|
||||||
|
await data.ApplyAsync(Mutation("d1", "aaa", "older-tie", time), CancellationToken.None);
|
||||||
|
await data.ApplyAsync(Mutation("d2", "zzz", "winner", time), CancellationToken.None);
|
||||||
|
await data.ApplyAsync(Mutation("d3", "aaa", "loser-replay", time), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("winner", (await context.workOrders.SingleAsync()).WorkerOrderTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderReconciliationService Create(
|
||||||
|
IProcurementWorkOrderClient client,
|
||||||
|
IWorkOrderWebhookDataService workOrders,
|
||||||
|
IWorkOrderReconciliationDataService jobs) =>
|
||||||
|
new(
|
||||||
|
client,
|
||||||
|
workOrders,
|
||||||
|
jobs,
|
||||||
|
new TestOptions(new WorkOrderReconciliationOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
MaxPages = 10,
|
||||||
|
PageSize = 100,
|
||||||
|
LeaseSeconds = 120
|
||||||
|
}),
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<WorkOrderReconciliationService>.Instance);
|
||||||
|
|
||||||
|
private static ProcurementWorkOrder WorkOrder(string id, string status) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WorkOrderId = id,
|
||||||
|
WoStatus = status,
|
||||||
|
Description = $"Mock {id}",
|
||||||
|
UpdatedAt = DateTimeOffset.Parse("2026-07-24T12:00:00Z")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ProcurementWorkOrderComment Comment(string workOrderId, string commentId) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
CommentId = commentId,
|
||||||
|
Text = "Mock comment",
|
||||||
|
IngestedAt = DateTimeOffset.Parse("2026-07-24T12:01:00Z")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WorkOrderWebhookMutation Mutation(
|
||||||
|
string delivery,
|
||||||
|
string versionHash,
|
||||||
|
string title,
|
||||||
|
DateTimeOffset updatedAt) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DeliveryId = delivery,
|
||||||
|
EventType = "work_order.updated",
|
||||||
|
OccurredAt = updatedAt,
|
||||||
|
UpdatedAt = updatedAt,
|
||||||
|
ProcessedAt = updatedAt,
|
||||||
|
BodySha256 = delivery,
|
||||||
|
VersionHash = versionHash,
|
||||||
|
ExternalWorkOrderId = "123",
|
||||||
|
WorkerOrderNumber = "123",
|
||||||
|
Source = "procurement",
|
||||||
|
IsStateEvent = true,
|
||||||
|
Title = title
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class MockClient : IProcurementWorkOrderClient
|
||||||
|
{
|
||||||
|
private readonly Queue<ProcurementPage<ProcurementWorkOrder>> _workOrders;
|
||||||
|
private readonly Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>> _comments;
|
||||||
|
|
||||||
|
public MockClient(
|
||||||
|
IEnumerable<ProcurementPage<ProcurementWorkOrder>> workOrders,
|
||||||
|
Dictionary<string, Queue<ProcurementPage<ProcurementWorkOrderComment>>> comments)
|
||||||
|
{
|
||||||
|
_workOrders = new Queue<ProcurementPage<ProcurementWorkOrder>>(workOrders);
|
||||||
|
_comments = comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
|
||||||
|
string? cursor,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(_workOrders.Dequeue());
|
||||||
|
|
||||||
|
public Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
|
||||||
|
string workOrderId,
|
||||||
|
string? cursor,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(_comments.TryGetValue(workOrderId, out var pages)
|
||||||
|
? pages.Dequeue()
|
||||||
|
: new ProcurementPage<ProcurementWorkOrderComment>(
|
||||||
|
Array.Empty<ProcurementWorkOrderComment>(),
|
||||||
|
null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingWorkOrders : IWorkOrderWebhookDataService
|
||||||
|
{
|
||||||
|
public List<WorkOrderWebhookMutation> Items { get; } = new();
|
||||||
|
|
||||||
|
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
|
||||||
|
WorkOrderWebhookMutation mutation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Items.Add(mutation);
|
||||||
|
return Task.FromResult(new WorkOrderWebhookPersistenceResult(
|
||||||
|
WorkOrderWebhookPersistenceStatus.Applied));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingJobs : IWorkOrderReconciliationDataService
|
||||||
|
{
|
||||||
|
private readonly Guid _run = Guid.NewGuid();
|
||||||
|
private readonly Guid _fence = Guid.NewGuid();
|
||||||
|
public int CompletedWorkOrders { get; private set; }
|
||||||
|
public int CompletedComments { get; private set; }
|
||||||
|
public int RenewCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<ReconciliationJobSnapshot> EnqueueAsync(
|
||||||
|
string reason,
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(Snapshot("Pending"));
|
||||||
|
|
||||||
|
public Task<ReconciliationLease?> TryAcquirePendingAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult<ReconciliationLease?>(new(_run, _fence));
|
||||||
|
|
||||||
|
public Task<ReconciliationJobSnapshot> GetStatusAsync(CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(Snapshot("Pending"));
|
||||||
|
|
||||||
|
public Task<bool> RenewLeaseAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset now,
|
||||||
|
TimeSpan leaseDuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
RenewCount++;
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset completedAt,
|
||||||
|
int workOrders,
|
||||||
|
int comments,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CompletedWorkOrders = workOrders;
|
||||||
|
CompletedComments = comments;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task FailAsync(
|
||||||
|
Guid runId,
|
||||||
|
Guid fenceToken,
|
||||||
|
DateTimeOffset completedAt,
|
||||||
|
string errorCode,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
private ReconciliationJobSnapshot Snapshot(string state) =>
|
||||||
|
new(_run, state, "test", null, null, null, 0, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestOptions : IOptionsMonitor<WorkOrderReconciliationOptions>
|
||||||
|
{
|
||||||
|
public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value;
|
||||||
|
public WorkOrderReconciliationOptions CurrentValue { get; }
|
||||||
|
public WorkOrderReconciliationOptions Get(string? name) => CurrentValue;
|
||||||
|
public IDisposable? OnChange(
|
||||||
|
Action<WorkOrderReconciliationOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -302,8 +302,10 @@ public sealed class WorkOrderWebhookDataServiceTests
|
||||||
DeliveryId = deliveryId,
|
DeliveryId = deliveryId,
|
||||||
EventType = eventType,
|
EventType = eventType,
|
||||||
OccurredAt = occurredAt,
|
OccurredAt = occurredAt,
|
||||||
|
UpdatedAt = occurredAt,
|
||||||
ProcessedAt = occurredAt.AddMinutes(1),
|
ProcessedAt = occurredAt.AddMinutes(1),
|
||||||
BodySha256 = bodyHash,
|
BodySha256 = bodyHash,
|
||||||
|
VersionHash = bodyHash,
|
||||||
ExternalWorkOrderId = "123",
|
ExternalWorkOrderId = "123",
|
||||||
WorkerOrderNumber = "00000000123",
|
WorkerOrderNumber = "00000000123",
|
||||||
Source = "procurement",
|
Source = "procurement",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue