mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
Merge pull request #72 from Sea-Haven-Industries/feat/sh-254-be-confirm-deactivation
feat(vendors): confirm-to-deactivate with open work orders (SH-254)
This commit is contained in:
commit
6eda0cd0f8
7 changed files with 138 additions and 14 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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