diff --git a/SeaHaven.Services/Helpers/WorkOrderNumberRules.cs b/SeaHaven.Services/Helpers/WorkOrderNumberRules.cs new file mode 100644 index 0000000..5ceb0ba --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderNumberRules.cs @@ -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; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 161e212..a5df677 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -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 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 changes, diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index d0ea7f7..341bf63 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index a85376a..d3df3a9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -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] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 0c61a84..14fd7e8 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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(() => + 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() {