From 97df3e6e4eabdfa53471c416b98ab9b0f3630c40 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 16 Sep 2026 13:10:58 -0300 Subject: [PATCH] fix(work-orders): keep SH placeholder WO numbers and block downgrades 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. --- .../Helpers/WorkOrderNumberNormalizer.cs | 33 +++++++++++- .../WorkOrderBoardUpdateService.cs | 8 +++ .../WorkOrderBoardCreateServiceTests.cs | 18 +++++++ .../WorkOrderBoardUpdateServiceTests.cs | 51 +++++++++++++++++++ .../WorkOrderNumberNormalizerTests.cs | 26 ++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs b/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs index 59f8494..8e97f43 100644 --- a/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs +++ b/SeaHaven.Services/Helpers/WorkOrderNumberNormalizer.cs @@ -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; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 18604b2..9d14089 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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."); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index dd2fd10..8a327e4 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -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() { diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 975c58a..b2c715e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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(() => + 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() { diff --git a/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs b/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs index dcc4755..708f25d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderNumberNormalizerTests.cs @@ -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); + } }