diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs index dbab5d4..10945ae 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardMutationDataService.cs @@ -25,12 +25,18 @@ namespace SeaHaven.DataServices.Implementation cancellationToken); public Task GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken) - => _context.Dispatches.FirstOrDefaultAsync( - d => d.Id == dispatchId && d.WorkOrderId == workOrderId, - cancellationToken); + => _context.Dispatches + .Include(d => d.DispatchWorkOrders) + .FirstOrDefaultAsync( + d => d.Id == dispatchId + && (d.WorkOrderId == workOrderId + || d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)), + cancellationToken); public Task GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken) - => _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); + => _context.Dispatches + .Include(d => d.DispatchWorkOrders) + .FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken); public void TrackNewWorkOrder(WorkOrder workOrder) => _context.workOrders.Add(workOrder); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index b8c94e8..eb7824c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -88,6 +88,7 @@ namespace SeaHaven.Services.Implementation request.PrimaryDispatchId, vendorIdHint, replaceInactivePrimary, + relaxInvalidDispatch: vendorIdHint.HasValue, ct); dispatch = resolved.Dispatch; @@ -206,12 +207,19 @@ namespace SeaHaven.Services.Implementation int? primaryDispatchId, int? vendorIdForCreate, bool replaceInactivePrimary, + bool relaxInvalidDispatch, CancellationToken cancellationToken) { - var existing = await LoadExistingDispatchAsync(workOrder, primaryDispatchId, cancellationToken); - if (existing != null - && !(replaceInactivePrimary - && WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status))) + var existing = await LoadExistingDispatchAsync( + workOrder, + primaryDispatchId, + relaxInvalidDispatch, + cancellationToken); + var mustReplace = existing != null + && replaceInactivePrimary + && (WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status) + || IsSharedDispatch(existing, workOrder.Id)); + if (existing != null && !mustReplace) { workOrder.PrimaryDispatchId = existing.Id; return new ResolvedDispatch(existing, Created: false, Previous: null); @@ -238,29 +246,60 @@ namespace SeaHaven.Services.Implementation private async Task LoadExistingDispatchAsync( WorkOrder workOrder, int? primaryDispatchId, + bool relaxInvalidDispatch, CancellationToken cancellationToken) { - if (primaryDispatchId.HasValue) + var clientId = NormalizeClientPrimaryDispatchId(primaryDispatchId); + if (clientId.HasValue) { var dispatch = await _mutationData.GetTrackedDispatchAsync( - primaryDispatchId.Value, + clientId.Value, workOrder.Id, cancellationToken); - if (dispatch == null) + if (dispatch != null) + return dispatch; + + if (!relaxInvalidDispatch) throw new WorkOrderBoardValidationException( "InvalidDispatch", "Primary dispatch does not belong to this work order."); - - return dispatch; } - if (!workOrder.PrimaryDispatchId.HasValue) + var storedId = NormalizeClientPrimaryDispatchId(workOrder.PrimaryDispatchId); + if (!storedId.HasValue) return null; - return await _mutationData.GetTrackedDispatchByIdAsync( - workOrder.PrimaryDispatchId.Value, + var stored = await _mutationData.GetTrackedDispatchByIdAsync( + storedId.Value, cancellationToken); + if (stored == null || !DispatchBelongsToWorkOrder(stored, workOrder.Id)) + return null; + + return stored; + } + + private static int? NormalizeClientPrimaryDispatchId(int? primaryDispatchId) + => primaryDispatchId is > 0 ? primaryDispatchId : null; + + private static bool DispatchBelongsToWorkOrder(Dispatch dispatch, int workOrderId) + => dispatch.WorkOrderId == workOrderId + || dispatch.DispatchWorkOrders?.Any(link => link.WorkOrderId == workOrderId) == true; + + private static bool IsSharedDispatch(Dispatch dispatch, int workOrderId) + { + if (dispatch.WorkOrderId.HasValue && dispatch.WorkOrderId.Value != workOrderId) + return true; + + var linkedIds = dispatch.DispatchWorkOrders? + .Select(link => link.WorkOrderId) + .Distinct() + .ToList(); + if (linkedIds == null || linkedIds.Count == 0) + return false; + if (linkedIds.Count > 1) + return true; + return dispatch.WorkOrderId.HasValue && linkedIds[0] != dispatch.WorkOrderId.Value; } private async Task EnsureVendorExistsAsync(int vendorId) diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 9bdf934..b6e0923 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -483,6 +483,212 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal(liveStatus, updated.Status); } + [Fact] + public async Task PatchField_VendorId_SucceedsWhenClientPrimaryDispatchIdDoesNotBelong() + { + var (context, service) = CreateSut(); + context.Vendors.AddRange( + new Vendor { Id = 1, CompanyName = "Old Co" }, + new Vendor { Id = 5, CompanyName = "New Co" }); + var ownDispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var foreignDispatch = new Dispatch + { + Id = 99, + WorkOrderId = 2, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 3 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var otherWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 99, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 4 } + }; + context.Dispatches.AddRange(ownDispatch, foreignDispatch); + context.workOrders.AddRange(wo, otherWo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(ownDispatch), + PrimaryDispatchId = 99 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.Equal(10, result.PrimaryDispatchId); + var leftoverForeign = await context.Dispatches.SingleAsync(d => d.Id == 99); + Assert.Equal(1, leftoverForeign.VendorId); + } + + [Fact] + public async Task PatchField_VendorId_TreatsZeroPrimaryDispatchIdAsOmitted() + { + var (context, service) = CreateSut(); + context.Vendors.AddRange( + new Vendor { Id = 1, CompanyName = "Old Co" }, + new Vendor { Id = 5, CompanyName = "New Co" }); + var dispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Dispatches.Add(dispatch); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(dispatch), + PrimaryDispatchId = 0 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.Equal(10, result.PrimaryDispatchId); + Assert.Equal(1, await context.Dispatches.CountAsync()); + } + + [Fact] + public async Task PatchField_VendorId_SharedDispatch_CreatesWoOwnedDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.AddRange( + new Vendor { Id = 1, CompanyName = "Old Co" }, + new Vendor { Id = 5, CompanyName = "New Co" }); + var shared = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var primaryWo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var secondaryWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 5 } + }; + context.Dispatches.Add(shared); + context.workOrders.AddRange(primaryWo, secondaryWo); + context.DispatchWorkOrders.AddRange( + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 1 }, + new DispatchWorkOrder { DispatchId = 10, WorkOrderId = 2 }); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(2, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(secondaryWo), + DispatchVersion = ToVersion(shared), + PrimaryDispatchId = 10 + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.NotEqual(10, result.PrimaryDispatchId); + Assert.Equal("Pending", result.PrimaryDispatchStatus); + var original = await context.Dispatches.SingleAsync(d => d.Id == 10); + Assert.Equal(1, original.VendorId); + Assert.Equal("Sent", original.Status); + var created = await context.Dispatches.SingleAsync(d => d.Id == result.PrimaryDispatchId); + Assert.Equal(2, created.WorkOrderId); + Assert.Equal(5, created.VendorId); + var primaryUnchanged = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(10, primaryUnchanged.PrimaryDispatchId); + } + + [Fact] + public async Task PatchField_ApptDate_ClientIdNotBelonging_ThrowsInvalidDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" }); + var ownDispatch = new Dispatch + { + Id = 10, + WorkOrderId = 1, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }; + var foreignDispatch = new Dispatch + { + Id = 99, + WorkOrderId = 2, + VendorId = 1, + Status = "Sent", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 3 } + }; + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + var otherWo = new WorkOrder + { + Id = 2, + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 99, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 4 } + }; + context.Dispatches.AddRange(ownDispatch, foreignDispatch); + context.workOrders.AddRange(wo, otherWo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ApptDate, + Value = "2026-06-26", + WorkOrderVersion = ToVersion(wo), + DispatchVersion = ToVersion(ownDispatch), + PrimaryDispatchId = 99 + }, "actor-1")); + + Assert.Equal("InvalidDispatch", ex.Code); + } + [Fact] public async Task PatchField_CreatesFieldLockAndAuditPerField() {