fix(work-orders): keep SH placeholder WO numbers and block downgrades (#123)
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions

SH-320: the WO number normalizer stripped every non-digit, so a manually
entered SH placeholder (e.g. SH00001) was saved as 00000000001 on both
create and patch. Keep the SH prefix, and reject replacing a saved real
APM number with an SH placeholder.
This commit is contained in:
Alexandre Brandizzi 2026-09-16 15:01:10 -03:00 • committed by GitHub
parent 776c8be5cb
commit 4b772c8db3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 135 additions and 1 deletions

View file

@ -5,6 +5,8 @@ namespace SeaHaven.Services.Helpers
public static class WorkOrderNumberNormalizer
{
private const int RequiredLength = 11;
private const string ProvisionalPrefix = "SH";
private const int MinimumProvisionalDigits = 5;
public static bool TryNormalize(string? input, out string normalized, out string? error)
{
@ -17,7 +19,11 @@ namespace SeaHaven.Services.Helpers
return false;
}
var digits = Regex.Replace(input.Trim(), @"\D", "");
var trimmed = input.Trim();
if (WorkOrderNumberRules.IsProvisional(trimmed))
return TryNormalizeProvisional(trimmed, out normalized, out error);
var digits = Regex.Replace(trimmed, @"\D", "");
if (digits.Length == 0)
{
error = "WO number must contain digits.";
@ -33,5 +39,30 @@ namespace SeaHaven.Services.Helpers
normalized = digits.PadLeft(RequiredLength, '0');
return true;
}
// SH-320: an SH-prefixed number is the documented placeholder for a WO still
// awaiting its real APM number; keep the prefix instead of stripping it to digits.
private static bool TryNormalizeProvisional(string trimmed, out string normalized, out string? error)
{
normalized = "";
error = null;
var digits = Regex.Replace(trimmed[ProvisionalPrefix.Length..], @"\D", "");
if (digits.Length == 0)
{
error = "WO number must contain digits.";
return false;
}
var candidate = ProvisionalPrefix + digits.PadLeft(MinimumProvisionalDigits, '0');
if (candidate.Length > RequiredLength)
{
error = $"WO number cannot exceed {RequiredLength} characters.";
return false;
}
normalized = candidate;
return true;
}
}
}

View file

@ -334,6 +334,14 @@ namespace SeaHaven.Services.Implementation
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
// SH-320: a saved real APM number can never be downgraded to an SH placeholder.
if (WorkOrderNumberRules.IsProvisional(normalized)
&& !string.IsNullOrWhiteSpace(workOrder.InternalWONumber)
&& !WorkOrderNumberRules.IsProvisional(workOrder.InternalWONumber))
throw new WorkOrderBoardValidationException(
"WoNumberDowngrade",
"Cannot replace an APM work order number with an internal number.");
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");

View file

@ -87,6 +87,24 @@ public class WorkOrderBoardCreateServiceTests
Assert.Equal("00000012345", result.WoNumber);
}
[Theory]
[InlineData("SH00001", "SH00001")]
[InlineData("sh00042", "SH00042")]
public async Task Create_WithManualShPlaceholderWoNumber_KeepsShPrefix(string requested, string expected)
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WoNumber = requested,
WorkOrderType = WorkOrderType.PM,
SiteCode = "DAL",
LocationId = 1
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
Assert.Equal(expected, result.WoNumber);
}
[Fact]
public async Task Create_DuplicateWoNumber_Throws()
{

View file

@ -1158,6 +1158,57 @@ public class WorkOrderBoardUpdateServiceTests
Assert.Equal("00000012345", result.WoNumber);
}
[Fact]
public async Task PatchField_ShPlaceholderReplacesShPlaceholder()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "SH00001",
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "SH00007",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
Assert.Equal("SH00007", result.WoNumber);
}
[Fact]
public async Task PatchField_RejectsReplacingRealWoNumberWithShPlaceholder()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
InternalWONumber = "00000012345",
LifecycleStatus = LifecycleStatus.Incomplete,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.WoNumber,
Value = "SH00001",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("WoNumberDowngrade", ex.Code);
var stored = await context.workOrders.SingleAsync(w => w.Id == 1);
Assert.Equal("00000012345", stored.InternalWONumber);
}
[Fact]
public async Task PatchField_RejectsDuplicateWoNumber()
{

View file

@ -40,4 +40,30 @@ public class WorkOrderNumberNormalizerTests
Assert.False(ok);
Assert.Contains("11", error);
}
[Theory]
[InlineData("SH00001", "SH00001")]
[InlineData(" sh00001 ", "SH00001")]
[InlineData("SH-00001", "SH00001")]
[InlineData("SH1", "SH00001")]
[InlineData("SH123456789", "SH123456789")]
public void TryNormalize_KeepsShPlaceholderPrefix(string input, string expected)
{
var ok = WorkOrderNumberNormalizer.TryNormalize(input, out var normalized, out var error);
Assert.True(ok);
Assert.Null(error);
Assert.Equal(expected, normalized);
}
[Theory]
[InlineData("SH")]
[InlineData("SH1234567890")]
public void TryNormalize_RejectsInvalidShPlaceholder(string input)
{
var ok = WorkOrderNumberNormalizer.TryNormalize(input, out _, out var error);
Assert.False(ok);
Assert.NotNull(error);
}
}