Merge pull request #75 from Sea-Haven-Industries/fix/sh-252-provisional-wo-number
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

fix(work-orders): enforce provisional WO numbers (SH-252)
This commit is contained in:
Alexandre Brandizzi 2026-08-20 14:48:23 -03:00 • committed by GitHub
commit b69c2fe64e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 12 deletions

View file

@ -0,0 +1,29 @@
using System.Globalization;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderNumberRules
{
private const int MaximumLength = 11;
private const int MinimumProvisionalDigits = 5;
public static bool IsProvisional(string? woNumber)
=> !string.IsNullOrWhiteSpace(woNumber)
&& woNumber.Trim().StartsWith("SH", StringComparison.OrdinalIgnoreCase);
public static string FormatProvisional(long seed)
{
if (seed < 1)
throw new ArgumentOutOfRangeException(nameof(seed), "Provisional WO number seed must be positive.");
var digits = seed.ToString(CultureInfo.InvariantCulture)
.PadLeft(MinimumProvisionalDigits, '0');
var provisional = $"SH{digits}";
if (provisional.Length > MaximumLength)
throw new ArgumentOutOfRangeException(nameof(seed), "Provisional WO number exceeds the supported length.");
return provisional;
}
}
}

View file

@ -284,9 +284,19 @@ namespace SeaHaven.Services.Implementation
return normalized;
}
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None);
for (var attempt = 0; attempt < 20; attempt++)
{
var candidate = await GenerateNextSequentialWoNumberAsync();
string candidate;
try
{
candidate = WorkOrderNumberRules.FormatProvisional((long)maxId + 1 + attempt);
}
catch (ArgumentOutOfRangeException)
{
break;
}
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
return candidate;
}
@ -294,15 +304,6 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("WoNumberGenerationFailed", "Could not generate a unique WO number.");
}
private async Task<string> GenerateNextSequentialWoNumberAsync()
{
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None);
var seed = Math.Max(maxId + 1, 1);
if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _))
normalized = seed.ToString().PadLeft(11, '0');
return normalized;
}
private async Task StageChangesAsync(
int workOrderId,
IEnumerable<WorkOrderBoardFieldMutations.BoardFieldChange> changes,

View file

@ -362,6 +362,13 @@ namespace SeaHaven.Services.Implementation
if (parsed == null)
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid lifecycleStatus: {value}");
if (parsed == LifecycleStatus.Completed && WorkOrderNumberRules.IsProvisional(workOrder.InternalWONumber))
{
throw new WorkOrderBoardValidationException(
"ProvisionalWoNumber",
"Replace the provisional SH work order number before marking the work order Completed.");
}
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
if (workOrder.LifecycleStatus == parsed)
return FieldChange.Unchanged(auditField);

View file

@ -44,8 +44,28 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
Assert.Equal("BK5", result.SiteCode);
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
Assert.False(string.IsNullOrWhiteSpace(result.WoNumber));
Assert.Equal(11, result.WoNumber!.Length);
Assert.Equal("SH00001", result.WoNumber);
}
[Fact]
public async Task Create_BlankWoNumber_PreservesExistingNumericPlaceholderAndGeneratesShNumber()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
InternalWONumber = "00000000123",
WorkerOrderNumber = "00000000123"
});
await context.SaveChangesAsync();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal("SH00002", result.WoNumber);
Assert.True(await context.workOrders.AnyAsync(w => w.InternalWONumber == "00000000123"));
}
[Fact]

View file

@ -153,6 +153,51 @@ public class WorkOrderBoardUpdateServiceTests
Assert.False(afterStatus.IsPastDue);
}
[Fact]
public async Task PatchField_ProvisionalWoNumber_BlocksCompletionUntilReplaced()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "SH00001",
WorkerOrderNumber = "SH00001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.Date.AddDays(1),
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var blocked = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("ProvisionalWoNumber", blocked.Code);
var renumbered = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "12345",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("00000012345", renumbered.WoNumber);
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
var completed = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.LifecycleStatus,
Value = "Complete",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal(LifecycleStatus.Completed, completed.LifecycleStatus);
}
[Fact]
public async Task PatchField_DueDateAlone_DoesNotClearPastDue()
{