mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
fix(work-orders): address procurement review findings
This commit is contained in:
parent
8a2e260177
commit
27bf81b7f8
14 changed files with 367 additions and 34 deletions
|
|
@ -1,6 +1,11 @@
|
||||||
using Api.SeaHavenIndustries.Controllers;
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ActionConstraints;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -37,6 +42,41 @@ public sealed class WorkOrderReconciliationControllerTests
|
||||||
Assert.DoesNotContain("exception", result.Value!.ToString(), StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("exception", result.Value!.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Framework_action_descriptors_expose_only_admin_get_and_post_reconciliation_routes()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddMvcCore()
|
||||||
|
.AddApplicationPart(typeof(WorkOrderReconciliationController).Assembly);
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var descriptors = provider
|
||||||
|
.GetRequiredService<IActionDescriptorCollectionProvider>()
|
||||||
|
.ActionDescriptors
|
||||||
|
.Items
|
||||||
|
.OfType<ControllerActionDescriptor>()
|
||||||
|
.Where(d => d.ControllerTypeInfo == typeof(WorkOrderReconciliationController))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var routes = descriptors
|
||||||
|
.SelectMany(d => (d.ActionConstraints ?? Array.Empty<IActionConstraintMetadata>())
|
||||||
|
.OfType<HttpMethodActionConstraint>()
|
||||||
|
.SelectMany(c => c.HttpMethods)
|
||||||
|
.Select(method => $"{method} {d.AttributeRouteInfo!.Template}"))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
Assert.True(routes.SetEquals(new[]
|
||||||
|
{
|
||||||
|
"GET api/admin/work-order-reconciliation",
|
||||||
|
"POST api/admin/work-order-reconciliation"
|
||||||
|
}));
|
||||||
|
Assert.All(descriptors, descriptor =>
|
||||||
|
Assert.Contains(
|
||||||
|
descriptor.ControllerTypeInfo.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||||
|
.OfType<AuthorizeAttribute>(),
|
||||||
|
attribute => attribute.Roles == "Admin"));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class StubService : IWorkOrderReconciliationService
|
private sealed class StubService : IWorkOrderReconciliationService
|
||||||
{
|
{
|
||||||
private readonly Guid _runId;
|
private readonly Guid _runId;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
{
|
{
|
||||||
var nextScheduledAt = _timeProvider.GetUtcNow();
|
var nextScheduledAt = _timeProvider.GetUtcNow();
|
||||||
if (_options.CurrentValue.Enabled && _options.CurrentValue.RunOnStartup)
|
if (_options.CurrentValue.Enabled && _options.CurrentValue.RunOnStartup)
|
||||||
|
{
|
||||||
await TriggerAsync("startup", stoppingToken);
|
await TriggerAsync("startup", stoppingToken);
|
||||||
|
nextScheduledAt = _timeProvider.GetUtcNow().AddMinutes(_options.CurrentValue.ScheduleMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
"MaxResponseBytes": 4194304,
|
"MaxResponseBytes": 4194304,
|
||||||
"PollSeconds": 10,
|
"PollSeconds": 10,
|
||||||
"ScheduleMinutes": 60,
|
"ScheduleMinutes": 60,
|
||||||
"LeaseSeconds": 120
|
"LeaseSeconds": 180
|
||||||
},
|
},
|
||||||
"Sync": {
|
"Sync": {
|
||||||
"Enabled": true
|
"Enabled": true
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,8 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
|
workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description;
|
||||||
workOrder.Description = mutation.Description;
|
workOrder.Description = mutation.Description;
|
||||||
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status;
|
||||||
|
if (mutation.LifecycleStatus.HasValue)
|
||||||
|
workOrder.LifecycleStatus = mutation.LifecycleStatus.Value;
|
||||||
workOrder.Severity = mutation.Severity;
|
workOrder.Severity = mutation.Severity;
|
||||||
workOrder.Priority = mutation.Priority;
|
workOrder.Priority = mutation.Priority;
|
||||||
workOrder.ExternalAssignedTo = mutation.AssignedTo;
|
workOrder.ExternalAssignedTo = mutation.AssignedTo;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Interfaces
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
public interface IWorkOrderWebhookDataService
|
public interface IWorkOrderWebhookDataService
|
||||||
|
|
@ -24,6 +26,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
public string? Title { get; init; }
|
public string? Title { get; init; }
|
||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
public string? Status { get; init; }
|
public string? Status { get; init; }
|
||||||
|
public LifecycleStatus? LifecycleStatus { 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? AssignedTo { get; init; }
|
||||||
|
|
|
||||||
46
SeaHaven.Services.Tests/WorkOrderExternalVersionTests.cs
Normal file
46
SeaHaven.Services.Tests/WorkOrderExternalVersionTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Tests;
|
||||||
|
|
||||||
|
public sealed class WorkOrderExternalVersionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Work_order_inputs_from_webhook_and_reconciliation_have_the_same_canonical_hash()
|
||||||
|
{
|
||||||
|
var timestamp = DateTimeOffset.Parse("2026-07-24T12:00:00+02:00");
|
||||||
|
var webhook = new WorkOrderExternalVersion.WorkOrder(
|
||||||
|
"42", "new", "Leaking pipe", "Acme", "S1", "A", "1 Main St", "2", "High",
|
||||||
|
"Alex", timestamp, timestamp.AddHours(1), timestamp.AddDays(1), "update", timestamp,
|
||||||
|
"emails/42.eml");
|
||||||
|
var reconciliation = webhook with { DateReported = timestamp.ToUniversalTime() };
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
WorkOrderExternalVersion.ComputeWorkOrder(webhook),
|
||||||
|
WorkOrderExternalVersion.ComputeWorkOrder(reconciliation));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Comment_inputs_from_webhook_and_reconciliation_have_the_same_20_slot_canonical_hash()
|
||||||
|
{
|
||||||
|
var createdAt = DateTimeOffset.Parse("2026-07-24T11:59:00+02:00");
|
||||||
|
var ingestedAt = DateTimeOffset.Parse("2026-07-24T12:01:00+02:00");
|
||||||
|
var webhook = new WorkOrderExternalVersion.Comment(
|
||||||
|
"42", "comment-7", "comment", "Alex", "A note", createdAt, ingestedAt, "emails/42.eml");
|
||||||
|
var reconciliation = webhook with
|
||||||
|
{
|
||||||
|
CreatedAt = createdAt.ToUniversalTime(),
|
||||||
|
IngestedAt = ingestedAt.ToUniversalTime()
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
WorkOrderExternalVersion.ComputeComment(webhook),
|
||||||
|
WorkOrderExternalVersion.ComputeComment(reconciliation));
|
||||||
|
Assert.Equal(
|
||||||
|
WorkOrderExternalVersion.Compute(
|
||||||
|
"42", null, null, null, null, null, null, null, null, null,
|
||||||
|
null, null, null, "comment", "2026-07-24T09:59:00.0000000+00:00", "emails/42.eml", "comment-7", "Alex",
|
||||||
|
"A note", "2026-07-24T10:01:00.0000000+00:00"),
|
||||||
|
WorkOrderExternalVersion.ComputeComment(webhook));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Tests;
|
||||||
|
|
||||||
|
public sealed class WorkOrderReconciliationOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Enabled_reconciliation_rejects_a_lease_shorter_than_worst_case_request_budget()
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["WorkOrderReconciliation:Enabled"] = "true",
|
||||||
|
["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30",
|
||||||
|
["WorkOrderReconciliation:MaxRetries"] = "3",
|
||||||
|
["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "100",
|
||||||
|
["WorkOrderReconciliation:LeaseSeconds"] = "120"
|
||||||
|
};
|
||||||
|
|
||||||
|
using var provider = BuildProvider(values);
|
||||||
|
|
||||||
|
Assert.Throws<OptionsValidationException>(() => provider
|
||||||
|
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_reconciliation_does_not_require_a_request_budget_lease()
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["WorkOrderReconciliation:Enabled"] = "false",
|
||||||
|
["WorkOrderReconciliation:LeaseSeconds"] = "1"
|
||||||
|
};
|
||||||
|
|
||||||
|
using var provider = BuildProvider(values);
|
||||||
|
|
||||||
|
Assert.False(provider
|
||||||
|
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Enabled_reconciliation_rejects_a_lease_equal_to_the_request_budget()
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["WorkOrderReconciliation:Enabled"] = "true",
|
||||||
|
["WorkOrderReconciliation:RequestTimeoutSeconds"] = "30",
|
||||||
|
["WorkOrderReconciliation:MaxRetries"] = "0",
|
||||||
|
["WorkOrderReconciliation:RetryBaseDelayMilliseconds"] = "0",
|
||||||
|
["WorkOrderReconciliation:LeaseSeconds"] = "30"
|
||||||
|
};
|
||||||
|
|
||||||
|
using var provider = BuildProvider(values);
|
||||||
|
|
||||||
|
Assert.Throws<OptionsValidationException>(() => provider
|
||||||
|
.GetRequiredService<IOptions<WorkOrderReconciliationOptions>>().Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider BuildProvider(IDictionary<string, string?> values)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(values)
|
||||||
|
.Build();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddBusinessServices(configuration);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,6 @@ namespace SeaHaven.Services.Configuration
|
||||||
public int MaxResponseBytes { get; set; } = 4_194_304;
|
public int MaxResponseBytes { get; set; } = 4_194_304;
|
||||||
public int PollSeconds { get; set; } = 10;
|
public int PollSeconds { get; set; } = 10;
|
||||||
public int ScheduleMinutes { get; set; } = 60;
|
public int ScheduleMinutes { get; set; } = 60;
|
||||||
public int LeaseSeconds { get; set; } = 120;
|
public int LeaseSeconds { get; set; } = 180;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,8 @@ namespace SeaHaven.Services.DependencyInjection
|
||||||
&& o.MaxResponseBytes is >= 1 and <= 16_777_216
|
&& o.MaxResponseBytes is >= 1 and <= 16_777_216
|
||||||
&& o.PollSeconds is >= 1 and <= 300
|
&& o.PollSeconds is >= 1 and <= 300
|
||||||
&& o.ScheduleMinutes is >= 1 and <= 10_080
|
&& o.ScheduleMinutes is >= 1 and <= 10_080
|
||||||
&& o.LeaseSeconds is >= 30 and <= 3_600),
|
&& o.LeaseSeconds is >= 30 and <= 3_600
|
||||||
|
&& HasSufficientReconciliationLease(o)),
|
||||||
"WorkOrderReconciliation configuration is invalid.")
|
"WorkOrderReconciliation configuration is invalid.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
|
@ -80,5 +81,25 @@ namespace SeaHaven.Services.DependencyInjection
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasSufficientReconciliationLease(WorkOrderReconciliationOptions options)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attempts = checked((long)options.MaxRetries + 1);
|
||||||
|
var requestBudgetMilliseconds = checked(
|
||||||
|
attempts * options.RequestTimeoutSeconds * 1_000L);
|
||||||
|
var retryDelayMultiplier = checked((1L << options.MaxRetries) - 1L);
|
||||||
|
var retryDelayMilliseconds = checked(
|
||||||
|
retryDelayMultiplier * options.RetryBaseDelayMilliseconds);
|
||||||
|
var worstCaseMilliseconds = checked(
|
||||||
|
requestBudgetMilliseconds + retryDelayMilliseconds);
|
||||||
|
return checked((long)options.LeaseSeconds * 1_000L) > worstCaseMilliseconds;
|
||||||
|
}
|
||||||
|
catch (OverflowException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,50 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
public static class WorkOrderExternalVersion
|
public static class WorkOrderExternalVersion
|
||||||
{
|
{
|
||||||
|
public static string ComputeWorkOrder(WorkOrder value) => Compute(
|
||||||
|
value.WorkOrderId,
|
||||||
|
value.Status,
|
||||||
|
value.Description,
|
||||||
|
value.Customer,
|
||||||
|
value.SiteCode,
|
||||||
|
value.Building,
|
||||||
|
value.Address,
|
||||||
|
value.Severity,
|
||||||
|
value.Priority,
|
||||||
|
value.AssignedTo,
|
||||||
|
Format(value.DateReported),
|
||||||
|
Format(value.ScheduledStart),
|
||||||
|
Format(value.DueDate),
|
||||||
|
value.RecordType,
|
||||||
|
Format(value.CreatedAt),
|
||||||
|
value.SourceEmailS3Key,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
|
public static string ComputeComment(Comment value) => Compute(
|
||||||
|
value.WorkOrderId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
value.RecordType,
|
||||||
|
Format(value.CreatedAt),
|
||||||
|
value.SourceEmailS3Key,
|
||||||
|
value.CommentId,
|
||||||
|
value.Commenter,
|
||||||
|
value.Text,
|
||||||
|
Format(value.IngestedAt));
|
||||||
|
|
||||||
public static string Compute(params string?[] values)
|
public static string Compute(params string?[] values)
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
|
|
@ -18,5 +62,36 @@ namespace SeaHaven.Services.Helpers
|
||||||
SHA256.HashData(Encoding.UTF8.GetBytes(buffer.ToString())))
|
SHA256.HashData(Encoding.UTF8.GetBytes(buffer.ToString())))
|
||||||
.ToLowerInvariant();
|
.ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? Format(DateTimeOffset? value) =>
|
||||||
|
value?.ToUniversalTime().ToString("O");
|
||||||
|
|
||||||
|
public sealed record WorkOrder(
|
||||||
|
string WorkOrderId,
|
||||||
|
string? Status,
|
||||||
|
string? Description,
|
||||||
|
string? Customer,
|
||||||
|
string? SiteCode,
|
||||||
|
string? Building,
|
||||||
|
string? Address,
|
||||||
|
string? Severity,
|
||||||
|
string? Priority,
|
||||||
|
string? AssignedTo,
|
||||||
|
DateTimeOffset? DateReported,
|
||||||
|
DateTimeOffset? ScheduledStart,
|
||||||
|
DateTimeOffset? DueDate,
|
||||||
|
string? RecordType,
|
||||||
|
DateTimeOffset? CreatedAt,
|
||||||
|
string? SourceEmailS3Key);
|
||||||
|
|
||||||
|
public sealed record Comment(
|
||||||
|
string WorkOrderId,
|
||||||
|
string CommentId,
|
||||||
|
string? RecordType,
|
||||||
|
string? Commenter,
|
||||||
|
string? Text,
|
||||||
|
DateTimeOffset? CreatedAt,
|
||||||
|
DateTimeOffset? IngestedAt,
|
||||||
|
string? SourceEmailS3Key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
@ -151,8 +152,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
for (var pageNumber = 0; pageNumber < options.MaxPages; pageNumber++)
|
for (var pageNumber = 0; pageNumber < options.MaxPages; pageNumber++)
|
||||||
{
|
{
|
||||||
var page = await _client.GetWorkOrdersAsync(cursor, options.PageSize, cancellationToken);
|
var page = await _client.GetWorkOrdersAsync(cursor, options.PageSize, cancellationToken);
|
||||||
|
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
|
||||||
foreach (var item in page.Items)
|
foreach (var item in page.Items)
|
||||||
{
|
{
|
||||||
|
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
|
||||||
await _workOrders.ApplyAsync(Map(item), cancellationToken);
|
await _workOrders.ApplyAsync(Map(item), cancellationToken);
|
||||||
workOrderCount++;
|
workOrderCount++;
|
||||||
commentCount += await ReconcileCommentsAsync(
|
commentCount += await ReconcileCommentsAsync(
|
||||||
|
|
@ -189,8 +192,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
cursor,
|
cursor,
|
||||||
options.PageSize,
|
options.PageSize,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
|
||||||
foreach (var comment in page.Items)
|
foreach (var comment in page.Items)
|
||||||
{
|
{
|
||||||
|
await EnsureLeaseAsync(lease, leaseDuration, cancellationToken);
|
||||||
await _workOrders.ApplyAsync(Map(comment), cancellationToken);
|
await _workOrders.ApplyAsync(Map(comment), cancellationToken);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +228,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
private WorkOrderWebhookMutation Map(ProcurementWorkOrder item)
|
private WorkOrderWebhookMutation Map(ProcurementWorkOrder item)
|
||||||
{
|
{
|
||||||
var updatedAt = item.UpdatedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
|
var updatedAt = item.UpdatedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
|
||||||
var hash = WorkOrderExternalVersion.Compute(
|
var hash = WorkOrderExternalVersion.ComputeWorkOrder(new WorkOrderExternalVersion.WorkOrder(
|
||||||
item.WorkOrderId,
|
item.WorkOrderId,
|
||||||
item.WoStatus,
|
item.WoStatus,
|
||||||
item.Description,
|
item.Description,
|
||||||
|
|
@ -234,16 +239,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
item.Severity,
|
item.Severity,
|
||||||
item.Priority,
|
item.Priority,
|
||||||
item.AssignedTo,
|
item.AssignedTo,
|
||||||
Format(item.DateReported),
|
item.DateReported,
|
||||||
Format(item.ScheduledStart),
|
item.ScheduledStart,
|
||||||
Format(item.DueDate),
|
item.DueDate,
|
||||||
item.RecordType,
|
item.RecordType,
|
||||||
Format(item.CreatedAt),
|
item.CreatedAt,
|
||||||
item.SourceEmailS3Key,
|
item.SourceEmailS3Key));
|
||||||
null,
|
var status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
|
||||||
null,
|
var lifecycleStatus = item.WoStatus == "cancelled" || item.RecordType == "cancellation"
|
||||||
null,
|
? LifecycleStatus.Canceled
|
||||||
null);
|
: LifecycleStatusMapper.FromLegacyStatus(status);
|
||||||
return new WorkOrderWebhookMutation
|
return new WorkOrderWebhookMutation
|
||||||
{
|
{
|
||||||
DeliveryId = ReceiptId("work-order", item.WorkOrderId, updatedAt, hash),
|
DeliveryId = ReceiptId("work-order", item.WorkOrderId, updatedAt, hash),
|
||||||
|
|
@ -259,7 +264,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
IsStateEvent = true,
|
IsStateEvent = true,
|
||||||
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
|
IsCancelled = item.WoStatus == "cancelled" || item.RecordType == "cancellation",
|
||||||
Description = item.Description,
|
Description = item.Description,
|
||||||
Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus),
|
Status = status,
|
||||||
|
LifecycleStatus = lifecycleStatus,
|
||||||
Severity = item.Severity,
|
Severity = item.Severity,
|
||||||
Priority = item.Priority ?? WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
|
Priority = item.Priority ?? WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
|
||||||
AssignedTo = item.AssignedTo,
|
AssignedTo = item.AssignedTo,
|
||||||
|
|
@ -279,15 +285,15 @@ namespace SeaHaven.Services.Implementation
|
||||||
private WorkOrderWebhookMutation Map(ProcurementWorkOrderComment item)
|
private WorkOrderWebhookMutation Map(ProcurementWorkOrderComment item)
|
||||||
{
|
{
|
||||||
var updatedAt = item.IngestedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
|
var updatedAt = item.IngestedAt ?? item.CreatedAt ?? DateTimeOffset.UnixEpoch;
|
||||||
var hash = WorkOrderExternalVersion.Compute(
|
var hash = WorkOrderExternalVersion.ComputeComment(new WorkOrderExternalVersion.Comment(
|
||||||
item.WorkOrderId,
|
item.WorkOrderId,
|
||||||
item.CommentId,
|
item.CommentId,
|
||||||
item.RecordType,
|
item.RecordType,
|
||||||
item.Commenter,
|
item.Commenter,
|
||||||
item.Text,
|
item.Text,
|
||||||
Format(item.CreatedAt),
|
item.CreatedAt,
|
||||||
Format(item.IngestedAt),
|
item.IngestedAt,
|
||||||
item.SourceEmailS3Key);
|
item.SourceEmailS3Key));
|
||||||
return new WorkOrderWebhookMutation
|
return new WorkOrderWebhookMutation
|
||||||
{
|
{
|
||||||
DeliveryId = ReceiptId("comment", item.CommentId, updatedAt, hash),
|
DeliveryId = ReceiptId("comment", item.CommentId, updatedAt, hash),
|
||||||
|
|
@ -332,8 +338,5 @@ namespace SeaHaven.Services.Implementation
|
||||||
.ToLowerInvariant();
|
.ToLowerInvariant();
|
||||||
return $"reconcile:{digest}";
|
return $"reconcile:{digest}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? Format(DateTimeOffset? value) =>
|
|
||||||
value?.ToUniversalTime().ToString("O");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
@ -267,7 +268,17 @@ namespace SeaHaven.Services.Implementation
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant();
|
var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant();
|
||||||
var versionHash = WorkOrderExternalVersion.Compute(
|
var versionHash = isComment
|
||||||
|
? WorkOrderExternalVersion.ComputeComment(new WorkOrderExternalVersion.Comment(
|
||||||
|
envelope.Data.WorkOrderId,
|
||||||
|
envelope.Data.CommentId!,
|
||||||
|
envelope.Data.RecordType,
|
||||||
|
envelope.Data.Commenter,
|
||||||
|
envelope.Data.Text,
|
||||||
|
envelope.Data.CreatedAt,
|
||||||
|
envelope.Data.IngestedAt,
|
||||||
|
envelope.Data.SourceEmailS3Key))
|
||||||
|
: WorkOrderExternalVersion.ComputeWorkOrder(new WorkOrderExternalVersion.WorkOrder(
|
||||||
envelope.Data.WorkOrderId,
|
envelope.Data.WorkOrderId,
|
||||||
envelope.Data.WoStatus ?? envelope.Data.Status,
|
envelope.Data.WoStatus ?? envelope.Data.Status,
|
||||||
envelope.Data.Description,
|
envelope.Data.Description,
|
||||||
|
|
@ -278,17 +289,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
envelope.Data.Severity,
|
envelope.Data.Severity,
|
||||||
envelope.Data.Priority,
|
envelope.Data.Priority,
|
||||||
envelope.Data.AssignedTo,
|
envelope.Data.AssignedTo,
|
||||||
envelope.Data.DateReported?.ToUniversalTime().ToString("O"),
|
envelope.Data.DateReported,
|
||||||
envelope.Data.ScheduledStart?.ToUniversalTime().ToString("O"),
|
envelope.Data.ScheduledStart,
|
||||||
envelope.Data.DueDate?.ToUniversalTime().ToString("O"),
|
envelope.Data.DueDate,
|
||||||
envelope.Data.RecordType,
|
envelope.Data.RecordType,
|
||||||
envelope.Data.CreatedAt?.ToUniversalTime().ToString("O"),
|
envelope.Data.CreatedAt,
|
||||||
envelope.Data.SourceEmailS3Key,
|
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;
|
||||||
|
var status = WorkOrderIngestFieldMapper.MapStatus(statusSource);
|
||||||
mutation = new WorkOrderWebhookMutation
|
mutation = new WorkOrderWebhookMutation
|
||||||
{
|
{
|
||||||
DeliveryId = envelope.DeliveryId,
|
DeliveryId = envelope.DeliveryId,
|
||||||
|
|
@ -305,7 +313,10 @@ namespace SeaHaven.Services.Implementation
|
||||||
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 = status,
|
||||||
|
LifecycleStatus = envelope.EventType == "work_order.cancelled"
|
||||||
|
? LifecycleStatus.Canceled
|
||||||
|
: LifecycleStatusMapper.FromLegacyStatus(status),
|
||||||
Severity = envelope.Data.Severity,
|
Severity = envelope.Data.Severity,
|
||||||
Priority = envelope.Data.Priority
|
Priority = envelope.Data.Priority
|
||||||
?? WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity),
|
?? WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
@ -51,6 +52,8 @@ public sealed class WorkOrderReconciliationTests
|
||||||
|
|
||||||
Assert.Equal(4, mutations.Items.Count);
|
Assert.Equal(4, mutations.Items.Count);
|
||||||
Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1" && m.IsCancelled);
|
Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1" && m.IsCancelled);
|
||||||
|
Assert.Contains(mutations.Items, m => m.ExternalWorkOrderId == "1"
|
||||||
|
&& m.LifecycleStatus == LifecycleStatus.Canceled);
|
||||||
Assert.Contains(mutations.Items, m => m.CommentId == "c2");
|
Assert.Contains(mutations.Items, m => m.CommentId == "c2");
|
||||||
Assert.Equal(2, jobs.CompletedWorkOrders);
|
Assert.Equal(2, jobs.CompletedWorkOrders);
|
||||||
Assert.Equal(2, jobs.CompletedComments);
|
Assert.Equal(2, jobs.CompletedComments);
|
||||||
|
|
@ -71,6 +74,27 @@ public sealed class WorkOrderReconciliationTests
|
||||||
Assert.False(await service.RunPendingAsync(CancellationToken.None));
|
Assert.False(await service.RunPendingAsync(CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Lost_lease_after_fetch_prevents_work_order_persistence()
|
||||||
|
{
|
||||||
|
var mutations = new RecordingWorkOrders();
|
||||||
|
var jobs = new RecordingJobs { LoseLeaseOnRenewal = true };
|
||||||
|
var service = Create(
|
||||||
|
new MockClient(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ProcurementPage<ProcurementWorkOrder>(
|
||||||
|
new[] { WorkOrder("1", "new") },
|
||||||
|
null)
|
||||||
|
},
|
||||||
|
new()),
|
||||||
|
mutations,
|
||||||
|
jobs);
|
||||||
|
|
||||||
|
Assert.False(await service.RunPendingAsync(CancellationToken.None));
|
||||||
|
Assert.Empty(mutations.Items);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Legacy_records_without_timestamps_use_stable_epoch_freshness()
|
public async Task Legacy_records_without_timestamps_use_stable_epoch_freshness()
|
||||||
{
|
{
|
||||||
|
|
@ -299,6 +323,7 @@ public sealed class WorkOrderReconciliationTests
|
||||||
public int CompletedWorkOrders { get; private set; }
|
public int CompletedWorkOrders { get; private set; }
|
||||||
public int CompletedComments { get; private set; }
|
public int CompletedComments { get; private set; }
|
||||||
public int RenewCount { get; private set; }
|
public int RenewCount { get; private set; }
|
||||||
|
public bool LoseLeaseOnRenewal { get; init; }
|
||||||
|
|
||||||
public Task<ReconciliationJobSnapshot> EnqueueAsync(
|
public Task<ReconciliationJobSnapshot> EnqueueAsync(
|
||||||
string reason,
|
string reason,
|
||||||
|
|
@ -323,7 +348,7 @@ public sealed class WorkOrderReconciliationTests
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RenewCount++;
|
RenewCount++;
|
||||||
return Task.FromResult(true);
|
return Task.FromResult(!LoseLeaseOnRenewal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task CompleteAsync(
|
public Task CompleteAsync(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Api.SeaHavenIndustries.Controllers;
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
@ -282,12 +283,43 @@ public sealed class WorkOrderWebhookDataServiceTests
|
||||||
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)) with
|
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)) with
|
||||||
{
|
{
|
||||||
IsCancelled = true,
|
IsCancelled = true,
|
||||||
Status = "Open"
|
Status = "Open",
|
||||||
|
LifecycleStatus = LifecycleStatus.Canceled
|
||||||
};
|
};
|
||||||
|
|
||||||
await data.ApplyAsync(mutation, CancellationToken.None);
|
await data.ApplyAsync(mutation, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("Cancelled", (await context.workOrders.SingleAsync()).Status);
|
Assert.Equal("Cancelled", (await context.workOrders.SingleAsync()).Status);
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, (await context.workOrders.SingleAsync()).LifecycleStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unmapped_imported_status_preserves_existing_lifecycle_status()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
await context.Database.EnsureCreatedAsync();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
ExternalWorkOrderId = "123",
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var data = new WorkOrderWebhookDataService(context);
|
||||||
|
|
||||||
|
await data.ApplyAsync(
|
||||||
|
Mutation(
|
||||||
|
"unmapped-status",
|
||||||
|
"hash",
|
||||||
|
"work_order.updated",
|
||||||
|
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Scheduled, (await context.workOrders.SingleAsync()).LifecycleStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WorkOrderWebhookMutation Mutation(
|
private static WorkOrderWebhookMutation Mutation(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue