diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.Types/Requests/ProcessApprenticeshipApprovalRequest.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.Types/Requests/ProcessApprenticeshipApprovalRequest.cs new file mode 100644 index 000000000..fb5d418bd --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.Types/Requests/ProcessApprenticeshipApprovalRequest.cs @@ -0,0 +1,6 @@ +namespace SFA.DAS.CommitmentsV2.Api.Types.Requests; + +public class ProcessApprenticeshipApprovalRequest : SaveDataRequest +{ + public bool ApplyChanges { get; set; } +} diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.UnitTests/Controllers/ApprenticeshipApprovalControllerTests/WhenGettingApprenticeshipApprovalRequest.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.UnitTests/Controllers/ApprenticeshipApprovalControllerTests/WhenGettingApprenticeshipApprovalRequest.cs new file mode 100644 index 000000000..91224bbaf --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.UnitTests/Controllers/ApprenticeshipApprovalControllerTests/WhenGettingApprenticeshipApprovalRequest.cs @@ -0,0 +1,44 @@ +using AutoFixture.NUnit3; +using Microsoft.AspNetCore.Mvc; +using SFA.DAS.CommitmentsV2.Api.Controllers; +using SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; +using SFA.DAS.Testing.AutoFixture; + +namespace SFA.DAS.CommitmentsV2.Api.UnitTests.Controllers.ApprenticeshipApprovalControllerTests +{ + public class WhenGettingApprenticeshipApprovalRequest + { + [Test, MoqAutoData] + public async Task Then_The_Request_Is_Passed_To_Mediator_And_Data_Returned( + GetApprenticeshipApprovalQueryResult result, + long apprenticeshipId, + Guid ApprovalRequestId, + [Frozen] Mock mediator, + [Greedy] ApprenticeshipApprovalsController controller) + { + mediator.Setup(x => x.Send(It.Is(q => q.ApprenticeshipId == apprenticeshipId && q.ApprovalRequestId == ApprovalRequestId), + CancellationToken.None)).ReturnsAsync(result); + + var actual = await controller.GetApprenticeshipApproval(apprenticeshipId, ApprovalRequestId) as OkObjectResult; + + actual.Should().NotBeNull(); + var model = actual.Value as GetApprenticeshipApprovalQueryResult; + model.Should().Be(result); + } + + [Test, MoqAutoData] + public async Task Then_The_Request_Is_Passed_To_Mediator_And_NoData_Returned( + long apprenticeshipId, + Guid ApprovalRequestId, + [Frozen] Mock mediator, + [Greedy] ApprenticeshipApprovalsController controller) + { + mediator.Setup(x => x.Send(It.Is(q => q.ApprenticeshipId == apprenticeshipId && q.ApprovalRequestId == ApprovalRequestId), + CancellationToken.None)).ReturnsAsync((GetApprenticeshipApprovalQueryResult)null); + + var actual = await controller.GetApprenticeshipApproval(apprenticeshipId, ApprovalRequestId) as NotFoundResult; + + actual.Should().NotBeNull(); + } + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.UnitTests/Controllers/ApprenticeshipApprovalControllerTests/WhenProcessingApprenticeshipApprovalRequest.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.UnitTests/Controllers/ApprenticeshipApprovalControllerTests/WhenProcessingApprenticeshipApprovalRequest.cs new file mode 100644 index 000000000..a08a69b04 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api.UnitTests/Controllers/ApprenticeshipApprovalControllerTests/WhenProcessingApprenticeshipApprovalRequest.cs @@ -0,0 +1,32 @@ +using AutoFixture.NUnit3; +using Microsoft.AspNetCore.Mvc; +using SFA.DAS.CommitmentsV2.Api.Controllers; +using SFA.DAS.CommitmentsV2.Api.Types.Requests; +using SFA.DAS.CommitmentsV2.Application.Commands.ProcessApprenticeshipApproval; +using SFA.DAS.Testing.AutoFixture; + +namespace SFA.DAS.CommitmentsV2.Api.UnitTests.Controllers.ApprenticeshipApprovalControllerTests +{ + public class WhenProcessingApprenticeshipApprovalRequest + { + [Test, MoqAutoData] + public async Task Then_The_Request_Is_Passed_To_Mediator_And_Ok_Returned( + long apprenticeshipId, + Guid ApprovalRequestId, + ProcessApprenticeshipApprovalRequest request, + [Frozen] Mock mediator, + [Greedy] ApprenticeshipApprovalsController controller) + { + mediator.Setup(x => x.Send(It.IsAny(), CancellationToken.None)); + + var actual = await controller.PostApprenticeshipApproval(apprenticeshipId, ApprovalRequestId, request) as OkResult; + + actual.Should().NotBeNull(); + mediator.Verify(x => x.Send(It.Is(c => + c.ApprenticeshipId == apprenticeshipId && + c.ApprovalRequestId == ApprovalRequestId && + c.ApplyChanges == request.ApplyChanges && + c.UserInfo == request.UserInfo), CancellationToken.None), Times.Once); + } + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api/Controllers/ApprenticeshipApprovalsController.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api/Controllers/ApprenticeshipApprovalsController.cs new file mode 100644 index 000000000..f66a837ee --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Api/Controllers/ApprenticeshipApprovalsController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Authorization; +using SFA.DAS.CommitmentsV2.Api.Types.Requests; +using SFA.DAS.CommitmentsV2.Application.Commands.ProcessApprenticeshipApproval; +using SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; + +namespace SFA.DAS.CommitmentsV2.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/apprenticeships/{ApprenticeshipId:long}/approvals/{ApprovalRequestId:Guid}")] +public class ApprenticeshipApprovalsController(IMediator mediator) : ControllerBase +{ + [HttpGet] + public async Task GetApprenticeshipApproval(long apprenticeshipId, Guid approvalRequestId) + { + var result = await mediator.Send(new GetApprenticeshipApprovalQuery(apprenticeshipId, approvalRequestId)); + + if(result == null) + { + return NotFound(); + } + + return Ok(result); + } + + [HttpPost] + public async Task PostApprenticeshipApproval(long apprenticeshipId, Guid approvalRequestId, [FromBody] ProcessApprenticeshipApprovalRequest request) + { + await mediator.Send(new ProcessApprenticeshipApprovalCommand + { + ApprenticeshipId = apprenticeshipId, + ApprovalRequestId = approvalRequestId, + ApplyChanges = request.ApplyChanges, + UserInfo = request.UserInfo + }); + + return Ok(); + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Messages/Events/LearningChangeEvents.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Messages/Events/LearningChangeEvents.cs new file mode 100644 index 000000000..a7dc43114 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Messages/Events/LearningChangeEvents.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace SFA.DAS.CommitmentsV2.Messages.Events; + + +public class LearningChangeApprovedEvent : LearningChangeEvent { } + +public class LearningChangeRejectedEvent : LearningChangeEvent { } + + +public class LearningChangeEvent +{ + public Guid LearningKey { get; set; } + public long ApprenticeshipId { get; set; } + public Dictionary Changes { get; set; } + + public class Change + { + public string Old { get; set; } + public string New { get; set; } + public DateTime? EffectiveFromDate { get; set; } + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Commands/ProcessApprenticeshipApprovalCommandHandlerTests.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Commands/ProcessApprenticeshipApprovalCommandHandlerTests.cs new file mode 100644 index 000000000..d74a1cb40 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Commands/ProcessApprenticeshipApprovalCommandHandlerTests.cs @@ -0,0 +1,201 @@ +using NServiceBus; +using SFA.DAS.CommitmentsV2.Application.Commands.ProcessApprenticeshipApproval; +using SFA.DAS.CommitmentsV2.Data; +using SFA.DAS.CommitmentsV2.Domain.Exceptions; +using SFA.DAS.CommitmentsV2.Messages.Commands; +using SFA.DAS.CommitmentsV2.Messages.Events; +using SFA.DAS.CommitmentsV2.Models; +using SFA.DAS.CommitmentsV2.Types; + +namespace SFA.DAS.CommitmentsV2.UnitTests.Application.Commands; +[TestFixture] +public class ProcessApprenticeshipApprovalCommandHandlerTests +{ + private Fixture _autoFixture; + private ProcessApprenticeshipApprovalCommandHandlerTestsFixture _fixture; + + [SetUp] + public void Arrange() + { + _autoFixture = new Fixture(); + _fixture = new ProcessApprenticeshipApprovalCommandHandlerTestsFixture(); + } + + [Test] + public async Task When_HandlingCommand_And_ApprovalRequest_NotFound_Throw_Exception() + { + _fixture.ApprovalRequest = null; + var act = async () => await _fixture.Handle(); + await act.Should().ThrowAsync().WithMessage($"Approval request {_fixture.Command.ApprovalRequestId} not found"); + } + + [Test] + public async Task When_HandlingCommand_And_ApprovalRequest_Found_But_Wrong_Apprenticeship_Throw_Exception() + { + _fixture.ApprovalRequest.ApprenticeshipId = _fixture.Command.ApprenticeshipId + 1; + await _fixture.SeedData(); + var act = async () => await _fixture.Handle(); + await act.Should().ThrowAsync().WithMessage($"Approval request {_fixture.Command.ApprovalRequestId} not found for apprenticeship {_fixture.Command.ApprenticeshipId}"); + } + + [Test] + public async Task When_HandlingCommand_And_ApprovalRequest_Found_But_Status_Not_Pending_Throw_Exception() + { + var items = new List(); + _fixture.ApprovalRequest.Status = CocApprovalResultStatus.Cancelled; + await _fixture.SeedData(); + var act = async () => await _fixture.Handle(); + await act.Should().ThrowAsync().WithMessage($"Approval request {_fixture.Command.ApprovalRequestId} is no longer pending. It's status is {_fixture.ApprovalRequest.Status}"); + } + + [Test] + public async Task When_HandlingCommand_And_ApprovalRequest_TNPValues_Exceed_Upper_Limit_Throw_DomainException() + { + var items = new List(); + await _fixture.SeedData(); + _fixture.ApprovalRequest.Items.First(x => x.Field == "TNP1").New = "100001"; + var act = async () => await _fixture.Handle(); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task When_HandlingCommand_Should_Send_Command_ToChangeHistory() + { + await _fixture.SeedData(); + await _fixture.Handle(); + + _fixture.MessageSession.Verify(y => y.Send(It.Is(x => x.ApprenticeshipId == _fixture.Command.ApprenticeshipId && + x.Source == LearningSourceType.ApprovalAPI && + x.ChangeType == (_fixture.Command.ApplyChanges ? LearningChangeType.EmployerApproved : LearningChangeType.EmployerRejected) && + x.Description == "Total price change from £1,100 to £2,200" + ), It.IsAny()), Times.Once); + } + + [Test] + public async Task When_HandlingCommand_Should_SaveRequestAsApproved() + { + _fixture.Command.ApplyChanges = true; + await _fixture.SeedData(); + await _fixture.Handle(); + + var request = await _fixture.Db.ApprovalRequests.FirstOrDefaultAsync(x => x.Id == _fixture.Command.ApprovalRequestId); + + request.Status.Should().Be(CocApprovalResultStatus.Complete); + request.Items.First().Status.Should().Be(CocApprovalItemStatus.EmployerApproved); + request.Items.First().ApproverId.Should().Be(_fixture.Command.UserInfo.UserId); + request.Items.Last().Status.Should().Be(CocApprovalItemStatus.EmployerApproved); + request.Items.Last().ApproverId.Should().Be(_fixture.Command.UserInfo.UserId); + } + + [Test] + public async Task When_HandlingCommand_Should_SaveRequestAsRejected() + { + _fixture.Command.ApplyChanges = false; + await _fixture.SeedData(); + await _fixture.Handle(); + + var request = await _fixture.Db.ApprovalRequests.FirstOrDefaultAsync(x => x.Id == _fixture.Command.ApprovalRequestId); + + request.Status.Should().Be(CocApprovalResultStatus.Complete); + request.Items.First().Status.Should().Be(CocApprovalItemStatus.EmployerRejected); + request.Items.First().ApproverId.Should().Be(_fixture.Command.UserInfo.UserId); + request.Items.Last().Status.Should().Be(CocApprovalItemStatus.EmployerRejected); + request.Items.Last().ApproverId.Should().Be(_fixture.Command.UserInfo.UserId); + } + + [Test] + public async Task When_HandlingCommand_Should_Publish_LearningChangeApprovedEvent() + { + _fixture.Command.ApplyChanges = true; + await _fixture.SeedData(); + await _fixture.Handle(); + + _fixture.MessageSession.Verify(y => y.Publish(It.Is(x => x.ApprenticeshipId == _fixture.Command.ApprenticeshipId && + x.LearningKey == _fixture.ApprovalRequest.LearningKey && + x.Changes["TrainingPrice"].Old == "1000" && + x.Changes["TrainingPrice"].New == "2000" && + x.Changes["AssessmentPrice"].Old == "100" && + x.Changes["AssessmentPrice"].New == "200"), It.IsAny()), Times.Once); + } + + [Test] + public async Task When_HandlingCommand_Should_Publish_LearningChangeRejectedEvent() + { + _fixture.Command.ApplyChanges = false; + await _fixture.SeedData(); + await _fixture.Handle(); + + _fixture.MessageSession.Verify(y => y.Publish(It.Is(x => x.ApprenticeshipId == _fixture.Command.ApprenticeshipId && + x.LearningKey == _fixture.ApprovalRequest.LearningKey && + x.Changes["TrainingPrice"].Old == "1000" && + x.Changes["TrainingPrice"].New == "2000" && + x.Changes["AssessmentPrice"].Old == "100" && + x.Changes["AssessmentPrice"].New == "200"), It.IsAny()), Times.Once); + } + + + public class ProcessApprenticeshipApprovalCommandHandlerTestsFixture + { + public ProcessApprenticeshipApprovalCommandHandler Handler; + public ProcessApprenticeshipApprovalCommand Command; + public ProviderCommitmentsDbContext Db { get; set; } + public ApprovalRequest ApprovalRequest; + public List Items; + public Mock MessageSession; + + public ProcessApprenticeshipApprovalCommandHandlerTestsFixture() + { + var autoFixture = new Fixture(); + Db = new ProviderCommitmentsDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + MessageSession = new Mock(); + + Handler = new ProcessApprenticeshipApprovalCommandHandler( + new Lazy(() => Db), + MessageSession.Object); + + Command = autoFixture.Create(); + Items = + [ + autoFixture.Build() + .With(x => x.Id, Guid.NewGuid()) + .With(x => x.Field, "TNP1") + .With(x => x.Old, "1000") + .With(x => x.New, "2000") + .Without(x => x.ApprovalRequestId) + .Without(x => x.ApprovalRequest) + .Create(), + autoFixture.Build() + .With(x => x.Id, Guid.NewGuid()) + .With(x => x.Field, "TNP2") + .With(x => x.Old, "100") + .With(x => x.New, "200") + .Without(x => x.ApprovalRequestId) + .Without(x => x.ApprovalRequest) + .Create(), + ]; + + ApprovalRequest = autoFixture.Build() + .With(x => x.Items, Items) + .With(x => x.Id, Command.ApprovalRequestId) + .With(x => x.ApprenticeshipId, Command.ApprenticeshipId) + .With(x => x.Status, CocApprovalResultStatus.Pending).Create(); + } + + public async Task Handle() + { + await Handler.Handle(Command, CancellationToken.None); + } + + public async Task SeedData() + { + if (ApprovalRequest != null) + { + Db.ApprovalRequests.Add(ApprovalRequest); + await Db.SaveChangesAsync(); + } + return this; + } + } +} diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalHandlerTests.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalHandlerTests.cs new file mode 100644 index 000000000..1fdfd7e57 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalHandlerTests.cs @@ -0,0 +1,217 @@ +using SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; +using SFA.DAS.CommitmentsV2.Data; +using SFA.DAS.CommitmentsV2.Models; +using SFA.DAS.CommitmentsV2.Types; + +namespace SFA.DAS.CommitmentsV2.UnitTests.Application.Queries.GetApprenticeshipApproval; + +[TestFixture] +public class GetApprenticeshipApprovalHandlerTests +{ + private GetApprenticeshipApprovalHandlerTestsFixture _fixture; + + [SetUp] + public void Arrange() + { + _fixture = new GetApprenticeshipApprovalHandlerTestsFixture(); + } + + [Test] + public async Task Handle_ThenShouldReturn_CoreValues() + { + var result = await _fixture.Handle(); + + result.Should().NotBeNull(); + result.ApprenticeshipId.Should().Be(_fixture.ApprenticeshipId); + result.ApprovalRequestId.Should().Be(_fixture.ApprovalRequestId); + result.AccountLegalEntityId.Should().Be(_fixture.AccountLegalEntityId); + result.AccountLegalEntityName.Should().Be(_fixture.AccountLegalEntity.Name); + result.AccountId.Should().Be(_fixture.AccountLegalEntity.AccountId); + result.Name.Should().Be($"{_fixture.Apprenticeship.FirstName} {_fixture.Apprenticeship.LastName}"); + result.ULN.Should().Be(_fixture.Apprenticeship.Uln); + result.StartDate.Should().Be(_fixture.Apprenticeship.StartDate); + result.CourseCode.Should().Be(_fixture.Apprenticeship.CourseCode); + result.CourseName.Should().Be(_fixture.Apprenticeship.CourseName); + result.ProviderName.Should().Be(_fixture.Provider.Name); + result.UKPRN.Should().Be(_fixture.Provider.UkPrn); + result.ApprovalRequestStatus.Should().Be(_fixture.ApprovalRequest.Status); + result.Items.Should().HaveCount(_fixture.ApprovalFieldRequests.Count); + } + + [Test] + public async Task Handle_ForContinuationApprenticeship_ThenShouldReturn_OriginalStartDate() + { + _fixture.Apprenticeship.ContinuationOfId = 123; + _fixture.Apprenticeship.OriginalStartDate = DateTime.Now.AddYears(-2); + var result = await _fixture.Handle(); + + result.Should().NotBeNull(); + result.StartDate.Should().Be(_fixture.Apprenticeship.OriginalStartDate); + } + + [Test] + public async Task Handle_ThenShouldReturn_MappedItems() + { + var result = await _fixture.Handle(); + + result.Should().NotBeNull(); + result.Items.Should().BeEquivalentTo(_fixture.ApprovalFieldRequests + .Select(x=> new GetApprenticeshipApprovalQueryResult.ChangeItem { FieldName = x.Field, NewValue = x.New, OldValue = x.Old, EffectiveFromDate = x.EffectiveFromDate } )); + } + + [Test] + public async Task WhenApprovalRequestNotFound_ThenShouldReturn_Null() + { + _fixture.Request = new GetApprenticeshipApprovalQuery(_fixture.ApprenticeshipId, Guid.NewGuid()); + var result = await _fixture.Handle(); + + result.Should().BeNull(); + } + + [Test] + public async Task WhenApprenticeshipIdDoesNotMatch_ThenShouldReturn_Null() + { + _fixture.Request = new GetApprenticeshipApprovalQuery(_fixture.ApprenticeshipId + 1, _fixture.ApprovalRequestId); + var result = await _fixture.Handle(); + + result.Should().BeNull(); + } + + public class GetApprenticeshipApprovalHandlerTestsFixture + { + public long ApprenticeshipId { get; private set; } + public Guid ApprovalRequestId { get; private set; } = Guid.NewGuid(); + public long AccountLegalEntityId { get; private set; } + public ApprovalRequest ApprovalRequest { get; private set; } + public List ApprovalFieldRequests { get; private set; } + public Apprenticeship Apprenticeship { get; private set; } + public Cohort Cohort { get; private set; } + public Provider Provider { get; private set; } + public AccountLegalEntity AccountLegalEntity { get; private set; } + public Course Course { get; private set; } + + public GetApprenticeshipApprovalQuery Request; + public GetApprenticeshipApprovalQueryResult Result; + + private readonly GetApprenticeshipApprovalQueryHandler _handler; + private readonly ProviderCommitmentsDbContext _db; + private Fixture _autoFixture; + + public GetApprenticeshipApprovalHandlerTestsFixture() + { + _db = new ProviderCommitmentsDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString(), b => b.EnableNullChecks(false)).Options); + SeedData(); + Request = new GetApprenticeshipApprovalQuery(ApprenticeshipId, ApprovalRequestId); + + + _handler = new GetApprenticeshipApprovalQueryHandler(new Lazy(() => _db)); + } + + public async Task Handle() + { + Result = await _handler.Handle(Request, new CancellationToken()); + return Result; + } + + private GetApprenticeshipApprovalHandlerTestsFixture SeedData() + { + _autoFixture = new Fixture(); + _autoFixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + ApprenticeshipId = _autoFixture.Create(); + + Provider = new Provider + { + UkPrn = _autoFixture.Create(), + Name = _autoFixture.Create() + }; + + var account = new Account(1, "", "", "", DateTime.UtcNow); + + AccountLegalEntity = new AccountLegalEntity(account, + AccountLegalEntityId, + 0, + "", + publicHashedId: _autoFixture.Create(), + _autoFixture.Create(), + OrganisationType.PublicBodies, + "", + DateTime.UtcNow); + + Cohort = new Cohort + { + Id = _autoFixture.CreateMany().Last(), + AccountLegalEntity = AccountLegalEntity, + EmployerAccountId = _autoFixture.Create(), + ProviderId = Provider.UkPrn, + Provider = Provider, + ApprenticeshipEmployerTypeOnApproval = ApprenticeshipEmployerType.Levy + }; + + var courseCode = _autoFixture.Create(); + + Apprenticeship = new Apprenticeship + { + Id = ApprenticeshipId, + CommitmentId = Cohort.Id, + Cohort = Cohort, + AgreedOn = _autoFixture.Create(), + CourseCode = courseCode, + StandardUId = "ST0001_1.0", + TrainingCourseVersion = "1.0", + CourseName = _autoFixture.Create(), + FirstName = _autoFixture.Create(), + LastName = _autoFixture.Create(), + DateOfBirth = _autoFixture.Create(), + StartDate = _autoFixture.Create(), + EndDate = _autoFixture.Create(), + Uln = _autoFixture.Create(), + PaymentStatus = _autoFixture.Create(), + EmployerRef = _autoFixture.Create(), + MadeRedundant = _autoFixture.Create(), + FlexibleEmployment = _autoFixture.Create(), + PriorLearning = _autoFixture.Create(), + TrainingTotalHours = _autoFixture.Create(), + EmployerHasEditedCost = _autoFixture.Create(), + StopDate = _autoFixture.Create(), + WithdrawnReasonCode = _autoFixture.Create(), + PaymentFreezeDate = DateTime.UtcNow.Date.AddDays(-7), + FreezePaymentsReason = FreezePaymentsReason.LearnerOnBreak, + }; + + switch (Apprenticeship.PaymentStatus) + { + case PaymentStatus.Withdrawn: + Apprenticeship.StopDate = _autoFixture.Create(); + break; + case PaymentStatus.Paused: + Apprenticeship.PauseDate = _autoFixture.Create(); + break; + case PaymentStatus.Completed: + Apprenticeship.CompletionDate = _autoFixture.Create(); + break; + } + + _db.Apprenticeships.Add(Apprenticeship); + + Course = _autoFixture.Build().With(c => c.LarsCode, courseCode).Create(); + _db.Courses.Add(Course); + + ApprovalFieldRequests = _autoFixture.Build() + .With(afr => afr.ApprovalRequestId, ApprovalRequestId) + .CreateMany(3).ToList(); + + ApprovalRequest = _autoFixture.Build() + .With(ar => ar.Id, ApprovalRequestId) + .With(ar => ar.ApprenticeshipId, ApprenticeshipId) + .With(ar => ar.Status, CocApprovalResultStatus.Pending) + .With(ar => ar.Items, ApprovalFieldRequests) + .Create(); + _db.ApprovalRequests.Add(ApprovalRequest); + + _db.SaveChanges(); + + return this; + } + } +} diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalValidationTests.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalValidationTests.cs new file mode 100644 index 000000000..37f000637 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalValidationTests.cs @@ -0,0 +1,34 @@ +using SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; + +namespace SFA.DAS.CommitmentsV2.UnitTests.Application.Queries.GetApprenticeshipApproval; + +[TestFixture] +public class GetApprenticeshipApprovalValidationTests +{ + [TestCase(-1, false)] + [TestCase(0, false)] + [TestCase(1, true)] + public void Validate_WithSpecifiedAppprenticeshipId(int apprenticeshipId, bool expectedIsValid) + { + var validator = new GetApprenticeshipApprovalQueryValidator(); + var validationResults = validator.Validate(new GetApprenticeshipApprovalQuery(apprenticeshipId, Guid.NewGuid())); + validationResults.IsValid.Should().Be(expectedIsValid); + } + + [Test] + public void Validate_WithNonEmptyApprovalRequestId_Then_Should_Pass() + { + var validator = new GetApprenticeshipApprovalQueryValidator(); + var validationResults = validator.Validate(new GetApprenticeshipApprovalQuery(111, Guid.NewGuid())); + validationResults.IsValid.Should().Be(true); + } + + [Test] + public void Validate_WithEmptyApprovalRequestId_Then_Should_Fail() + { + var validator = new GetApprenticeshipApprovalQueryValidator(); + var validationResults = validator.Validate(new GetApprenticeshipApprovalQuery(111, Guid.Empty)); + validationResults.IsValid.Should().Be(false); + } + +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipUpdate/GetApprenticeshipUpdateValidationTests.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipUpdate/GetApprenticeshipUpdateValidationTests.cs index 72a4236e4..dfd352c59 100644 --- a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipUpdate/GetApprenticeshipUpdateValidationTests.cs +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.UnitTests/Application/Queries/GetApprenticeshipUpdate/GetApprenticeshipUpdateValidationTests.cs @@ -1,30 +1,29 @@ using SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipUpdate; using SFA.DAS.CommitmentsV2.Types; -namespace SFA.DAS.CommitmentsV2.UnitTests.Application.Queries.GetApprenticeshipUpdate +namespace SFA.DAS.CommitmentsV2.UnitTests.Application.Queries.GetApprenticeshipUpdate; + +[TestFixture] +public class GetApprenticeshipUpdateValidationTests { - [TestFixture] - public class GetApprenticeshipUpdateValidationTests + [TestCase(-1, false)] + [TestCase(0, false)] + [TestCase(1, true)] + public void Validate_WithSpecifiedAppprenticeshipId(int apprenticeshipId, bool expectedIsValid) { - [TestCase(-1, false)] - [TestCase(0, false)] - [TestCase(1, true)] - public void Validate_WithSpecifiedAppprenticeshipId(int apprenticeshipId, bool expectedIsValid) - { - var validator = new GetApprenticeshipUpdateQueryValidator(); - var validationResults = validator.Validate(new GetApprenticeshipUpdateQuery(apprenticeshipId, null)); - Assert.That(validationResults.IsValid, Is.EqualTo(expectedIsValid)); - } + var validator = new GetApprenticeshipUpdateQueryValidator(); + var validationResults = validator.Validate(new GetApprenticeshipUpdateQuery(apprenticeshipId, null)); + Assert.That(validationResults.IsValid, Is.EqualTo(expectedIsValid)); + } - [TestCase(null, true)] - [TestCase(ApprenticeshipUpdateStatus.Approved, true)] - [TestCase(ApprenticeshipUpdateStatus.Deleted, true)] - [TestCase(ApprenticeshipUpdateStatus.Superceded, true)] - public void DontValidate_ApprenticeshipUpdateStatus(ApprenticeshipUpdateStatus? status, bool expectedIsValid) - { - var validator = new GetApprenticeshipUpdateQueryValidator(); - var validationResults = validator.Validate(new GetApprenticeshipUpdateQuery(1, status)); - Assert.That(validationResults.IsValid, Is.EqualTo(expectedIsValid)); - } + [TestCase(null, true)] + [TestCase(ApprenticeshipUpdateStatus.Approved, true)] + [TestCase(ApprenticeshipUpdateStatus.Deleted, true)] + [TestCase(ApprenticeshipUpdateStatus.Superceded, true)] + public void DontValidate_ApprenticeshipUpdateStatus(ApprenticeshipUpdateStatus? status, bool expectedIsValid) + { + var validator = new GetApprenticeshipUpdateQueryValidator(); + var validationResults = validator.Validate(new GetApprenticeshipUpdateQuery(1, status)); + Assert.That(validationResults.IsValid, Is.EqualTo(expectedIsValid)); } -} +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Commands/ProcessApprenticeshipApproval/ProcessApprenticeshipApprovalCommand.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Commands/ProcessApprenticeshipApproval/ProcessApprenticeshipApprovalCommand.cs new file mode 100644 index 000000000..cfa05f147 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Commands/ProcessApprenticeshipApproval/ProcessApprenticeshipApprovalCommand.cs @@ -0,0 +1,12 @@ +using SFA.DAS.CommitmentsV2.Types; + +namespace SFA.DAS.CommitmentsV2.Application.Commands.ProcessApprenticeshipApproval; + +public class ProcessApprenticeshipApprovalCommand : IRequest +{ + public long ApprenticeshipId { get; set; } + public Guid ApprovalRequestId { get; set; } + public bool ApplyChanges { get; set; } + + public UserInfo UserInfo { get; set; } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Commands/ProcessApprenticeshipApproval/ProcessApprenticeshipApprovalCommandHandler.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Commands/ProcessApprenticeshipApproval/ProcessApprenticeshipApprovalCommandHandler.cs new file mode 100644 index 000000000..16faa4738 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Commands/ProcessApprenticeshipApproval/ProcessApprenticeshipApprovalCommandHandler.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using NServiceBus; +using SFA.DAS.CommitmentsV2.Data; +using SFA.DAS.CommitmentsV2.Domain.Exceptions; +using SFA.DAS.CommitmentsV2.Messages.Commands; +using SFA.DAS.CommitmentsV2.Messages.Events; +using SFA.DAS.CommitmentsV2.Models; +using SFA.DAS.CommitmentsV2.Types; +using UserInfo = SFA.DAS.CommitmentsV2.Types.UserInfo; + +namespace SFA.DAS.CommitmentsV2.Application.Commands.ProcessApprenticeshipApproval; + +public class ProcessApprenticeshipApprovalCommandHandler( + Lazy dbContext, + IMessageSession messageSession) + : IRequestHandler +{ + public async Task Handle(ProcessApprenticeshipApprovalCommand command, CancellationToken cancellationToken) + { + var db = dbContext.Value; + + var approval = await db.ApprovalRequests.Include(x => x.Items).FirstOrDefaultAsync(x => x.Id == command.ApprovalRequestId, cancellationToken); + + if (approval == null) + { + throw new Exception($"Approval request {command.ApprovalRequestId} not found"); + } + if (approval.ApprenticeshipId != command.ApprenticeshipId) + { + throw new Exception($"Approval request {command.ApprovalRequestId} not found for apprenticeship {command.ApprenticeshipId}"); + } + if (approval.Status != CocApprovalResultStatus.Pending) + { + throw new Exception($"Approval request {command.ApprovalRequestId} is no longer pending. It's status is {approval.Status}"); + } + + if(TotalPriceExceedsLimit(approval)) + { + throw new DomainException("ApproveChanges", "The total cost must be £100,000 or less"); + } + + if(command.ApplyChanges) + { + approval.Status = CocApprovalResultStatus.Complete; + foreach (var item in approval.Items) + { + item.Status = CocApprovalItemStatus.EmployerApproved; + item.ApproverId = command.UserInfo?.UserId; + } + + var approved = new LearningChangeApprovedEvent + { + LearningKey = approval.LearningKey, + ApprenticeshipId = approval.ApprenticeshipId, + Changes = ConvertItemsToChangeDictionary(approval.Items) + }; + await messageSession.Publish(approved); + } + else + { + approval.Status = CocApprovalResultStatus.Complete; + foreach (var item in approval.Items) + { + item.Status = CocApprovalItemStatus.EmployerRejected; + item.ApproverId = command.UserInfo?.UserId; + } + + var rejected = new LearningChangeRejectedEvent + { + LearningKey = approval.LearningKey, + ApprenticeshipId = approval.ApprenticeshipId, + Changes = ConvertItemsToChangeDictionary(approval.Items) + }; + await messageSession.Publish(rejected); + } + + await RecordCocUpdatesInLearnerHistory(approval, command.UserInfo, command.ApplyChanges); + } + + private async Task RecordCocUpdatesInLearnerHistory(ApprovalRequest approval, UserInfo userInfo, bool applyChanges) + { + + if(approval.Items != null && approval.Items.Any(x=>x.Field == "TNP1" || x.Field == "TNP2")) + { + var totalOldValues = SumStringList(approval.Items.Where(x => x.Field == "TNP1" || x.Field == "TNP2").Select(x => x.Old).ToList()); + var totalNewValues = SumStringList(approval.Items.Where(x => x.Field == "TNP1" || x.Field == "TNP2").Select(x => x.New).ToList()); + + await messageSession.Send(new StoreLearningHistoryCommand + { + ApprenticeshipId = approval.ApprenticeshipId, + Source = LearningSourceType.ApprovalAPI, + ChangeType = applyChanges ? LearningChangeType.EmployerApproved : LearningChangeType.EmployerRejected, + AppliedDate = DateTime.UtcNow, + Description = $"Total price change from {ToCurrency(totalOldValues)} to {ToCurrency(totalNewValues)}", + UserId = GetUserId(userInfo) + }); + } + } + + private bool TotalPriceExceedsLimit(ApprovalRequest approval) + { + if(approval.Items == null) + return false; + return SumStringList(approval.Items.Where(x => x.Field == "TNP1" || x.Field == "TNP2").Select(x => x.New).ToList()) > 100000; + } + + public static string ToCurrency(int input) + { + var culture = new CultureInfo("en-GB"); + return input.ToString("C0", culture); + } + + private int SumStringList(List list) + { + int total = 0; + + foreach (var s in list) + { + if (int.TryParse(s, out int value)) + total += value; + } + return total; + } + + private static Guid? GetUserId(UserInfo userInfo) + { + if (userInfo?.UserId != null && Guid.TryParse(userInfo.UserId, out var userId)) + { + return userId; + } + + return null; + } + + private Dictionary ConvertItemsToChangeDictionary(ICollection items) + { + var changes = new Dictionary(); + foreach (var item in items) + { + changes[MapToLearningFieldName(item.Field)] = new LearningChangeEvent.Change + { + Old = item.Old, + New = item.New, + EffectiveFromDate = item.EffectiveFromDate + }; + } + return changes; + } + + private string MapToLearningFieldName(string fieldName) + { + return fieldName switch + { + "TNP1" => "TrainingPrice", + "TNP2" => "AssessmentPrice", + _ => fieldName + }; + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQuery.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQuery.cs new file mode 100644 index 000000000..9f08d52db --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQuery.cs @@ -0,0 +1,14 @@ +namespace SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; + +public class GetApprenticeshipApprovalQuery : IRequest +{ + public long ApprenticeshipId { get; } + + public Guid ApprovalRequestId { get; } + + public GetApprenticeshipApprovalQuery(long apprenticeshipId, Guid approvalRequestId) + { + ApprenticeshipId = apprenticeshipId; + ApprovalRequestId = approvalRequestId; + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryHandler.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryHandler.cs new file mode 100644 index 000000000..fca5fe30d --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryHandler.cs @@ -0,0 +1,52 @@ +using SFA.DAS.CommitmentsV2.Data; + +namespace SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; + +public class GetApprenticeshipApprovalQueryHandler(Lazy dbContext) : IRequestHandler +{ + public async Task Handle(GetApprenticeshipApprovalQuery request, CancellationToken cancellationToken) + { + var approvalRequest = await dbContext.Value.ApprovalRequests + .Include(x => x.Items) + .FirstOrDefaultAsync(x => x.Id == request.ApprovalRequestId, cancellationToken); + + if (approvalRequest == null) + { + return null; + } + + if (approvalRequest.ApprenticeshipId != request.ApprenticeshipId) + { + return null; + } + + var apprenticeship = await dbContext.Value.Apprenticeships + .Include(x => x.Cohort).ThenInclude(x => x.Provider) + .Include(a => a.Cohort).ThenInclude(c => c.AccountLegalEntity) + .FirstOrDefaultAsync(x => x.Id == request.ApprenticeshipId, cancellationToken); + + return new GetApprenticeshipApprovalQueryResult + { + ApprenticeshipId = apprenticeship.Id, + Name = $"{apprenticeship.FirstName} {apprenticeship.LastName}", + ULN = apprenticeship.Uln, + UKPRN = apprenticeship.Cohort.Provider.UkPrn, + ProviderName = apprenticeship.Cohort.Provider.Name, + AccountLegalEntityId = apprenticeship.Cohort.AccountLegalEntity.Id, + AccountLegalEntityName = apprenticeship.Cohort.AccountLegalEntity.Name, + AccountId = apprenticeship.Cohort.AccountLegalEntity.AccountId, + StartDate = apprenticeship.OriginalStartDate ?? apprenticeship.StartDate, + CourseCode = apprenticeship.CourseCode, + CourseName = apprenticeship.CourseName, + ApprovalRequestStatus = approvalRequest.Status, + ApprovalRequestId = approvalRequest.Id, + Items = approvalRequest.Items.Select(i => new GetApprenticeshipApprovalQueryResult.ChangeItem + { + FieldName = i.Field, + OldValue = i.Old, + NewValue = i.New, + EffectiveFromDate = i.EffectiveFromDate + }).ToList() + }; + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryResult.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryResult.cs new file mode 100644 index 000000000..17cd66ac9 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryResult.cs @@ -0,0 +1,29 @@ +using SFA.DAS.CommitmentsV2.Models; + +namespace SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; + +public class GetApprenticeshipApprovalQueryResult +{ + public long ApprenticeshipId { get; set; } + public Guid ApprovalRequestId { get; set; } + public CocApprovalResultStatus? ApprovalRequestStatus { get; set; } + public virtual ICollection Items { get; set; } + public string Name { get; set; } + public string ULN { get; set; } + public DateTime? StartDate { get; set; } + public string CourseCode { get; set; } + public string CourseName { get; set; } + public string ProviderName { get; set; } + public long UKPRN { get; set; } + public string AccountLegalEntityName { get; set; } + public long AccountLegalEntityId { get; set; } + public long AccountId { get; set; } + + public class ChangeItem + { + public string FieldName { get; set; } + public string OldValue { get; set; } + public string NewValue { get; set; } + public DateTime? EffectiveFromDate { get; set; } + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryValidator.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryValidator.cs new file mode 100644 index 000000000..f38b445c7 --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Application/Queries/GetApprenticeshipApproval/GetApprenticeshipApprovalQueryValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace SFA.DAS.CommitmentsV2.Application.Queries.GetApprenticeshipApproval; + +public class GetApprenticeshipApprovalQueryValidator : AbstractValidator +{ + public GetApprenticeshipApprovalQueryValidator() + { + RuleFor(q => q.ApprenticeshipId).GreaterThan(0); + RuleFor(q => q.ApprovalRequestId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/SFA.DAS.CommitmentsV2.csproj b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/SFA.DAS.CommitmentsV2.csproj index 103889a95..2d377e4fb 100644 --- a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/SFA.DAS.CommitmentsV2.csproj +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/SFA.DAS.CommitmentsV2.csproj @@ -65,10 +65,5 @@ - - - - -