Merge pull request #72 from Sea-Haven-Industries/feat/sh-254-be-confirm-deactivation
Some checks are pending
Validate and deploy dev / Validate deployable source bundle (push) Waiting to run
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Blocked by required conditions

feat(vendors): confirm-to-deactivate with open work orders (SH-254)
This commit is contained in:
Alexandre Brandizzi 2026-08-20 11:06:24 -03:00 • committed by GitHub
commit 6eda0cd0f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 138 additions and 14 deletions

View file

@ -256,7 +256,7 @@ public class VendorControllerTests
{
var openWorkOrders = new List<LinkedWorkOrderDTO> { new() { WorkOrderId = 7 } };
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<bool>()))
.ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders));
var logger = new Mock<ILogger<VendorController>>();
@ -293,7 +293,7 @@ public class VendorControllerTests
public async Task Delete_MapsDeactivationBlocked_ToConflict()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<bool>()))
.ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List<LinkedWorkOrderDTO>()));
var controller = NewController(service);
@ -307,7 +307,7 @@ public class VendorControllerTests
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<bool>()))
.ThrowsAsync(new InvalidOperationException());
var controller = NewController(service);

View file

@ -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<ILogger<VendorService>>());
[Fact]
public async Task CreateVendor_PersistsCompleteProfile()
@ -99,7 +101,7 @@ public class VendorServiceTests
data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
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<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(21)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(21))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
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<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(22)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(22))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
new()
{
WorkOrderId = 502,
WorkOrderNumber = "WO-502",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
}
});
var act = () => NewService(data).DeleteVendorAsync(22, "42", confirmOpenWorkOrders: false);
await act.Should().ThrowAsync<VendorDeactivationBlockedException>();
existing.IsActive.Should().BeTrue();
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), 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<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(23)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(23))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
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<ILogger<VendorService>>());
var facets = await service.GetFacetsAsync(null, CancellationToken.None);

View file

@ -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<IActionResult> Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null)
public async Task<IActionResult> 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)

View file

@ -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

View file

@ -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

View file

@ -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<VendorService> _logger;
public VendorService(
IVendorDataService vendorDataService,
@ -33,7 +35,8 @@ namespace SeaHaven.Services.Implementation
IOptions<FrontendOptions> frontendOptions,
ICreateVendorValidation createValidator,
IUpdateVendorValidation updateValidator,
IWorkOrderVendorUpdateValidation workOrderUpdateValidator)
IWorkOrderVendorUpdateValidation workOrderUpdateValidator,
ILogger<VendorService> logger)
{
_vendorDataService = vendorDataService;
_vendorTokenService = vendorTokenService;
@ -42,6 +45,7 @@ namespace SeaHaven.Services.Implementation
_createValidator = createValidator;
_updateValidator = updateValidator;
_workOrderUpdateValidator = workOrderUpdateValidator;
_logger = logger;
}
public async Task<VendorDTO?> 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);

View file

@ -30,7 +30,7 @@ namespace SeaHaven.Services.Interfaces
CancellationToken cancellationToken = default);
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
Task DeleteVendorAsync(int id, string userId);
Task DeleteVendorAsync(int id, string userId, bool confirmOpenWorkOrders);
Task<bool> VendorExistsAsync(int id);
Task<int> GetTotalVendorCountAsync();
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);