mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +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 openWorkOrders = new List<LinkedWorkOrderDTO> { new() { WorkOrderId = 7 } };
|
||||||
var service = new Mock<IVendorService>();
|
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));
|
.ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders));
|
||||||
|
|
||||||
var logger = new Mock<ILogger<VendorController>>();
|
var logger = new Mock<ILogger<VendorController>>();
|
||||||
|
|
@ -293,7 +293,7 @@ public class VendorControllerTests
|
||||||
public async Task Delete_MapsDeactivationBlocked_ToConflict()
|
public async Task Delete_MapsDeactivationBlocked_ToConflict()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorService>();
|
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>()));
|
.ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List<LinkedWorkOrderDTO>()));
|
||||||
|
|
||||||
var controller = NewController(service);
|
var controller = NewController(service);
|
||||||
|
|
@ -307,7 +307,7 @@ public class VendorControllerTests
|
||||||
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
|
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
|
||||||
{
|
{
|
||||||
var service = new Mock<IVendorService>();
|
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());
|
.ThrowsAsync(new InvalidOperationException());
|
||||||
|
|
||||||
var controller = NewController(service);
|
var controller = NewController(service);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
|
@ -27,7 +28,8 @@ public class VendorServiceTests
|
||||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||||
new CreateVendorValidation(),
|
new CreateVendorValidation(),
|
||||||
new UpdateVendorValidation(),
|
new UpdateVendorValidation(),
|
||||||
new WorkOrderVendorUpdateValidation());
|
new WorkOrderVendorUpdateValidation(),
|
||||||
|
Mock.Of<ILogger<VendorService>>());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateVendor_PersistsCompleteProfile()
|
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.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
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.IsActive.Should().BeFalse();
|
||||||
existing.IsDeleted.Should().NotBeTrue();
|
existing.IsDeleted.Should().NotBeTrue();
|
||||||
|
|
@ -289,6 +291,84 @@ public class VendorServiceTests
|
||||||
badUrl.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
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]
|
[Fact]
|
||||||
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
|
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
|
||||||
{
|
{
|
||||||
|
|
@ -680,7 +760,8 @@ public class VendorServiceTests
|
||||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||||
new CreateVendorValidation(),
|
new CreateVendorValidation(),
|
||||||
new UpdateVendorValidation(),
|
new UpdateVendorValidation(),
|
||||||
new WorkOrderVendorUpdateValidation());
|
new WorkOrderVendorUpdateValidation(),
|
||||||
|
Mock.Of<ILogger<VendorService>>());
|
||||||
|
|
||||||
var facets = await service.GetFacetsAsync(null, CancellationToken.None);
|
var facets = await service.GetFacetsAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
TradeSpecialties = model.TradeSpecialties,
|
TradeSpecialties = model.TradeSpecialties,
|
||||||
GoogleMapsUrl = model.GoogleMapsUrl,
|
GoogleMapsUrl = model.GoogleMapsUrl,
|
||||||
Notes = model.Notes,
|
Notes = model.Notes,
|
||||||
IsActive = model.IsActive
|
IsActive = model.IsActive,
|
||||||
|
ConfirmOpenWorkOrders = model.ConfirmOpenWorkOrders
|
||||||
};
|
};
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
@ -221,7 +222,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[HttpPost("Delete")]
|
[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
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -234,7 +238,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
if (userId == null)
|
if (userId == null)
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
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" });
|
return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (VendorDeactivationBlockedException dbex)
|
catch (VendorDeactivationBlockedException dbex)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ namespace Api.SeaHavenIndustries.DTOs
|
||||||
public class EditVendor_DTO : Vendor_DTO
|
public class EditVendor_DTO : Vendor_DTO
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
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
|
public class WorkOrderVendorUpdate_DTO
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,11 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public bool? IsActive { 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
|
public class VendorDeactivationImpactDTO
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using FluentValidation.Results;
|
using FluentValidation.Results;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
@ -25,6 +26,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly ICreateVendorValidation _createValidator;
|
private readonly ICreateVendorValidation _createValidator;
|
||||||
private readonly IUpdateVendorValidation _updateValidator;
|
private readonly IUpdateVendorValidation _updateValidator;
|
||||||
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
|
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
|
||||||
|
private readonly ILogger<VendorService> _logger;
|
||||||
|
|
||||||
public VendorService(
|
public VendorService(
|
||||||
IVendorDataService vendorDataService,
|
IVendorDataService vendorDataService,
|
||||||
|
|
@ -33,7 +35,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
IOptions<FrontendOptions> frontendOptions,
|
IOptions<FrontendOptions> frontendOptions,
|
||||||
ICreateVendorValidation createValidator,
|
ICreateVendorValidation createValidator,
|
||||||
IUpdateVendorValidation updateValidator,
|
IUpdateVendorValidation updateValidator,
|
||||||
IWorkOrderVendorUpdateValidation workOrderUpdateValidator)
|
IWorkOrderVendorUpdateValidation workOrderUpdateValidator,
|
||||||
|
ILogger<VendorService> logger)
|
||||||
{
|
{
|
||||||
_vendorDataService = vendorDataService;
|
_vendorDataService = vendorDataService;
|
||||||
_vendorTokenService = vendorTokenService;
|
_vendorTokenService = vendorTokenService;
|
||||||
|
|
@ -42,6 +45,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
_createValidator = createValidator;
|
_createValidator = createValidator;
|
||||||
_updateValidator = updateValidator;
|
_updateValidator = updateValidator;
|
||||||
_workOrderUpdateValidator = workOrderUpdateValidator;
|
_workOrderUpdateValidator = workOrderUpdateValidator;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
|
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
|
||||||
|
|
@ -194,7 +198,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||||
|
|
||||||
if (dto.IsActive.HasValue && !dto.IsActive.Value)
|
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.Name != null) vendor.CompanyName = dto.Name;
|
||||||
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
|
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
|
||||||
|
|
@ -224,13 +233,16 @@ namespace SeaHaven.Services.Implementation
|
||||||
return MapToDTO(vendor);
|
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);
|
var vendor = await _vendorDataService.GetByIdAsync(id);
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
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.IsActive = false;
|
||||||
vendor.LastModificationTime = DateTime.UtcNow;
|
vendor.LastModificationTime = DateTime.UtcNow;
|
||||||
|
|
@ -502,6 +514,24 @@ namespace SeaHaven.Services.Implementation
|
||||||
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
|
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)
|
private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId)
|
||||||
{
|
{
|
||||||
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ namespace SeaHaven.Services.Interfaces
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO 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<bool> VendorExistsAsync(int id);
|
||||||
Task<int> GetTotalVendorCountAsync();
|
Task<int> GetTotalVendorCountAsync();
|
||||||
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);
|
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue