mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
feat(vendors): confirm-to-deactivate with open work orders (SH-254)
SH-44 and SH-82 both left "blocks, or requires explicit confirmation" to be decided with the team, and the implementation took the blocking branch. SH-254 settles it the other way: the approved design offers "Deactivate anyway" beside the list of open work orders. Deactivation with open work orders is now permitted, but only when the caller says it has shown them: ConfirmOpenWorkOrders on the update DTO and a confirmOpenWorkOrders query parameter on the delete route. Absent the flag the existing guard still throws, so nothing deactivates by accident and no caller loses the check by omission. confirmOpenWorkOrders is a required parameter on DeleteVendorAsync rather than an optional one, so every call site states its intent.
This commit is contained in:
parent
da21aa84b3
commit
7a0856ddf7
7 changed files with 106 additions and 11 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -99,7 +99,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 +289,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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ namespace SeaHaven.Services.Implementation
|
|||
if (vendor == null)
|
||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||
|
||||
if (dto.IsActive.HasValue && !dto.IsActive.Value)
|
||||
if (dto.IsActive.HasValue && !dto.IsActive.Value && !dto.ConfirmOpenWorkOrders)
|
||||
await AssertNoOpenLinkedWorkOrdersAsync(id);
|
||||
|
||||
if (dto.Name != null) vendor.CompanyName = dto.Name;
|
||||
|
|
@ -224,13 +224,14 @@ 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 AssertNoOpenLinkedWorkOrdersAsync(id);
|
||||
|
||||
vendor.IsActive = false;
|
||||
vendor.LastModificationTime = DateTime.UtcNow;
|
||||
|
|
@ -502,6 +503,9 @@ namespace SeaHaven.Services.Implementation
|
|||
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue