diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index 24477c4..e0df2a9 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -256,7 +256,7 @@ public class VendorControllerTests { var openWorkOrders = new List { new() { WorkOrderId = 7 } }; var service = new Mock(); - service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders)); var logger = new Mock>(); @@ -293,7 +293,7 @@ public class VendorControllerTests public async Task Delete_MapsDeactivationBlocked_ToConflict() { var service = new Mock(); - service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List())); var controller = NewController(service); @@ -307,7 +307,7 @@ public class VendorControllerTests public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound() { var service = new Mock(); - service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException()); var controller = NewController(service); diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 1816257..ba42cb5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; @@ -27,7 +28,8 @@ public class VendorServiceTests Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), new CreateVendorValidation(), new UpdateVendorValidation(), - new WorkOrderVendorUpdateValidation()); + new WorkOrderVendorUpdateValidation(), + Mock.Of>()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() @@ -99,7 +101,7 @@ public class VendorServiceTests data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); - await NewService(data).DeleteVendorAsync(9, "42"); + await NewService(data).DeleteVendorAsync(9, "42", confirmOpenWorkOrders: false); existing.IsActive.Should().BeFalse(); existing.IsDeleted.Should().NotBeTrue(); @@ -289,6 +291,84 @@ public class VendorServiceTests badUrl.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); } + [Fact] + public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_WhenConfirmed_Deactivates() + { + var existing = new Vendor { Id = 21, CompanyName = "Confirmed", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(21)).ReturnsAsync(existing); + data.Setup(x => x.GetLinkedWorkOrdersAsync(21)) + .ReturnsAsync(new List + { + new() + { + WorkOrderId = 501, + WorkOrderNumber = "WO-501", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + } + }); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorAsync( + 21, + new UpdateVendorDTO { IsActive = false, ConfirmOpenWorkOrders = true }, + "42"); + + existing.IsActive.Should().BeFalse(); + data.Verify(x => x.UpdateAsync(existing), Times.Once); + } + + [Fact] + public async Task DeleteVendor_WithOpenWorkOrders_WhenNotConfirmed_ThrowsAndLeavesActive() + { + var existing = new Vendor { Id = 22, CompanyName = "Guarded Delete", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(22)).ReturnsAsync(existing); + data.Setup(x => x.GetLinkedWorkOrdersAsync(22)) + .ReturnsAsync(new List + { + new() + { + WorkOrderId = 502, + WorkOrderNumber = "WO-502", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + } + }); + + var act = () => NewService(data).DeleteVendorAsync(22, "42", confirmOpenWorkOrders: false); + + await act.Should().ThrowAsync(); + existing.IsActive.Should().BeTrue(); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteVendor_WithOpenWorkOrders_WhenConfirmed_Deactivates() + { + var existing = new Vendor { Id = 23, CompanyName = "Confirmed Delete", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(23)).ReturnsAsync(existing); + data.Setup(x => x.GetLinkedWorkOrdersAsync(23)) + .ReturnsAsync(new List + { + new() + { + WorkOrderId = 503, + WorkOrderNumber = "WO-503", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + } + }); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).DeleteVendorAsync(23, "42", confirmOpenWorkOrders: true); + + existing.IsActive.Should().BeFalse(); + data.Verify(x => x.UpdateAsync(existing), Times.Once); + } + [Fact] public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive() { @@ -680,7 +760,8 @@ public class VendorServiceTests Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), new CreateVendorValidation(), new UpdateVendorValidation(), - new WorkOrderVendorUpdateValidation()); + new WorkOrderVendorUpdateValidation(), + Mock.Of>()); var facets = await service.GetFacetsAsync(null, CancellationToken.None); diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index ea76250..1051fcf 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -185,7 +185,8 @@ namespace Api.SeaHavenIndustries.Controllers TradeSpecialties = model.TradeSpecialties, GoogleMapsUrl = model.GoogleMapsUrl, Notes = model.Notes, - IsActive = model.IsActive + IsActive = model.IsActive, + ConfirmOpenWorkOrders = model.ConfirmOpenWorkOrders }; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -221,7 +222,10 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete("{id}")] [HttpPost("Delete")] - public async Task Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null) + public async Task Delete( + [FromRoute] int? id, + [FromQuery(Name = "id")] int? queryId = null, + [FromQuery] bool confirmOpenWorkOrders = false) { try { @@ -234,7 +238,7 @@ namespace Api.SeaHavenIndustries.Controllers if (userId == null) return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - await _vendorService.DeleteVendorAsync(vendorId, userId); + await _vendorService.DeleteVendorAsync(vendorId, userId, confirmOpenWorkOrders); return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" }); } catch (VendorDeactivationBlockedException dbex) diff --git a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs index 6249bb8..6d7b7b8 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -22,6 +22,10 @@ namespace Api.SeaHavenIndustries.DTOs public class EditVendor_DTO : Vendor_DTO { public int Id { get; set; } + + // SH-254: set when the caller has been shown the vendor's open work orders + // and chose to deactivate anyway. Without it the open-work-order guard holds. + public bool ConfirmOpenWorkOrders { get; set; } } public class WorkOrderVendorUpdate_DTO diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 908c5c5..bf30e27 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -80,6 +80,11 @@ namespace SeaHaven.Services.DTOs public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } public bool? IsActive { get; set; } + + // SH-254: deactivating a vendor with open work orders is allowed, but only + // when the caller has seen those work orders and said so. Absent this flag + // the open-work-order guard still blocks. + public bool ConfirmOpenWorkOrders { get; set; } } public class VendorDeactivationImpactDTO diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 4944a58..8765fb3 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using FluentValidation.Results; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; @@ -25,6 +26,7 @@ namespace SeaHaven.Services.Implementation private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; + private readonly ILogger _logger; public VendorService( IVendorDataService vendorDataService, @@ -33,7 +35,8 @@ namespace SeaHaven.Services.Implementation IOptions frontendOptions, ICreateVendorValidation createValidator, IUpdateVendorValidation updateValidator, - IWorkOrderVendorUpdateValidation workOrderUpdateValidator) + IWorkOrderVendorUpdateValidation workOrderUpdateValidator, + ILogger logger) { _vendorDataService = vendorDataService; _vendorTokenService = vendorTokenService; @@ -42,6 +45,7 @@ namespace SeaHaven.Services.Implementation _createValidator = createValidator; _updateValidator = updateValidator; _workOrderUpdateValidator = workOrderUpdateValidator; + _logger = logger; } public async Task GetVendorByIdAsync(int id) @@ -194,7 +198,12 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException($"Vendor with ID {id} not found"); if (dto.IsActive.HasValue && !dto.IsActive.Value) - await AssertNoOpenLinkedWorkOrdersAsync(id); + { + if (dto.ConfirmOpenWorkOrders) + await LogConfirmedDeactivationAsync(id, userId); + else + await AssertNoOpenLinkedWorkOrdersAsync(id); + } if (dto.Name != null) vendor.CompanyName = dto.Name; if (dto.ContactName != null) vendor.ContactName = dto.ContactName; @@ -224,13 +233,16 @@ namespace SeaHaven.Services.Implementation return MapToDTO(vendor); } - public async Task DeleteVendorAsync(int id, string userId) + public async Task DeleteVendorAsync(int id, string userId, bool confirmOpenWorkOrders) { var vendor = await _vendorDataService.GetByIdAsync(id); if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); - await AssertNoOpenLinkedWorkOrdersAsync(id); + if (confirmOpenWorkOrders) + await LogConfirmedDeactivationAsync(id, userId); + else + await AssertNoOpenLinkedWorkOrdersAsync(id); vendor.IsActive = false; vendor.LastModificationTime = DateTime.UtcNow; @@ -502,6 +514,24 @@ namespace SeaHaven.Services.Implementation dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); } + // SH-44's story is about not *silently* orphaning active work. When the caller + // confirms past the guard, the work orders it chose to leave open are recorded. + private async Task LogConfirmedDeactivationAsync(int vendorId, string userId) + { + var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId); + var openCount = linked.Count(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)); + if (openCount == 0) return; + + _logger.LogWarning( + "Vendor {VendorId} deactivated by user {UserId} with {OpenWorkOrderCount} open work orders, confirmed by the caller.", + vendorId, + userId, + openCount); + } + + // Guard for the unconfirmed path only. SH-44 and SH-82 left "blocks or requires + // explicit confirmation" to be settled with the team; SH-254 settles it as + // explicit confirmation, so a caller that has not confirmed is still blocked. private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId) { var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId); diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index b7238da..68e8ae9 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -30,7 +30,7 @@ namespace SeaHaven.Services.Interfaces CancellationToken cancellationToken = default); Task CreateVendorAsync(CreateVendorDTO dto, string userId); Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId); - Task DeleteVendorAsync(int id, string userId); + Task DeleteVendorAsync(int id, string userId, bool confirmOpenWorkOrders); Task VendorExistsAsync(int id); Task GetTotalVendorCountAsync(); Task GetDeactivationImpactAsync(int vendorId);