mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
refactor(vendors): enforce service and data boundaries
This commit is contained in:
parent
c440b2e3ee
commit
9cd1c7e7fc
36 changed files with 2698 additions and 1042 deletions
|
|
@ -2,6 +2,7 @@ using Data.SeaHavenIndustries;
|
|||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SeaHaven.DataServices.DependencyInjection;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
|
|
@ -19,6 +20,9 @@ public class ServiceRegistrationTests
|
|||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddSingleton<IZipCodeDistance>(_ => new DummyZipCodeDistance());
|
||||
services.AddSingleton<IEmailSender>(_ => new DummyEmailSender());
|
||||
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()));
|
||||
|
|
@ -76,4 +80,37 @@ public class ServiceRegistrationTests
|
|||
provider.GetRequiredService<IDispatchDataService>().Should().BeOfType<DispatchDataService>();
|
||||
provider.GetRequiredService<ISettingsDataService>().Should().BeOfType<SettingsDataService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConventionDi_RegistersVendorAndUpliftServices()
|
||||
{
|
||||
using var provider = BuildProvider();
|
||||
|
||||
provider.GetRequiredService<IVendorService>().Should().BeOfType<VendorService>();
|
||||
provider.GetRequiredService<IVendorPortalService>().Should().BeOfType<VendorPortalService>();
|
||||
provider.GetRequiredService<IVendorPortalTokenService>()
|
||||
.Should().BeOfType<SeaHaven.Services.Implementation.VendorPortalTokenService>();
|
||||
provider.GetRequiredService<IUpliftService>().Should().BeOfType<UpliftService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConventionDi_RegistersVendorAndUpliftDataServices()
|
||||
{
|
||||
using var provider = BuildProvider();
|
||||
|
||||
provider.GetRequiredService<IVendorDataService>().Should().BeOfType<VendorDataService>();
|
||||
provider.GetRequiredService<IUpliftDataService>().Should().BeOfType<UpliftDataService>();
|
||||
provider.GetRequiredService<ICommentDataService>().Should().BeOfType<CommentDataService>();
|
||||
}
|
||||
|
||||
private sealed class DummyZipCodeDistance : IZipCodeDistance
|
||||
{
|
||||
public double? GetDistanceMiles(string? zip1, string? zip2) => null;
|
||||
}
|
||||
|
||||
private sealed class DummyEmailSender : IEmailSender
|
||||
{
|
||||
public Task<bool> SendEmailAsync(string emailTo, string subject, string body) =>
|
||||
Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs
Normal file
174
Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class UpliftControllerTests
|
||||
{
|
||||
private static UpliftController NewController(Mock<IUpliftService> service, params string[] roles)
|
||||
{
|
||||
var controller = new UpliftController(service.Object);
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "42") };
|
||||
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_ReturnsPagedResult()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
var dto = new UpliftListResultDTO
|
||||
{
|
||||
Total = 1, Page = 1, PageSize = 25,
|
||||
Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } }
|
||||
};
|
||||
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Pending", null, 1, 25, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(dto);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.List();
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
envelope.Status.Should().Be("Success");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_NotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 99, null, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Approve(99, null);
|
||||
|
||||
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
nf.Value.Should().BeOfType<Response>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_ForbiddenTier_Returns403()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new UpliftForbiddenException("requires Tier 2"));
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Approve(5, null);
|
||||
|
||||
var status = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
status.StatusCode.Should().Be(403);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_AlreadyDecided_Returns400()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("already decided"));
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Approve(5, null);
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_Success_ReturnsApprovedResult()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
var approved = new UpliftApproveResultDTO { Id = 5, Status = "Approved", NTEAmount = 750m };
|
||||
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, "ok", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(approved);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Approve(5, new UpliftController.DecisionRequest { Note = "ok" });
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
((UpliftApproveResultDTO)envelope.Data).Should().Be(approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deny_WithoutNote_Returns400()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("A note is required when denying"));
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Deny(5, null);
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deny_Success_ReturnsDeniedResult()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
var denied = new UpliftDenyResultDTO { Id = 5, Status = "Denied" };
|
||||
service.Setup(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, "too high", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(denied);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Deny(5, new UpliftController.DecisionRequest { Note = "too high" });
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
service.Verify(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, "too high", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanApprove_DelegatesToService()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
service.Setup(x => x.CanApprove(It.IsAny<ClaimsPrincipal>(), 2))
|
||||
.Returns(true);
|
||||
|
||||
var controller = NewController(service, "Manager");
|
||||
|
||||
var result = controller.CanApprove(2);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
service.Verify(x => x.CanApprove(It.Is<ClaimsPrincipal>(p => p.IsInRole("Manager")), 2), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListForDispatch_ReturnsItems()
|
||||
{
|
||||
var service = new Mock<IUpliftService>();
|
||||
var items = new[] { new UpliftForDispatchDTO { Id = 1, DispatchId = 10 } };
|
||||
service.Setup(x => x.ListForDispatchAsync(It.IsAny<ClaimsPrincipal>(), 10, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.ListForDispatch(10);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
envelope.Status.Should().Be("Success");
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,10 @@ using Data.SeaHavenIndustries;
|
|||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
|
@ -18,12 +16,7 @@ public class VendorControllerTests
|
|||
{
|
||||
private static VendorController NewController(Mock<IVendorService> service)
|
||||
{
|
||||
var controller = new VendorController(
|
||||
service.Object,
|
||||
db: null!,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
var controller = new VendorController(service.Object);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
|
|
@ -108,62 +101,178 @@ public class VendorControllerTests
|
|||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "Active Co", "Inactive Co")]
|
||||
[InlineData(false, "Inactive Co", "Active Co")]
|
||||
public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors(
|
||||
bool isActive,
|
||||
string expected,
|
||||
string excluded)
|
||||
[Fact]
|
||||
public async Task GetFacets_DelegatesToServiceAndReturnsOk()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new Mock<IVendorService>();
|
||||
var facets = new VendorFacetsDTO
|
||||
{
|
||||
Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } },
|
||||
Trades = new[] { "Plumbing" },
|
||||
Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } },
|
||||
JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } }
|
||||
};
|
||||
service.Setup(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(facets);
|
||||
|
||||
var controller = new VendorController(
|
||||
Mock.Of<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.GetFacets(isActive);
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
var result = await controller.GetFacets(true);
|
||||
|
||||
payload.Should().Contain(expected);
|
||||
payload.Should().NotContain(excluded);
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
ok.Value.Should().Be(facets);
|
||||
service.Verify(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup()
|
||||
public async Task GetDropdown_DelegatesToServiceAndReturnsOk()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new Mock<IVendorService>();
|
||||
var items = new[]
|
||||
{
|
||||
new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 }
|
||||
};
|
||||
service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items);
|
||||
|
||||
var controller = new VendorController(
|
||||
Mock.Of<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.GetFacets();
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
var result = await controller.GetDropdown("Plumbing", "60601");
|
||||
|
||||
payload.Should().Contain("Active Co");
|
||||
payload.Should().Contain("Inactive Co");
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
ok.Value.Should().Be(items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPortalToken_ReturnsTokenDtoFromService()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" };
|
||||
service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(token);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.GetPortalToken(5);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
ok.Value.Should().Be(token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((VendorPortalTokenDTO?)null);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.GetPortalToken(99);
|
||||
|
||||
result.Should().BeOfType<NotFoundResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotatePortalToken_ReturnsRotatedToken()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
var token = new VendorPortalTokenDTO { Token = "new-token" };
|
||||
service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(token);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.RotatePortalToken(5);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
ok.Value.Should().Be(token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.RevokePortalToken(5);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
ok.Value.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.RevokePortalToken(99);
|
||||
|
||||
result.Should().BeOfType<NotFoundResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_MapsDeactivationBlocked_ToConflict()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List<LinkedWorkOrderDTO>()));
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Delete(5);
|
||||
|
||||
result.Should().BeOfType<ConflictObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new InvalidOperationException());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Delete(5);
|
||||
|
||||
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||
nf.Value.Should().BeOfType<Response>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.GetVendorByIdAsync(99))
|
||||
.ReturnsAsync((VendorDTO?)null);
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.GetVendorById(99, null);
|
||||
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_UnauthenticatedUser_ReturnsUnauthorized()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
var controller = new VendorController(service.Object);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() }
|
||||
};
|
||||
|
||||
var result = await controller.Update(5, new EditVendor_DTO { Id = 5, CompanyName = "X" });
|
||||
|
||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
302
Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs
Normal file
302
Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorPortalControllerTests
|
||||
{
|
||||
private static VendorPortalController NewController(Mock<IVendorPortalService> service, string? token = "valid-token")
|
||||
{
|
||||
var controller = new VendorPortalController(service.Object);
|
||||
var context = new DefaultHttpContext();
|
||||
if (token != null)
|
||||
context.Request.Headers["X-Vendor-Token"] = token;
|
||||
controller.ControllerContext = new ControllerContext { HttpContext = context };
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static readonly VendorPortalSession Session = new()
|
||||
{
|
||||
Id = 1, CompanyName = "Test Co", ContactName = "Sam", Email = "sam@test.co", Phone = "555-0100"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Session_MissingToken_ReturnsUnauthorized()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
var controller = NewController(service, token: null);
|
||||
|
||||
var result = await controller.Session();
|
||||
|
||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_InvalidToken_ReturnsUnauthorized()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((VendorPortalSession?)null);
|
||||
var controller = NewController(service, token: "bad");
|
||||
|
||||
var result = await controller.Session();
|
||||
|
||||
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_ValidToken_ReturnsSessionData()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Session();
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
envelope.Status.Should().Be("Success");
|
||||
((VendorPortalSession)envelope.Data).Should().Be(Session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDispatches_DelegatesToServiceWithStatus()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } };
|
||||
service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(dispatches);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.ListDispatches("Sent");
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDispatch_NotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((VendorDispatchDetailDTO?)null);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.GetDispatch(99);
|
||||
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_KeyNotFound_Returns404()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new KeyNotFoundException());
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Accept(99);
|
||||
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_InvalidOperation_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Cannot accept"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Accept(10);
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var resp = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
resp.Message.Should().Be("Cannot accept");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_Success_ReturnsAcknowledgedResult()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" };
|
||||
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(acceptResult);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.Accept(10);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
((AcceptDispatchResultDTO)envelope.Data).Should().Be(acceptResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeStatus_ForbiddenTransition_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Transition not allowed"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.ChangeStatus(10, new VendorPortalController.ChangeStatusRequest { Status = "Completed" });
|
||||
|
||||
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var resp = bad.Value.Should().BeOfType<Response>().Subject;
|
||||
resp.Message.Should().Contain("Transition");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestCancel_Success_ReturnsSuccessWithMessage()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" });
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
envelope.Status.Should().Be("Success");
|
||||
envelope.Message.Should().Be("Cancel request sent to dispatcher");
|
||||
service.Verify(x => x.RequestCancelAsync(Session, 10, "Parts delay", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChecklistItem_LockedDispatch_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Dispatch is locked"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.UpdateChecklistItem(10, 5, new VendorPortalController.ChecklistUpdateRequest { IsCompleted = true });
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSignoff_DuplicateSignoff_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("A vendor signoff already exists"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest
|
||||
{
|
||||
SignoffType = "vendor", Name = "Name", Signature = "sig", SignatureMethod = "drawn"
|
||||
});
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_NTEBelowCurrent_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m });
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUplift_Success_ReturnsResult()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 };
|
||||
service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(upliftResult);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
|
||||
{
|
||||
RequestedNTE = 500m, Reason = "Need more parts"
|
||||
});
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
((UpliftRequestResultDTO)envelope.Data).Should().Be(upliftResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelUpliftRequest_NonPending_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.CancelUpliftRequest(10, 77);
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" };
|
||||
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(cancelResult);
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.CancelUpliftRequest(10, 77);
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||
((CancelUpliftResultDTO)envelope.Data).Should().Be(cancelResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComment_EmptyText_Returns400()
|
||||
{
|
||||
var service = new Mock<IVendorPortalService>();
|
||||
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Session);
|
||||
service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Comment cannot be empty"));
|
||||
var controller = NewController(service);
|
||||
|
||||
var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" });
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ using Data.SeaHavenIndustries;
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -14,7 +16,14 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
public class VendorServiceTests
|
||||
{
|
||||
private static VendorService NewService(Mock<IVendorDataService> data) =>
|
||||
new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation());
|
||||
new(
|
||||
data.Object,
|
||||
Mock.Of<IVendorPortalTokenService>(),
|
||||
Mock.Of<IZipCodeDistance>(),
|
||||
new ConfigurationBuilder().Build(),
|
||||
new CreateVendorValidation(),
|
||||
new UpdateVendorValidation(),
|
||||
new WorkOrderVendorUpdateValidation());
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_PersistsCompleteProfile()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
|
|
@ -12,225 +12,75 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[Route("api/uplifts")]
|
||||
public class UpliftController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IUpliftService _upliftService;
|
||||
|
||||
public UpliftController(ApplicationDbContext db, IConfiguration config)
|
||||
public UpliftController(IUpliftService upliftService)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_upliftService = upliftService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25)
|
||||
public async Task<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = from u in _db.DispatchUpliftRequests
|
||||
join d in _db.Dispatches on u.DispatchId equals d.Id
|
||||
join v in _db.Vendors on d.VendorId equals v.Id into vendors
|
||||
from v in vendors.DefaultIfEmpty()
|
||||
where (u.IsDeleted == null || u.IsDeleted == false)
|
||||
select new { u, d, v };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(x => x.u.Status == status);
|
||||
if (tier.HasValue)
|
||||
query = query.Where(x => x.u.RequiredTier == tier.Value);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
var raw = await query
|
||||
.OrderByDescending(x => x.u.CreatedDate)
|
||||
.Skip((Math.Max(page, 1) - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.u.Id,
|
||||
DispatchId = x.d.Id,
|
||||
x.d.DispatchNumber,
|
||||
x.d.PONumber,
|
||||
WorkOrderId = x.d.WorkOrderId,
|
||||
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
|
||||
x.u.CurrentNTE,
|
||||
x.u.RequestedNTE,
|
||||
x.u.VendorReason,
|
||||
x.u.RequiredTier,
|
||||
x.u.Status,
|
||||
RequestedAt = x.u.CreatedDate,
|
||||
x.u.DecidedAt,
|
||||
x.u.DecisionNote
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var items = raw.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.DispatchId,
|
||||
r.DispatchNumber,
|
||||
r.PONumber,
|
||||
r.WorkOrderId,
|
||||
r.VendorCompanyName,
|
||||
r.CurrentNTE,
|
||||
r.RequestedNTE,
|
||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
||||
r.VendorReason,
|
||||
r.RequiredTier,
|
||||
r.Status,
|
||||
r.RequestedAt,
|
||||
r.DecidedAt,
|
||||
r.DecisionNote,
|
||||
CanDecide = UserCanApprove(r.RequiredTier)
|
||||
}).ToList();
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new { total, page, pageSize, items }
|
||||
});
|
||||
var result = await _upliftService.ListAsync(User, status, tier, page, pageSize, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
[HttpGet("dispatch/{dispatchId:int}")]
|
||||
public async Task<IActionResult> ListForDispatch(int dispatchId)
|
||||
public async Task<IActionResult> ListForDispatch(int dispatchId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await (from u in _db.DispatchUpliftRequests
|
||||
where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
from dec in decs.DefaultIfEmpty()
|
||||
orderby u.CreatedDate descending
|
||||
select new
|
||||
{
|
||||
u.Id,
|
||||
u.DispatchId,
|
||||
u.CurrentNTE,
|
||||
u.RequestedNTE,
|
||||
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
|
||||
u.VendorReason,
|
||||
u.Status,
|
||||
u.RequiredTier,
|
||||
u.RequestedByVendorName,
|
||||
RequestedAt = u.CreatedDate,
|
||||
u.DecidedAt,
|
||||
u.DecisionNote,
|
||||
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
||||
DecidedByLastName = dec != null ? dec.LastName : null
|
||||
}).ToListAsync();
|
||||
|
||||
var items = rows.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.DispatchId,
|
||||
r.CurrentNTE,
|
||||
r.RequestedNTE,
|
||||
r.Delta,
|
||||
r.VendorReason,
|
||||
r.Status,
|
||||
r.RequiredTier,
|
||||
r.RequestedByVendorName,
|
||||
r.RequestedAt,
|
||||
r.DecidedAt,
|
||||
r.DecisionNote,
|
||||
DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
|
||||
CanDecide = UserCanApprove(r.RequiredTier)
|
||||
}).ToList();
|
||||
|
||||
var items = await _upliftService.ListForDispatchAsync(User, dispatchId, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = items });
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/approve")]
|
||||
public async Task<IActionResult> Approve(int id, [FromBody] DecisionRequest? body)
|
||||
public async Task<IActionResult> Approve(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id);
|
||||
if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" });
|
||||
if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot approve a '{req.Status}' request" });
|
||||
if (!UserCanApprove(req.RequiredTier))
|
||||
return StatusCode(403, new Response { Status = "Error", Message = $"Approval requires a Tier {req.RequiredTier} role" });
|
||||
|
||||
var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Cannot approve uplift on a '{dispatch.Status}' dispatch" });
|
||||
|
||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var oldNTE = dispatch.NTEAmount ?? 0m;
|
||||
|
||||
dispatch.NTEAmount = req.RequestedNTE;
|
||||
dispatch.LastModificationTime = now;
|
||||
|
||||
req.Status = "Approved";
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = string.IsNullOrWhiteSpace(body?.Note) ? null : body!.Note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
try
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
||||
OldValue = $"${oldNTE:F2}",
|
||||
NewValue = $"${req.RequestedNTE:F2}",
|
||||
Action = "uplift_approved",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, dispatch.NTEAmount } });
|
||||
var result = await _upliftService.ApproveAsync(User, id, body?.Note, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(403, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/deny")]
|
||||
public async Task<IActionResult> Deny(int id, [FromBody] DecisionRequest body)
|
||||
public async Task<IActionResult> Deny(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Note))
|
||||
return BadRequest(new Response { Status = "Error", Message = "A note is required when denying" });
|
||||
|
||||
var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id);
|
||||
if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" });
|
||||
if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot deny a '{req.Status}' request" });
|
||||
if (!UserCanApprove(req.RequiredTier))
|
||||
return StatusCode(403, new Response { Status = "Error", Message = $"Decision requires a Tier {req.RequiredTier} role" });
|
||||
|
||||
var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId);
|
||||
|
||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
req.Status = "Denied";
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = body.Note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
try
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Denied",
|
||||
Action = "uplift_denied",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
||||
var result = await _upliftService.DenyAsync(User, id, body?.Note, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (UpliftForbiddenException ex)
|
||||
{
|
||||
return StatusCode(403, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("can-approve")]
|
||||
public IActionResult CanApprove([FromQuery] int tier)
|
||||
{
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = UserCanApprove(tier) } });
|
||||
}
|
||||
|
||||
private bool UserCanApprove(int requiredTier)
|
||||
{
|
||||
var key = $"Approvals:Tier{requiredTier}Roles";
|
||||
var roles = _config.GetSection(key).Get<string[]>() ?? Array.Empty<string>();
|
||||
foreach (var r in roles)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(r) && User.IsInRole(r)) return true;
|
||||
}
|
||||
return false;
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } });
|
||||
}
|
||||
|
||||
public class DecisionRequest
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ using Data.SeaHavenIndustries;
|
|||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using System.Security.Claims;
|
||||
|
|
@ -17,18 +16,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
public class VendorController : Controller
|
||||
{
|
||||
private readonly IVendorService _vendorService;
|
||||
private readonly ApplicationDbContext _db; // Keep for complex queries like nearby vendors
|
||||
private readonly Helper.ZipCodeDistance _zipDistance;
|
||||
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config)
|
||||
public VendorController(IVendorService vendorService)
|
||||
{
|
||||
_vendorService = vendorService;
|
||||
_db = db;
|
||||
_zipDistance = zipDistance;
|
||||
_vendorTokens = vendorTokens;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpGet("GetVendorList")]
|
||||
|
|
@ -318,158 +309,41 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("Dropdown")]
|
||||
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null)
|
||||
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.Vendors.Where(v => v.IsActive);
|
||||
|
||||
var vendors = await query.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new
|
||||
{
|
||||
v.Id,
|
||||
v.CompanyName,
|
||||
v.ContactName,
|
||||
v.PreferredContact,
|
||||
v.TradeSpecialties,
|
||||
v.Address,
|
||||
v.City,
|
||||
v.State,
|
||||
v.Zip
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var result = vendors.Select(v =>
|
||||
{
|
||||
var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip);
|
||||
var addr = new[] { v.Address, v.City, v.State, v.Zip }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
return new
|
||||
{
|
||||
v.Id,
|
||||
v.CompanyName,
|
||||
v.ContactName,
|
||||
v.PreferredContact,
|
||||
v.TradeSpecialties,
|
||||
address = string.Join(", ", addr),
|
||||
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trade))
|
||||
{
|
||||
var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList();
|
||||
var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList();
|
||||
result = tradeMatched.Concat(rest).ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(siteZip))
|
||||
{
|
||||
result = result.OrderBy(v => v.distanceMiles ?? 99999).ToList();
|
||||
}
|
||||
|
||||
var result = await _vendorService.GetDropdownAsync(trade, siteZip, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("facets")]
|
||||
public async Task<IActionResult> GetFacets([FromQuery] bool? isActive = null)
|
||||
public async Task<IActionResult> GetFacets([FromQuery] bool? isActive = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
var vendors = await query
|
||||
.OrderByDescending(v => v.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var companies = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
|
||||
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new
|
||||
{
|
||||
Name = v.CompanyName,
|
||||
v.CompanyId,
|
||||
v.CompanyPhone,
|
||||
v.Email,
|
||||
v.PreferredContact,
|
||||
v.Address,
|
||||
v.City,
|
||||
v.State,
|
||||
Zip = v.Zip,
|
||||
v.GoogleMapsUrl
|
||||
});
|
||||
|
||||
var trades = vendors
|
||||
.SelectMany(v => (v.TradeSpecialties ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value);
|
||||
|
||||
var locations = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
|
||||
.Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) })
|
||||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(location => location.Label);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Companies = companies,
|
||||
Trades = trades,
|
||||
Locations = locations,
|
||||
JobBuckets = new[]
|
||||
{
|
||||
new { Id = "under-50", Label = "Under 50" },
|
||||
new { Id = "50-99", Label = "50–99" },
|
||||
new { Id = "100-149", Label = "100–149" },
|
||||
new { Id = "150-plus", Label = "150+" }
|
||||
}
|
||||
});
|
||||
var result = await _vendorService.GetFacetsAsync(isActive, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/portal-token")]
|
||||
public async Task<IActionResult> GetPortalToken(int id)
|
||||
public async Task<IActionResult> GetPortalToken(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await _db.Vendors.FindAsync(id);
|
||||
if (vendor == null) return NotFound();
|
||||
|
||||
var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id);
|
||||
return Ok(BuildPortalTokenResponse(token));
|
||||
var result = await _vendorService.GetPortalTokenAsync(id, cancellationToken);
|
||||
if (result == null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/portal-token/rotate")]
|
||||
public async Task<IActionResult> RotatePortalToken(int id)
|
||||
public async Task<IActionResult> RotatePortalToken(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await _db.Vendors.FindAsync(id);
|
||||
if (vendor == null) return NotFound();
|
||||
|
||||
var token = await _vendorTokens.RotateAsync(id);
|
||||
return Ok(BuildPortalTokenResponse(token));
|
||||
var result = await _vendorService.RotatePortalTokenAsync(id, cancellationToken);
|
||||
if (result == null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/portal-token/revoke")]
|
||||
public async Task<IActionResult> RevokePortalToken(int id)
|
||||
public async Task<IActionResult> RevokePortalToken(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await _db.Vendors.FindAsync(id);
|
||||
if (vendor == null) return NotFound();
|
||||
|
||||
await _vendorTokens.RevokeAllAsync(id);
|
||||
var success = await _vendorService.RevokePortalTokenAsync(id, cancellationToken);
|
||||
if (!success) return NotFound();
|
||||
return Ok(new { revoked = true });
|
||||
}
|
||||
|
||||
private object BuildPortalTokenResponse(VendorAccessToken token)
|
||||
{
|
||||
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
|
||||
return new
|
||||
{
|
||||
token.Token,
|
||||
token.IssuedAt,
|
||||
token.ExpiresAt,
|
||||
token.LastUsedAt,
|
||||
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
|
|
@ -13,650 +12,221 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
private const string TokenHeader = "X-Vendor-Token";
|
||||
|
||||
private static readonly string[] VendorViewableStatuses =
|
||||
new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" };
|
||||
private readonly IVendorPortalService _portalService;
|
||||
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly VendorPortalTokenService _tokens;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly SendMessage _sendMessage;
|
||||
|
||||
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
|
||||
public VendorPortalController(IVendorPortalService portalService)
|
||||
{
|
||||
_db = db;
|
||||
_tokens = tokens;
|
||||
_config = config;
|
||||
_sendMessage = sendMessage;
|
||||
_portalService = portalService;
|
||||
}
|
||||
|
||||
[HttpGet("session")]
|
||||
public async Task<IActionResult> Session()
|
||||
public async Task<IActionResult> Session(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new
|
||||
{
|
||||
vendor.Id,
|
||||
vendor.CompanyName,
|
||||
vendor.ContactName,
|
||||
vendor.Email,
|
||||
vendor.Phone
|
||||
}
|
||||
Data = session
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("dispatches")]
|
||||
public async Task<IActionResult> ListDispatches([FromQuery] string? status = null)
|
||||
public async Task<IActionResult> ListDispatches([FromQuery] string? status = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var query = _db.Dispatches
|
||||
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
||||
.Where(d => d.VendorId == vendor.Id && (d.IsDeleted == null || d.IsDeleted == false));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
query = query.Where(d => d.Status == status);
|
||||
}
|
||||
|
||||
var dispatches = await query
|
||||
.OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate)
|
||||
.Select(d => new
|
||||
{
|
||||
d.Id,
|
||||
d.DispatchNumber,
|
||||
d.PONumber,
|
||||
d.Status,
|
||||
d.NTEAmount,
|
||||
d.ScheduledDate,
|
||||
d.CompletedDate,
|
||||
d.DispatchedAt,
|
||||
d.AcknowledgedAt,
|
||||
WorkOrderTitle = d.WorkOrder!.WorkerOrderTitle,
|
||||
InternalWONumber = d.WorkOrder!.InternalWONumber,
|
||||
LocationName = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.Name : null,
|
||||
LocationCity = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.City : null,
|
||||
LocationState = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.State : null
|
||||
})
|
||||
.ToListAsync();
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatches = await _portalService.ListDispatchesAsync(session, status, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = dispatches });
|
||||
}
|
||||
|
||||
[HttpGet("dispatches/{id:int}")]
|
||||
public async Task<IActionResult> GetDispatch(int id)
|
||||
public async Task<IActionResult> GetDispatch(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
var detail = await _portalService.GetDispatchDetailAsync(session, id, cancellationToken);
|
||||
if (detail == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
var checklist = await _db.DispatchChecklistItems
|
||||
.Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false))
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var signoffs = await _db.DispatchSignoffs
|
||||
.Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false))
|
||||
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var rawUplifts = await (from u in _db.DispatchUpliftRequests
|
||||
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
from dec in decs.DefaultIfEmpty()
|
||||
orderby u.CreatedDate descending
|
||||
select new
|
||||
{
|
||||
u.Id,
|
||||
u.CurrentNTE,
|
||||
u.RequestedNTE,
|
||||
u.VendorReason,
|
||||
u.Status,
|
||||
u.RequiredTier,
|
||||
u.RequestedByVendorName,
|
||||
u.CreatedDate,
|
||||
u.DecidedAt,
|
||||
u.DecisionNote,
|
||||
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
||||
DecidedByLastName = dec != null ? dec.LastName : null
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var upliftRequests = rawUplifts.Select(u => new
|
||||
{
|
||||
u.Id,
|
||||
u.CurrentNTE,
|
||||
u.RequestedNTE,
|
||||
u.VendorReason,
|
||||
u.Status,
|
||||
u.RequiredTier,
|
||||
u.RequestedByVendorName,
|
||||
RequestedAt = u.CreatedDate,
|
||||
u.DecidedAt,
|
||||
u.DecisionNote,
|
||||
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
||||
}).ToList();
|
||||
|
||||
var rawComments = await (from c in _db.Comments
|
||||
where c.DispatchId == id
|
||||
&& c.CommentType != "internal"
|
||||
&& (c.IsDeleted == null || c.IsDeleted == false)
|
||||
join u in _db.Users on c.UserId equals u.Id into users
|
||||
from u in users.DefaultIfEmpty()
|
||||
orderby c.CreatedDate
|
||||
select new
|
||||
{
|
||||
c.Id,
|
||||
c.Commenttext,
|
||||
c.Commenter,
|
||||
c.CommentType,
|
||||
c.CreatedDate,
|
||||
c.UserId,
|
||||
UserFirstName = u != null ? u.FirstName : null,
|
||||
UserLastName = u != null ? u.LastName : null
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var comments = rawComments.Select(c =>
|
||||
{
|
||||
var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId);
|
||||
var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
|
||||
|
||||
string resolvedType;
|
||||
string resolvedCommenter;
|
||||
if (hasShocUser)
|
||||
{
|
||||
resolvedType = "dispatcher";
|
||||
resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName
|
||||
: !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter!
|
||||
: "Dispatcher";
|
||||
}
|
||||
else if (c.CommentType == "customer")
|
||||
{
|
||||
resolvedType = "customer";
|
||||
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer";
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedType = "vendor";
|
||||
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : vendor.CompanyName ?? "Vendor";
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
c.Id,
|
||||
c.Commenttext,
|
||||
Commenter = resolvedCommenter,
|
||||
CommentType = resolvedType,
|
||||
c.CreatedDate
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.DispatchNumber,
|
||||
dispatch.PONumber,
|
||||
dispatch.Status,
|
||||
dispatch.NTEAmount,
|
||||
dispatch.Description,
|
||||
dispatch.ScheduledDate,
|
||||
dispatch.CompletedDate,
|
||||
dispatch.DispatchedAt,
|
||||
dispatch.AcknowledgedAt,
|
||||
WorkOrder = dispatch.WorkOrder == null ? null : new
|
||||
{
|
||||
dispatch.WorkOrder.Id,
|
||||
dispatch.WorkOrder.InternalWONumber,
|
||||
dispatch.WorkOrder.WorkerOrderTitle,
|
||||
dispatch.WorkOrder.Description,
|
||||
dispatch.WorkOrder.Priority,
|
||||
dispatch.WorkOrder.DueDate,
|
||||
dispatch.WorkOrder.Trade,
|
||||
dispatch.WorkOrder.SubTrade,
|
||||
dispatch.WorkOrder.Problem
|
||||
},
|
||||
Location = dispatch.WorkOrder?.Locations == null ? null : new
|
||||
{
|
||||
dispatch.WorkOrder.Locations.Name,
|
||||
dispatch.WorkOrder.Locations.Address1,
|
||||
dispatch.WorkOrder.Locations.City,
|
||||
dispatch.WorkOrder.Locations.State,
|
||||
dispatch.WorkOrder.Locations.Zip
|
||||
},
|
||||
Checklist = checklist,
|
||||
Signoffs = signoffs,
|
||||
Comments = comments,
|
||||
UpliftRequests = upliftRequests
|
||||
}
|
||||
});
|
||||
return Ok(new DataResponse { Status = "Success", Data = detail });
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/accept")]
|
||||
public async Task<IActionResult> Accept(int id)
|
||||
public async Task<IActionResult> Accept(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
if (dispatch.Status != "Sent")
|
||||
try
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" });
|
||||
var result = await _portalService.AcceptDispatchAsync(session, id, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
dispatch.Status = "Acknowledged";
|
||||
dispatch.AcknowledgedAt = now;
|
||||
dispatch.LastModificationTime = now;
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
||||
OldValue = "Sent",
|
||||
NewValue = "Acknowledged",
|
||||
Action = "vendor_accept",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } });
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/status")]
|
||||
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body)
|
||||
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
var from = dispatch.Status;
|
||||
var to = body.Status;
|
||||
if (!IsAllowedVendorTransition(from, to))
|
||||
try
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" });
|
||||
var result = await _portalService.ChangeStatusAsync(session, id, body?.Status, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
dispatch.Status = to;
|
||||
dispatch.LastModificationTime = now;
|
||||
if (to == "Completed") dispatch.CompletedDate = now;
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
||||
OldValue = from,
|
||||
NewValue = to,
|
||||
Action = "vendor_status_change",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } });
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/request-cancel")]
|
||||
public async Task<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body)
|
||||
public async Task<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
try
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" });
|
||||
await _portalService.RequestCancelAsync(session, id, body?.Reason, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim();
|
||||
|
||||
_db.Comments.Add(new Comments
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
DispatchId = id,
|
||||
WorkerOrderId = dispatch.WorkOrderId,
|
||||
Commenter = vendor.CompanyName,
|
||||
CommentType = "vendor",
|
||||
RecordType = "cancel_request",
|
||||
Commenttext = $"Vendor requested cancellation: {reason}",
|
||||
CreatedDate = now
|
||||
});
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber}",
|
||||
OldValue = dispatch.Status,
|
||||
NewValue = "Cancel Requested",
|
||||
Action = "vendor_request_cancel",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" });
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/checklist/{itemId:int}")]
|
||||
public async Task<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body)
|
||||
public async Task<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
try
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" });
|
||||
var result = await _portalService.UpdateChecklistItemAsync(session, id, itemId, body.IsCompleted, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
var item = await _db.DispatchChecklistItems
|
||||
.FirstOrDefaultAsync(c => c.Id == itemId && c.DispatchId == id);
|
||||
if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
item.IsCompleted = body.IsCompleted;
|
||||
item.CompletedBy = body.IsCompleted ? vendor.CompanyName : null;
|
||||
item.CompletedAt = body.IsCompleted ? now : null;
|
||||
item.LastModificationTime = now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }
|
||||
});
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/signoff")]
|
||||
public async Task<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body)
|
||||
public async Task<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
if (dispatch.Status != "In Progress" && dispatch.Status != "Completed")
|
||||
try
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" });
|
||||
var result = await _portalService.AddSignoffAsync(session, id, body?.SignoffType, body?.Name, body?.Signature, body?.SignatureMethod, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature))
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" });
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
|
||||
var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant();
|
||||
if (signoffType != "vendor" && signoffType != "customer")
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" });
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
|
||||
var existing = await _db.DispatchSignoffs
|
||||
.FirstOrDefaultAsync(s => s.DispatchId == id && s.SignoffType == signoffType);
|
||||
if (existing != null)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = $"A {signoffType} signoff already exists for this dispatch" });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var signoff = new DispatchSignoff
|
||||
{
|
||||
DispatchId = id,
|
||||
SignoffType = signoffType,
|
||||
Name = body!.Name,
|
||||
Signature = body.Signature,
|
||||
SignatureMethod = body.SignatureMethod ?? "drawn",
|
||||
SignedAt = now,
|
||||
CreatedDate = now
|
||||
};
|
||||
_db.DispatchSignoffs.Add(signoff);
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff",
|
||||
OldValue = null,
|
||||
NewValue = $"{(signoffType == "customer" ? "Customer" : "Vendor")}: {body.Name}",
|
||||
Action = signoffType == "customer" ? "customer_signoff" : "vendor_signoff",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { signoff.Id, signoff.SignedAt } });
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/comments")]
|
||||
public async Task<IActionResult> AddComment(int id, [FromBody] CommentRequest body)
|
||||
public async Task<IActionResult> AddComment(int id, [FromBody] CommentRequest body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body?.CommentText))
|
||||
try
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" });
|
||||
var result = await _portalService.AddCommentAsync(session, id, body?.CommentText, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var comment = new Comments
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
DispatchId = id,
|
||||
WorkerOrderId = dispatch.WorkOrderId,
|
||||
Commenter = vendor.CompanyName,
|
||||
CommentType = "vendor",
|
||||
RecordType = "comment",
|
||||
Commenttext = body!.CommentText,
|
||||
CreatedDate = now
|
||||
};
|
||||
_db.Comments.Add(comment);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new DataResponse
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate }
|
||||
});
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/uplift-request")]
|
||||
public async Task<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body)
|
||||
public async Task<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
try
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" });
|
||||
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
|
||||
if (body == null || body.RequestedNTE <= 0)
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" });
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
|
||||
var current = dispatch.NTEAmount ?? 0m;
|
||||
if (body.RequestedNTE <= current)
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" });
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
|
||||
var pendingExists = await _db.DispatchUpliftRequests
|
||||
.AnyAsync(u => u.DispatchId == id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false));
|
||||
if (pendingExists)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = "A pending uplift request already exists for this dispatch" });
|
||||
}
|
||||
|
||||
var tier1Max = _config.GetValue<decimal?>("Approvals:UpliftTier1MaxUsd") ?? 2500m;
|
||||
var delta = body.RequestedNTE - current;
|
||||
var requiredTier = delta > tier1Max ? 2 : 1;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var req = new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = id,
|
||||
CurrentNTE = current,
|
||||
RequestedNTE = body.RequestedNTE,
|
||||
VendorReason = string.IsNullOrWhiteSpace(body.Reason) ? null : body.Reason!.Trim(),
|
||||
Status = "Pending",
|
||||
RequiredTier = requiredTier,
|
||||
RequestedByVendorName = vendor.CompanyName,
|
||||
CreatedDate = now
|
||||
};
|
||||
_db.DispatchUpliftRequests.Add(req);
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = $"${current:F2}",
|
||||
NewValue = $"${body.RequestedNTE:F2}",
|
||||
Action = "uplift_requested",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await NotifyDispatcherOfUpliftAsync(dispatch, req, vendor);
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new { req.Id, req.Status, req.RequiredTier, req.CurrentNTE, req.RequestedNTE }
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/cancel")]
|
||||
public async Task<IActionResult> CancelUpliftRequest(int id, int requestId)
|
||||
public async Task<IActionResult> CancelUpliftRequest(int id, int requestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
var req = await _db.DispatchUpliftRequests
|
||||
.FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == id);
|
||||
if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" });
|
||||
|
||||
if (req.Status != "Pending")
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Cannot cancel a '{req.Status}' uplift request" });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
req.Status = "Cancelled";
|
||||
req.DecidedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Cancelled",
|
||||
Action = "uplift_cancelled",
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
||||
}
|
||||
|
||||
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dispatchAudit = await _db.WorkOrderAuditLogs
|
||||
.Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId))
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (dispatchAudit == null || string.IsNullOrWhiteSpace(dispatchAudit.UserId)) return;
|
||||
|
||||
var dispatcher = await _db.Users.FindAsync(dispatchAudit.UserId);
|
||||
if (dispatcher == null || string.IsNullOrWhiteSpace(dispatcher.Email)) return;
|
||||
|
||||
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
|
||||
var workOrderLink = dispatch.WorkOrderId.HasValue
|
||||
? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}"
|
||||
: frontendBase;
|
||||
|
||||
var vendorName = System.Net.WebUtility.HtmlEncode(vendor.CompanyName ?? "Vendor");
|
||||
var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)");
|
||||
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
|
||||
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
|
||||
|
||||
var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendor.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})";
|
||||
var body = $@"
|
||||
<h2>Vendor Uplift Request</h2>
|
||||
<p><strong>Dispatch:</strong> {dispatchNum}</p>
|
||||
<p><strong>Vendor:</strong> {vendorName}</p>
|
||||
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
||||
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
||||
</table>
|
||||
<h3>Vendor Reason</h3>
|
||||
<p>{reason}</p>
|
||||
<div style='margin:20px 0;'>
|
||||
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
|
||||
</div>";
|
||||
|
||||
await _sendMessage.SendEMail(dispatcher.Email, subject, body);
|
||||
var result = await _portalService.CancelUpliftRequestAsync(session, id, requestId, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Vendor?> ResolveVendorAsync()
|
||||
private async Task<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||
var token = values.ToString();
|
||||
return await _tokens.ResolveVendorAsync(token);
|
||||
}
|
||||
|
||||
private Task<Dispatch?> LoadVendorDispatchAsync(int id, int vendorId)
|
||||
{
|
||||
return _db.Dispatches
|
||||
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
||||
.FirstOrDefaultAsync(d => d.Id == id
|
||||
&& d.VendorId == vendorId
|
||||
&& (d.IsDeleted == null || d.IsDeleted == false));
|
||||
}
|
||||
|
||||
private static bool IsAllowedVendorTransition(string? from, string? to)
|
||||
{
|
||||
if (from == "Acknowledged" && to == "In Progress") return true;
|
||||
if (from == "In Progress" && to == "Completed") return true;
|
||||
return false;
|
||||
return await _portalService.ResolveSessionAsync(token, cancellationToken);
|
||||
}
|
||||
|
||||
public class ChangeStatusRequest
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly IWebHostEnvironment _webHostEnvironment;
|
||||
IHttpContextAccessor _httpContext;
|
||||
private readonly Helper.SendMessage _sendMessage;
|
||||
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
||||
private readonly IVendorPortalTokenService _vendorTokens;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public WorkOrderController(
|
||||
|
|
@ -57,7 +57,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
IWebHostEnvironment webHostEnvironment,
|
||||
IHttpContextAccessor httpContext,
|
||||
Helper.SendMessage sendMessage,
|
||||
Helper.VendorPortalTokenService vendorTokens,
|
||||
IVendorPortalTokenService vendorTokens,
|
||||
IConfiguration config)
|
||||
{
|
||||
_userManager = userManager;
|
||||
|
|
@ -917,7 +917,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var descriptionHtml = System.Net.WebUtility.HtmlEncode(model.Description ?? primaryWO.Description ?? "");
|
||||
var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName);
|
||||
|
||||
var vendorToken = await _vendorTokens.GetOrCreateActiveTokenAsync(vendorId);
|
||||
var vendorToken = await _vendorTokens.GetOrCreateActiveTokenAsync(vendorId, HttpContext.RequestAborted);
|
||||
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
|
||||
var portalUrl = $"{frontendBase}/v/{vendorToken.Token}/dashboard";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
|
||||
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SendGrid;
|
||||
using SendGrid.Helpers.Mail;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public class SendMessage
|
||||
public class SendMessage : IEmailSender
|
||||
{
|
||||
private IConfiguration _configuration;
|
||||
//string keysapi = "";
|
||||
|
|
@ -58,6 +59,8 @@ namespace Api.SeaHavenIndustries.Helper
|
|||
return false;
|
||||
}
|
||||
}
|
||||
public async Task<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
|
||||
=> await SendEMail(emailTo, subject, htmlBody);
|
||||
public async Task<bool> SendEMailAttachment(string emailTo, string subject, byte[] pdfBytes)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
using System.Security.Cryptography;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public class VendorPortalTokenService
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly int _lifetimeDays;
|
||||
|
||||
public VendorPortalTokenService(ApplicationDbContext db, IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_lifetimeDays = config.GetValue<int?>("VendorPortal:TokenLifetimeDays") ?? 365;
|
||||
}
|
||||
|
||||
public async Task<VendorAccessToken> GetOrCreateActiveTokenAsync(int vendorId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = await _db.VendorAccessTokens
|
||||
.Where(t => t.VendorId == vendorId
|
||||
&& t.RevokedAt == null
|
||||
&& t.ExpiresAt > now
|
||||
&& (t.IsDeleted == null || t.IsDeleted == false))
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existing != null) return existing;
|
||||
|
||||
var token = new VendorAccessToken
|
||||
{
|
||||
VendorId = vendorId,
|
||||
Token = GenerateToken(),
|
||||
IssuedAt = now,
|
||||
ExpiresAt = now.AddDays(_lifetimeDays),
|
||||
CreatedDate = now
|
||||
};
|
||||
_db.VendorAccessTokens.Add(token);
|
||||
await _db.SaveChangesAsync();
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<Vendor?> ResolveVendorAsync(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||
var now = DateTime.UtcNow;
|
||||
var record = await _db.VendorAccessTokens
|
||||
.Include(t => t.Vendor)
|
||||
.FirstOrDefaultAsync(t => t.Token == token
|
||||
&& t.RevokedAt == null
|
||||
&& t.ExpiresAt > now
|
||||
&& (t.IsDeleted == null || t.IsDeleted == false));
|
||||
|
||||
if (record?.Vendor == null) return null;
|
||||
|
||||
record.LastUsedAt = now;
|
||||
await _db.SaveChangesAsync();
|
||||
return record.Vendor;
|
||||
}
|
||||
|
||||
public async Task<VendorAccessToken> RotateAsync(int vendorId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var active = await _db.VendorAccessTokens
|
||||
.Where(t => t.VendorId == vendorId && t.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
foreach (var t in active) t.RevokedAt = now;
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetOrCreateActiveTokenAsync(vendorId);
|
||||
}
|
||||
|
||||
public async Task RevokeAllAsync(int vendorId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var active = await _db.VendorAccessTokens
|
||||
.Where(t => t.VendorId == vendorId && t.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
foreach (var t in active) t.RevokedAt = now;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string GenerateToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.TrimEnd('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public class ZipCodeDistance
|
||||
public class ZipCodeDistance : IZipCodeDistance
|
||||
{
|
||||
private readonly Dictionary<string, (double lat, double lng)> _coords = new();
|
||||
private bool _loaded;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
|
|||
builder.Services.AddScoped<SendMessage>();
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
||||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
|
||||
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
||||
{
|
||||
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
||||
|
|
@ -49,6 +48,8 @@ builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp
|
|||
if (File.Exists(csvPath)) zipService.Load(csvPath);
|
||||
return zipService;
|
||||
});
|
||||
builder.Services.AddSingleton<SeaHaven.Services.Interfaces.IZipCodeDistance>(
|
||||
serviceProvider => serviceProvider.GetRequiredService<Api.SeaHavenIndustries.Helper.ZipCodeDistance>());
|
||||
|
||||
builder.Services.AddDataServices();
|
||||
builder.Services.AddBusinessServices();
|
||||
|
|
|
|||
101
Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs
Normal file
101
Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorDispatchSummaryData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? DispatchNumber { get; set; }
|
||||
public string? PONumber { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
public string? WorkOrderTitle { get; set; }
|
||||
public string? InternalWONumber { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? LocationCity { get; set; }
|
||||
public string? LocationState { get; set; }
|
||||
}
|
||||
|
||||
public class PortalChecklistItemData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? ItemText { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PortalSignoffData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? SignoffType { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? SignatureMethod { get; set; }
|
||||
public DateTime? SignedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PortalUpliftData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByFirstName { get; set; }
|
||||
public string? DecidedByLastName { get; set; }
|
||||
}
|
||||
|
||||
public class PortalCommentData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Commenttext { get; set; }
|
||||
public string? Commenter { get; set; }
|
||||
public string? CommentType { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? UserFirstName { get; set; }
|
||||
public string? UserLastName { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftListItemData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int DispatchId { get; set; }
|
||||
public string? DispatchNumber { get; set; }
|
||||
public string? PONumber { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? VendorCompanyName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftForDispatchData
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int DispatchId { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByFirstName { get; set; }
|
||||
public string? DecidedByLastName { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -71,5 +71,32 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
return await _context.Comments.AnyAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task StageAsync(Comments comment, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.Comments.AddAsync(comment, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortalCommentData>> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from c in _context.Comments
|
||||
where c.DispatchId == dispatchId
|
||||
&& c.CommentType != "internal"
|
||||
&& (c.IsDeleted == null || c.IsDeleted == false)
|
||||
join u in _context.Users on c.UserId equals u.Id into users
|
||||
from u in users.DefaultIfEmpty()
|
||||
orderby c.CreatedDate
|
||||
select new PortalCommentData
|
||||
{
|
||||
Id = c.Id,
|
||||
Commenttext = c.Commenttext,
|
||||
Commenter = c.Commenter,
|
||||
CommentType = c.CommentType,
|
||||
CreatedDate = c.CreatedDate,
|
||||
UserId = c.UserId,
|
||||
UserFirstName = u != null ? u.FirstName : null,
|
||||
UserLastName = u != null ? u.LastName : null
|
||||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -558,5 +558,150 @@ namespace SeaHaven.DataServices.Implementation
|
|||
AuditLog = auditLog
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StageSignoffAsync(
|
||||
DispatchSignoff signoff, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.DispatchSignoffs.AddAsync(signoff, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VendorDispatchSummaryData>> GetVendorDispatchSummariesAsync(
|
||||
int vendorId, string? status, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(dispatch => dispatch.VendorId == vendorId
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(dispatch => dispatch.Status == status);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(dispatch => dispatch.DispatchedAt ?? dispatch.CreatedDate)
|
||||
.Select(dispatch => new VendorDispatchSummaryData
|
||||
{
|
||||
Id = dispatch.Id,
|
||||
DispatchNumber = dispatch.DispatchNumber,
|
||||
PONumber = dispatch.PONumber,
|
||||
Status = dispatch.Status,
|
||||
NTEAmount = dispatch.NTEAmount,
|
||||
ScheduledDate = dispatch.ScheduledDate,
|
||||
CompletedDate = dispatch.CompletedDate,
|
||||
DispatchedAt = dispatch.DispatchedAt,
|
||||
AcknowledgedAt = dispatch.AcknowledgedAt,
|
||||
WorkOrderTitle = dispatch.WorkOrder != null
|
||||
? dispatch.WorkOrder.WorkerOrderTitle
|
||||
: null,
|
||||
InternalWONumber = dispatch.WorkOrder != null
|
||||
? dispatch.WorkOrder.InternalWONumber
|
||||
: null,
|
||||
LocationName = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null
|
||||
? dispatch.WorkOrder.Locations.Name
|
||||
: null,
|
||||
LocationCity = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null
|
||||
? dispatch.WorkOrder.Locations.City
|
||||
: null,
|
||||
LocationState = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null
|
||||
? dispatch.WorkOrder.Locations.State
|
||||
: null
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Dispatch?> GetVendorDispatchDetailAsync(
|
||||
int id, int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Dispatches
|
||||
.Include(dispatch => dispatch.WorkOrder)
|
||||
.ThenInclude(workOrder => workOrder!.Locations)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(dispatch => dispatch.Id == id
|
||||
&& dispatch.VendorId == vendorId
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Dispatch?> GetVendorDispatchForMutationAsync(
|
||||
int id, int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Dispatches
|
||||
.Include(dispatch => dispatch.WorkOrder)
|
||||
.ThenInclude(workOrder => workOrder!.Locations)
|
||||
.FirstOrDefaultAsync(dispatch => dispatch.Id == id
|
||||
&& dispatch.VendorId == vendorId
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortalChecklistItemData>> GetPortalChecklistAsync(
|
||||
int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchChecklistItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.DispatchId == dispatchId
|
||||
&& (item.IsDeleted == null || item.IsDeleted == false))
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(item => new PortalChecklistItemData
|
||||
{
|
||||
Id = item.Id,
|
||||
ItemText = item.ItemText,
|
||||
IsCompleted = item.IsCompleted,
|
||||
CompletedBy = item.CompletedBy,
|
||||
CompletedAt = item.CompletedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortalSignoffData>> GetPortalSignoffsAsync(
|
||||
int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchSignoffs
|
||||
.AsNoTracking()
|
||||
.Where(signoff => signoff.DispatchId == dispatchId
|
||||
&& (signoff.IsDeleted == null || signoff.IsDeleted == false))
|
||||
.Select(signoff => new PortalSignoffData
|
||||
{
|
||||
Id = signoff.Id,
|
||||
SignoffType = signoff.SignoffType,
|
||||
Name = signoff.Name,
|
||||
SignatureMethod = signoff.SignatureMethod,
|
||||
SignedAt = signoff.SignedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DispatchChecklistItem?> GetChecklistItemForDispatchAsync(
|
||||
int itemId, int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchChecklistItems
|
||||
.FirstOrDefaultAsync(
|
||||
item => item.Id == itemId && item.DispatchId == dispatchId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StageAuditLogAsync(
|
||||
WorkOrderAuditLog log, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.WorkOrderAuditLogs.AddAsync(log, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string?> GetDispatchDispatcherUserIdAsync(
|
||||
int workOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.WorkOrderAuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(audit => audit.WorkOrderId == workOrderId
|
||||
&& audit.Action == "dispatch"
|
||||
&& audit.UserId != null
|
||||
&& audit.UserId != "")
|
||||
.OrderByDescending(audit => audit.CreatedAt)
|
||||
.Select(audit => audit.UserId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
SeaHaven.DataServices/Implementation/UpliftDataService.cs
Normal file
138
SeaHaven.DataServices/Implementation/UpliftDataService.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class UpliftDataService : IUpliftDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public UpliftDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = from u in _context.DispatchUpliftRequests
|
||||
join d in _context.Dispatches on u.DispatchId equals d.Id
|
||||
join v in _context.Vendors on d.VendorId equals v.Id into vendors
|
||||
from v in vendors.DefaultIfEmpty()
|
||||
where (u.IsDeleted == null || u.IsDeleted == false)
|
||||
select new { u, d, v };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(x => x.u.Status == status);
|
||||
if (tier.HasValue)
|
||||
query = query.Where(x => x.u.RequiredTier == tier.Value);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.u.CreatedDate)
|
||||
.Skip((Math.Max(page, 1) - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new UpliftListItemData
|
||||
{
|
||||
Id = x.u.Id,
|
||||
DispatchId = x.d.Id,
|
||||
DispatchNumber = x.d.DispatchNumber,
|
||||
PONumber = x.d.PONumber,
|
||||
WorkOrderId = x.d.WorkOrderId,
|
||||
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
|
||||
CurrentNTE = x.u.CurrentNTE,
|
||||
RequestedNTE = x.u.RequestedNTE,
|
||||
VendorReason = x.u.VendorReason,
|
||||
RequiredTier = x.u.RequiredTier,
|
||||
Status = x.u.Status,
|
||||
CreatedDate = x.u.CreatedDate,
|
||||
DecidedAt = x.u.DecidedAt,
|
||||
DecisionNote = x.u.DecisionNote
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (total, items);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
from dec in decs.DefaultIfEmpty()
|
||||
orderby u.CreatedDate descending
|
||||
select new UpliftForDispatchData
|
||||
{
|
||||
Id = u.Id,
|
||||
DispatchId = u.DispatchId,
|
||||
CurrentNTE = u.CurrentNTE,
|
||||
RequestedNTE = u.RequestedNTE,
|
||||
VendorReason = u.VendorReason,
|
||||
Status = u.Status,
|
||||
RequiredTier = u.RequiredTier,
|
||||
RequestedByVendorName = u.RequestedByVendorName,
|
||||
CreatedDate = u.CreatedDate,
|
||||
DecidedAt = u.DecidedAt,
|
||||
DecisionNote = u.DecisionNote,
|
||||
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
||||
DecidedByLastName = dec != null ? dec.LastName : null
|
||||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await (from u in _context.DispatchUpliftRequests
|
||||
where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
from dec in decs.DefaultIfEmpty()
|
||||
orderby u.CreatedDate descending
|
||||
select new PortalUpliftData
|
||||
{
|
||||
Id = u.Id,
|
||||
CurrentNTE = u.CurrentNTE,
|
||||
RequestedNTE = u.RequestedNTE,
|
||||
VendorReason = u.VendorReason,
|
||||
Status = u.Status,
|
||||
RequiredTier = u.RequiredTier,
|
||||
RequestedByVendorName = u.RequestedByVendorName,
|
||||
CreatedDate = u.CreatedDate,
|
||||
DecidedAt = u.DecidedAt,
|
||||
DecisionNote = u.DecisionNote,
|
||||
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
||||
DecidedByLastName = dec != null ? dec.LastName : null
|
||||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == dispatchId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.DispatchUpliftRequests
|
||||
.AnyAsync(u => u.DispatchId == dispatchId
|
||||
&& u.Status == "Pending"
|
||||
&& (u.IsDeleted == null || u.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -141,5 +141,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string?> GetEmailByIdAsync(
|
||||
string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(user => user.Id == userId)
|
||||
.Select(user => user.Email)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,29 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return await _context.Vendors.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsActive)
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(v => v.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
|
|
@ -284,16 +307,47 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return await _context.Vendors.AnyAsync(v => v.Id == id);
|
||||
}
|
||||
|
||||
public async Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId)
|
||||
public async Task<VendorAccessToken?> GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await _context.VendorAccessTokens
|
||||
.FirstOrDefaultAsync(t => t.VendorId == vendorId);
|
||||
.AsNoTracking()
|
||||
.Where(t => t.VendorId == vendorId
|
||||
&& t.RevokedAt == null
|
||||
&& t.ExpiresAt > now
|
||||
&& (t.IsDeleted == null || t.IsDeleted == false))
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<VendorAccessToken?> GetTokenByStringAsync(string token)
|
||||
public async Task<VendorAccessToken?> GetTokenWithVendorAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await _context.VendorAccessTokens
|
||||
.Include(t => t.Vendor)
|
||||
.FirstOrDefaultAsync(t => t.Token == token
|
||||
&& t.RevokedAt == null
|
||||
&& t.ExpiresAt > now
|
||||
&& (t.IsDeleted == null || t.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<VendorAccessToken> AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.VendorAccessTokens.AddAsync(token, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VendorAccessToken>> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.VendorAccessTokens
|
||||
.FirstOrDefaultAsync(t => t.Token == token);
|
||||
.Where(t => t.VendorId == vendorId && t.RevokedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync()
|
||||
|
|
|
|||
|
|
@ -12,5 +12,7 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task UpdateAsync(Comments comment);
|
||||
Task DeleteAsync(int id);
|
||||
Task<bool> ExistsAsync(int id);
|
||||
Task StageAsync(Comments comment, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalCommentData>> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,28 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task UpdateChecklistItemAsync(DispatchChecklistItem item);
|
||||
Task<IEnumerable<DispatchSignoff>> GetSignoffsAsync(int dispatchId);
|
||||
Task AddSignoffAsync(DispatchSignoff signoff);
|
||||
Task StageSignoffAsync(DispatchSignoff signoff, CancellationToken cancellationToken);
|
||||
Task<bool> HasSignoffTypeAsync(int dispatchId, string signoffType);
|
||||
Task<IEnumerable<DispatchWorkOrder>> GetDispatchWorkOrdersAsync(int dispatchId);
|
||||
Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder);
|
||||
|
||||
Task<DispatchListResult> GetListAsync(DispatchListQuery query, CancellationToken cancellationToken);
|
||||
Task<DispatchDetailDTO?> GetDetailAsync(int id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<VendorDispatchSummaryData>> GetVendorDispatchSummariesAsync(
|
||||
int vendorId, string? status, CancellationToken cancellationToken);
|
||||
Task<Dispatch?> GetVendorDispatchDetailAsync(
|
||||
int id, int vendorId, CancellationToken cancellationToken);
|
||||
Task<Dispatch?> GetVendorDispatchForMutationAsync(
|
||||
int id, int vendorId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalChecklistItemData>> GetPortalChecklistAsync(
|
||||
int dispatchId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalSignoffData>> GetPortalSignoffsAsync(
|
||||
int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchChecklistItem?> GetChecklistItemForDispatchAsync(
|
||||
int itemId, int dispatchId, CancellationToken cancellationToken);
|
||||
Task StageAuditLogAsync(WorkOrderAuditLog log, CancellationToken cancellationToken);
|
||||
Task<string?> GetDispatchDispatcherUserIdAsync(
|
||||
int workOrderId, CancellationToken cancellationToken);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
SeaHaven.DataServices/Interfaces/IUpliftDataService.cs
Normal file
17
SeaHaven.DataServices/Interfaces/IUpliftDataService.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IUpliftDataService
|
||||
{
|
||||
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
|
||||
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
|
||||
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,5 +17,6 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
|
||||
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<Vendor?> GetByIdAsync(int id);
|
||||
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
||||
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
|
||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
|
|
@ -20,8 +22,11 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task UpdateAsync(Vendor vendor);
|
||||
Task DeleteAsync(int id);
|
||||
Task<bool> ExistsAsync(int id);
|
||||
Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId);
|
||||
Task<VendorAccessToken?> GetTokenByStringAsync(string token);
|
||||
Task<VendorAccessToken?> GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task<VendorAccessToken?> GetTokenWithVendorAsync(string token, CancellationToken cancellationToken);
|
||||
Task<VendorAccessToken> AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<VendorAccessToken>> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
Task<int> CountAsync();
|
||||
|
||||
Task<VendorCompany?> GetCompanyByIdAsync(int id);
|
||||
|
|
|
|||
66
SeaHaven.Services/DTOs/UpliftDTOs.cs
Normal file
66
SeaHaven.Services/DTOs/UpliftDTOs.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class UpliftListItemDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int DispatchId { get; set; }
|
||||
public string? DispatchNumber { get; set; }
|
||||
public string? PONumber { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? VendorCompanyName { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public decimal Delta { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? RequestedAt { get; set; }
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
public bool CanDecide { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftListResultDTO
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public IEnumerable<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>();
|
||||
}
|
||||
|
||||
public class UpliftForDispatchDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int DispatchId { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public decimal Delta { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
public DateTime? RequestedAt { get; set; }
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
public bool CanDecide { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftApproveResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public decimal? NTEAmount { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftDenyResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftForbiddenException : Exception
|
||||
{
|
||||
public UpliftForbiddenException(string message) : base(message) { }
|
||||
}
|
||||
}
|
||||
65
SeaHaven.Services/DTOs/VendorPortalDTOs.cs
Normal file
65
SeaHaven.Services/DTOs/VendorPortalDTOs.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public sealed class VendorPortalTokenStateDTO
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
public DateTime IssuedAt { get; init; }
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
public DateTime? LastUsedAt { get; init; }
|
||||
}
|
||||
|
||||
public class VendorPortalTokenDTO
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public DateTime IssuedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public string PortalUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class VendorDropdownItemDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public double? DistanceMiles { get; set; }
|
||||
}
|
||||
|
||||
public class VendorFacetCompanyDTO
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
}
|
||||
|
||||
public class VendorFacetLocationDTO
|
||||
{
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
public class VendorJobBucketDTO
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class VendorFacetsDTO
|
||||
{
|
||||
public IEnumerable<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
|
||||
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
|
||||
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
|
||||
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
|
||||
}
|
||||
}
|
||||
164
SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs
Normal file
164
SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class VendorPortalSession
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
}
|
||||
|
||||
public class VendorDispatchSummaryDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? DispatchNumber { get; set; }
|
||||
public string? PONumber { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
public string? WorkOrderTitle { get; set; }
|
||||
public string? InternalWONumber { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? LocationCity { get; set; }
|
||||
public string? LocationState { get; set; }
|
||||
}
|
||||
|
||||
public class PortalChecklistItemDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? ItemText { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PortalSignoffDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? SignoffType { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? SignatureMethod { get; set; }
|
||||
public DateTime? SignedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PortalUpliftDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
public string? VendorReason { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public string? RequestedByVendorName { get; set; }
|
||||
public DateTime? RequestedAt { get; set; }
|
||||
public DateTime? DecidedAt { get; set; }
|
||||
public string? DecisionNote { get; set; }
|
||||
public string? DecidedByName { get; set; }
|
||||
}
|
||||
|
||||
public class PortalCommentDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Commenttext { get; set; }
|
||||
public string? Commenter { get; set; }
|
||||
public string? CommentType { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
}
|
||||
|
||||
public class PortalWorkOrderDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? InternalWONumber { get; set; }
|
||||
public string? WorkerOrderTitle { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Priority { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public string? SubTrade { get; set; }
|
||||
public string? Problem { get; set; }
|
||||
}
|
||||
|
||||
public class PortalLocationDTO
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Address1 { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
}
|
||||
|
||||
public class VendorDispatchDetailDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? DispatchNumber { get; set; }
|
||||
public string? PONumber { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
public PortalWorkOrderDTO? WorkOrder { get; set; }
|
||||
public PortalLocationDTO? Location { get; set; }
|
||||
public IEnumerable<PortalChecklistItemDTO> Checklist { get; set; } = Enumerable.Empty<PortalChecklistItemDTO>();
|
||||
public IEnumerable<PortalSignoffDTO> Signoffs { get; set; } = Enumerable.Empty<PortalSignoffDTO>();
|
||||
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
|
||||
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
|
||||
}
|
||||
|
||||
public class AcceptDispatchResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ChangeStatusResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
}
|
||||
|
||||
public class ChecklistItemResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public class SignoffResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime? SignedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CommentResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Commenttext { get; set; }
|
||||
public string? Commenter { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
}
|
||||
|
||||
public class UpliftRequestResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int RequiredTier { get; set; }
|
||||
public decimal? CurrentNTE { get; set; }
|
||||
public decimal RequestedNTE { get; set; }
|
||||
}
|
||||
|
||||
public class CancelUpliftResultDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
}
|
||||
171
SeaHaven.Services/Implementation/UpliftService.cs
Normal file
171
SeaHaven.Services/Implementation/UpliftService.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class UpliftService : IUpliftService
|
||||
{
|
||||
private readonly IUpliftDataService _upliftData;
|
||||
private readonly IDispatchDataService _dispatchData;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IConfiguration config)
|
||||
{
|
||||
_upliftData = upliftData;
|
||||
_dispatchData = dispatchData;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken);
|
||||
|
||||
var mapped = items.Select(r => new UpliftListItemDTO
|
||||
{
|
||||
Id = r.Id,
|
||||
DispatchId = r.DispatchId,
|
||||
DispatchNumber = r.DispatchNumber,
|
||||
PONumber = r.PONumber,
|
||||
WorkOrderId = r.WorkOrderId,
|
||||
VendorCompanyName = r.VendorCompanyName,
|
||||
CurrentNTE = r.CurrentNTE,
|
||||
RequestedNTE = r.RequestedNTE,
|
||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
||||
VendorReason = r.VendorReason,
|
||||
RequiredTier = r.RequiredTier,
|
||||
Status = r.Status,
|
||||
RequestedAt = r.CreatedDate,
|
||||
DecidedAt = r.DecidedAt,
|
||||
DecisionNote = r.DecisionNote,
|
||||
CanDecide = UserCanApprove(user, r.RequiredTier)
|
||||
}).ToList();
|
||||
|
||||
return new UpliftListResultDTO
|
||||
{
|
||||
Total = total,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Items = mapped
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UpliftForDispatchDTO>> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await _upliftData.GetForDispatchAsync(dispatchId, cancellationToken);
|
||||
|
||||
return rows.Select(r => new UpliftForDispatchDTO
|
||||
{
|
||||
Id = r.Id,
|
||||
DispatchId = r.DispatchId,
|
||||
CurrentNTE = r.CurrentNTE,
|
||||
RequestedNTE = r.RequestedNTE,
|
||||
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
||||
VendorReason = r.VendorReason,
|
||||
Status = r.Status,
|
||||
RequiredTier = r.RequiredTier,
|
||||
RequestedByVendorName = r.RequestedByVendorName,
|
||||
RequestedAt = r.CreatedDate,
|
||||
DecidedAt = r.DecidedAt,
|
||||
DecisionNote = r.DecisionNote,
|
||||
DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
|
||||
CanDecide = UserCanApprove(user, r.RequiredTier)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<UpliftApproveResultDTO> ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
||||
{
|
||||
var req = await _upliftData.GetByIdAsync(id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
if (req.Status != "Pending") throw new InvalidOperationException($"Cannot approve a '{req.Status}' request");
|
||||
if (!UserCanApprove(user, req.RequiredTier))
|
||||
throw new UpliftForbiddenException($"Approval requires a Tier {req.RequiredTier} role");
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch");
|
||||
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var oldNTE = dispatch.NTEAmount ?? 0m;
|
||||
|
||||
dispatch.NTEAmount = req.RequestedNTE;
|
||||
dispatch.LastModificationTime = now;
|
||||
|
||||
req.Status = "Approved";
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
||||
OldValue = $"${oldNTE:F2}",
|
||||
NewValue = $"${req.RequestedNTE:F2}",
|
||||
Action = "uplift_approved",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return new UpliftApproveResultDTO { Id = req.Id, Status = req.Status, NTEAmount = dispatch.NTEAmount };
|
||||
}
|
||||
|
||||
public async Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
throw new InvalidOperationException("A note is required when denying");
|
||||
|
||||
var req = await _upliftData.GetByIdAsync(id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
if (req.Status != "Pending") throw new InvalidOperationException($"Cannot deny a '{req.Status}' request");
|
||||
if (!UserCanApprove(user, req.RequiredTier))
|
||||
throw new UpliftForbiddenException($"Decision requires a Tier {req.RequiredTier} role");
|
||||
|
||||
var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId);
|
||||
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
req.Status = "Denied";
|
||||
req.DecidedAt = now;
|
||||
req.DecidedByUserId = userId;
|
||||
req.DecisionNote = note!.Trim();
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Denied",
|
||||
Action = "uplift_denied",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return new UpliftDenyResultDTO { Id = req.Id, Status = req.Status };
|
||||
}
|
||||
|
||||
public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier);
|
||||
|
||||
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
|
||||
{
|
||||
var key = $"Approvals:Tier{requiredTier}Roles";
|
||||
var roles = _config.GetSection(key).Get<string[]>() ?? Array.Empty<string>();
|
||||
foreach (var r in roles)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
544
SeaHaven.Services/Implementation/VendorPortalService.cs
Normal file
544
SeaHaven.Services/Implementation/VendorPortalService.cs
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class VendorPortalService : IVendorPortalService
|
||||
{
|
||||
private readonly IVendorPortalTokenService _tokens;
|
||||
private readonly IDispatchDataService _dispatchData;
|
||||
private readonly IUpliftDataService _upliftData;
|
||||
private readonly ICommentDataService _commentData;
|
||||
private readonly IUserDataService _userData;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public VendorPortalService(
|
||||
IVendorPortalTokenService tokens,
|
||||
IDispatchDataService dispatchData,
|
||||
IUpliftDataService upliftData,
|
||||
ICommentDataService commentData,
|
||||
IUserDataService userData,
|
||||
IEmailSender emailSender,
|
||||
IConfiguration config)
|
||||
{
|
||||
_tokens = tokens;
|
||||
_dispatchData = dispatchData;
|
||||
_upliftData = upliftData;
|
||||
_commentData = commentData;
|
||||
_userData = userData;
|
||||
_emailSender = emailSender;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _tokens.ResolveSessionAsync(token, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<VendorDispatchSummaryDTO>> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken)
|
||||
{
|
||||
var summaries = await _dispatchData.GetVendorDispatchSummariesAsync(session.Id, status, cancellationToken);
|
||||
|
||||
return summaries.Select(d => new VendorDispatchSummaryDTO
|
||||
{
|
||||
Id = d.Id,
|
||||
DispatchNumber = d.DispatchNumber,
|
||||
PONumber = d.PONumber,
|
||||
Status = d.Status,
|
||||
NTEAmount = d.NTEAmount,
|
||||
ScheduledDate = d.ScheduledDate,
|
||||
CompletedDate = d.CompletedDate,
|
||||
DispatchedAt = d.DispatchedAt,
|
||||
AcknowledgedAt = d.AcknowledgedAt,
|
||||
WorkOrderTitle = d.WorkOrderTitle,
|
||||
InternalWONumber = d.InternalWONumber,
|
||||
LocationName = d.LocationName,
|
||||
LocationCity = d.LocationCity,
|
||||
LocationState = d.LocationState
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<VendorDispatchDetailDTO?> GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchDetailAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) return null;
|
||||
|
||||
var checklist = await _dispatchData.GetPortalChecklistAsync(id, cancellationToken);
|
||||
var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken);
|
||||
var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken);
|
||||
var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken);
|
||||
|
||||
var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO
|
||||
{
|
||||
Id = u.Id,
|
||||
CurrentNTE = u.CurrentNTE,
|
||||
RequestedNTE = u.RequestedNTE,
|
||||
VendorReason = u.VendorReason,
|
||||
Status = u.Status,
|
||||
RequiredTier = u.RequiredTier,
|
||||
RequestedByVendorName = u.RequestedByVendorName,
|
||||
RequestedAt = u.CreatedDate,
|
||||
DecidedAt = u.DecidedAt,
|
||||
DecisionNote = u.DecisionNote,
|
||||
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
||||
}).ToList();
|
||||
|
||||
var comments = rawComments.Select(c =>
|
||||
{
|
||||
var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId);
|
||||
var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
|
||||
|
||||
string resolvedType;
|
||||
string resolvedCommenter;
|
||||
if (hasShocUser)
|
||||
{
|
||||
resolvedType = "dispatcher";
|
||||
resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName
|
||||
: !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter!
|
||||
: "Dispatcher";
|
||||
}
|
||||
else if (c.CommentType == "customer")
|
||||
{
|
||||
resolvedType = "customer";
|
||||
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer";
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedType = "vendor";
|
||||
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : session.CompanyName ?? "Vendor";
|
||||
}
|
||||
|
||||
return new PortalCommentDTO
|
||||
{
|
||||
Id = c.Id,
|
||||
Commenttext = c.Commenttext,
|
||||
Commenter = resolvedCommenter,
|
||||
CommentType = resolvedType,
|
||||
CreatedDate = c.CreatedDate
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new VendorDispatchDetailDTO
|
||||
{
|
||||
Id = dispatch.Id,
|
||||
DispatchNumber = dispatch.DispatchNumber,
|
||||
PONumber = dispatch.PONumber,
|
||||
Status = dispatch.Status,
|
||||
NTEAmount = dispatch.NTEAmount,
|
||||
Description = dispatch.Description,
|
||||
ScheduledDate = dispatch.ScheduledDate,
|
||||
CompletedDate = dispatch.CompletedDate,
|
||||
DispatchedAt = dispatch.DispatchedAt,
|
||||
AcknowledgedAt = dispatch.AcknowledgedAt,
|
||||
WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO
|
||||
{
|
||||
Id = dispatch.WorkOrder.Id,
|
||||
InternalWONumber = dispatch.WorkOrder.InternalWONumber,
|
||||
WorkerOrderTitle = dispatch.WorkOrder.WorkerOrderTitle,
|
||||
Description = dispatch.WorkOrder.Description,
|
||||
Priority = dispatch.WorkOrder.Priority,
|
||||
DueDate = dispatch.WorkOrder.DueDate,
|
||||
Trade = dispatch.WorkOrder.Trade,
|
||||
SubTrade = dispatch.WorkOrder.SubTrade,
|
||||
Problem = dispatch.WorkOrder.Problem
|
||||
},
|
||||
Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO
|
||||
{
|
||||
Name = dispatch.WorkOrder.Locations.Name,
|
||||
Address1 = dispatch.WorkOrder.Locations.Address1,
|
||||
City = dispatch.WorkOrder.Locations.City,
|
||||
State = dispatch.WorkOrder.Locations.State,
|
||||
Zip = dispatch.WorkOrder.Locations.Zip
|
||||
},
|
||||
Checklist = checklist.Select(c => new PortalChecklistItemDTO
|
||||
{
|
||||
Id = c.Id,
|
||||
ItemText = c.ItemText,
|
||||
IsCompleted = c.IsCompleted,
|
||||
CompletedBy = c.CompletedBy,
|
||||
CompletedAt = c.CompletedAt
|
||||
}),
|
||||
Signoffs = signoffs.Select(s => new PortalSignoffDTO
|
||||
{
|
||||
Id = s.Id,
|
||||
SignoffType = s.SignoffType,
|
||||
Name = s.Name,
|
||||
SignatureMethod = s.SignatureMethod,
|
||||
SignedAt = s.SignedAt
|
||||
}),
|
||||
Comments = comments,
|
||||
UpliftRequests = upliftRequests
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<AcceptDispatchResultDTO> AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (dispatch.Status != "Sent")
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot accept a dispatch with status '{dispatch.Status}'");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
dispatch.Status = "Acknowledged";
|
||||
dispatch.AcknowledgedAt = now;
|
||||
dispatch.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
||||
OldValue = "Sent",
|
||||
NewValue = "Acknowledged",
|
||||
Action = "vendor_accept",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _dispatchData.SaveChangesAsync(cancellationToken);
|
||||
return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt };
|
||||
}
|
||||
|
||||
public async Task<ChangeStatusResultDTO> ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
var from = dispatch.Status;
|
||||
if (!IsAllowedVendorTransition(from, to))
|
||||
{
|
||||
throw new InvalidOperationException($"Transition from '{from}' to '{to}' is not allowed");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
dispatch.Status = to;
|
||||
dispatch.LastModificationTime = now;
|
||||
if (to == "Completed") dispatch.CompletedDate = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
||||
OldValue = from,
|
||||
NewValue = to,
|
||||
Action = "vendor_status_change",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _dispatchData.SaveChangesAsync(cancellationToken);
|
||||
return new ChangeStatusResultDTO { Id = dispatch.Id, Status = dispatch.Status, CompletedDate = dispatch.CompletedDate };
|
||||
}
|
||||
|
||||
public async Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var reasonText = string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason!.Trim();
|
||||
|
||||
await _commentData.StageAsync(new Comments
|
||||
{
|
||||
DispatchId = id,
|
||||
WorkerOrderId = dispatch.WorkOrderId,
|
||||
Commenter = session.CompanyName,
|
||||
CommentType = "vendor",
|
||||
RecordType = "cancel_request",
|
||||
Commenttext = $"Vendor requested cancellation: {reasonText}",
|
||||
CreatedDate = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber}",
|
||||
OldValue = dispatch.Status,
|
||||
NewValue = "Cancel Requested",
|
||||
Action = "vendor_request_cancel",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _dispatchData.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ChecklistItemResultDTO> UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
{
|
||||
throw new InvalidOperationException("Dispatch is locked");
|
||||
}
|
||||
|
||||
var item = await _dispatchData.GetChecklistItemForDispatchAsync(itemId, id, cancellationToken);
|
||||
if (item == null) throw new KeyNotFoundException("Checklist item not found");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
item.IsCompleted = isCompleted;
|
||||
item.CompletedBy = isCompleted ? session.CompanyName : null;
|
||||
item.CompletedAt = isCompleted ? now : null;
|
||||
item.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.SaveChangesAsync(cancellationToken);
|
||||
return new ChecklistItemResultDTO
|
||||
{
|
||||
Id = item.Id,
|
||||
IsCompleted = item.IsCompleted,
|
||||
CompletedBy = item.CompletedBy,
|
||||
CompletedAt = item.CompletedAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SignoffResultDTO> AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (dispatch.Status != "In Progress" && dispatch.Status != "Completed")
|
||||
{
|
||||
throw new InvalidOperationException("Signoffs only allowed on In Progress or Completed dispatches");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
throw new InvalidOperationException("Name and signature are required");
|
||||
}
|
||||
|
||||
var normalizedSignoffType = (signoffType ?? "vendor").Trim().ToLowerInvariant();
|
||||
if (normalizedSignoffType != "vendor" && normalizedSignoffType != "customer")
|
||||
{
|
||||
throw new InvalidOperationException("signoffType must be 'vendor' or 'customer'");
|
||||
}
|
||||
|
||||
var existing = await _dispatchData.HasSignoffTypeAsync(id, normalizedSignoffType);
|
||||
if (existing)
|
||||
{
|
||||
throw new InvalidOperationException($"A {normalizedSignoffType} signoff already exists for this dispatch");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var signoff = new DispatchSignoff
|
||||
{
|
||||
DispatchId = id,
|
||||
SignoffType = normalizedSignoffType,
|
||||
Name = name,
|
||||
Signature = signature,
|
||||
SignatureMethod = signatureMethod ?? "drawn",
|
||||
SignedAt = now,
|
||||
CreatedDate = now
|
||||
};
|
||||
await _dispatchData.StageSignoffAsync(signoff, cancellationToken);
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff",
|
||||
OldValue = null,
|
||||
NewValue = $"{(normalizedSignoffType == "customer" ? "Customer" : "Vendor")}: {name}",
|
||||
Action = normalizedSignoffType == "customer" ? "customer_signoff" : "vendor_signoff",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _dispatchData.SaveChangesAsync(cancellationToken);
|
||||
return new SignoffResultDTO { Id = signoff.Id, SignedAt = signoff.SignedAt };
|
||||
}
|
||||
|
||||
public async Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(commentText))
|
||||
{
|
||||
throw new InvalidOperationException("Comment cannot be empty");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var comment = new Comments
|
||||
{
|
||||
DispatchId = id,
|
||||
WorkerOrderId = dispatch.WorkOrderId,
|
||||
Commenter = session.CompanyName,
|
||||
CommentType = "vendor",
|
||||
RecordType = "comment",
|
||||
Commenttext = commentText,
|
||||
CreatedDate = now
|
||||
};
|
||||
await _commentData.StageAsync(comment, cancellationToken);
|
||||
await _dispatchData.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new CommentResultDTO
|
||||
{
|
||||
Id = comment.Id,
|
||||
Commenttext = comment.Commenttext,
|
||||
Commenter = comment.Commenter,
|
||||
CreatedDate = comment.CreatedDate
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");
|
||||
}
|
||||
|
||||
if (requestedNTE <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Requested NTE must be greater than zero");
|
||||
}
|
||||
|
||||
var current = dispatch.NTEAmount ?? 0m;
|
||||
if (requestedNTE <= current)
|
||||
{
|
||||
throw new InvalidOperationException("Requested NTE must be greater than the current NTE");
|
||||
}
|
||||
|
||||
var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken);
|
||||
if (pendingExists)
|
||||
{
|
||||
throw new InvalidOperationException("A pending uplift request already exists for this dispatch");
|
||||
}
|
||||
|
||||
var tier1Max = _config.GetValue<decimal?>("Approvals:UpliftTier1MaxUsd") ?? 2500m;
|
||||
var delta = requestedNTE - current;
|
||||
var requiredTier = delta > tier1Max ? 2 : 1;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var req = new DispatchUpliftRequest
|
||||
{
|
||||
DispatchId = id,
|
||||
CurrentNTE = current,
|
||||
RequestedNTE = requestedNTE,
|
||||
VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(),
|
||||
Status = "Pending",
|
||||
RequiredTier = requiredTier,
|
||||
RequestedByVendorName = session.CompanyName,
|
||||
CreatedDate = now
|
||||
};
|
||||
await _upliftData.StageAsync(req, cancellationToken);
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = $"${current:F2}",
|
||||
NewValue = $"${requestedNTE:F2}",
|
||||
Action = "uplift_requested",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken);
|
||||
|
||||
return new UpliftRequestResultDTO
|
||||
{
|
||||
Id = req.Id,
|
||||
Status = req.Status,
|
||||
RequiredTier = req.RequiredTier,
|
||||
CurrentNTE = req.CurrentNTE,
|
||||
RequestedNTE = req.RequestedNTE
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken)
|
||||
{
|
||||
var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken);
|
||||
if (dispatch == null) throw new KeyNotFoundException("Dispatch not found");
|
||||
|
||||
var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken);
|
||||
if (req == null) throw new KeyNotFoundException("Uplift request not found");
|
||||
|
||||
if (req.Status != "Pending")
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
req.Status = "Cancelled";
|
||||
req.DecidedAt = now;
|
||||
req.LastModificationTime = now;
|
||||
|
||||
await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
||||
OldValue = "Pending",
|
||||
NewValue = "Cancelled",
|
||||
Action = "uplift_cancelled",
|
||||
CreatedAt = now
|
||||
}, cancellationToken);
|
||||
|
||||
await _upliftData.SaveChangesAsync(cancellationToken);
|
||||
return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status };
|
||||
}
|
||||
|
||||
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherUserId)) return;
|
||||
|
||||
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(dispatcherEmail)) return;
|
||||
|
||||
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
|
||||
var workOrderLink = dispatch.WorkOrderId.HasValue
|
||||
? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}"
|
||||
: frontendBase;
|
||||
|
||||
var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor");
|
||||
var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)");
|
||||
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
|
||||
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
|
||||
|
||||
var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})";
|
||||
var body = $@"
|
||||
<h2>Vendor Uplift Request</h2>
|
||||
<p><strong>Dispatch:</strong> {dispatchNum}</p>
|
||||
<p><strong>Vendor:</strong> {vendorName}</p>
|
||||
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
||||
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
|
||||
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
||||
</table>
|
||||
<h3>Vendor Reason</h3>
|
||||
<p>{reason}</p>
|
||||
<div style='margin:20px 0;'>
|
||||
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
|
||||
</div>";
|
||||
|
||||
await _emailSender.SendEmailAsync(dispatcherEmail, subject, body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedVendorTransition(string? from, string? to)
|
||||
{
|
||||
if (from == "Acknowledged" && to == "In Progress") return true;
|
||||
if (from == "In Progress" && to == "Completed") return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
92
SeaHaven.Services/Implementation/VendorPortalTokenService.cs
Normal file
92
SeaHaven.Services/Implementation/VendorPortalTokenService.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class VendorPortalTokenService : IVendorPortalTokenService
|
||||
{
|
||||
private readonly IVendorDataService _vendorDataService;
|
||||
private readonly int _lifetimeDays;
|
||||
|
||||
public VendorPortalTokenService(IVendorDataService vendorDataService, IConfiguration config)
|
||||
{
|
||||
_vendorDataService = vendorDataService;
|
||||
_lifetimeDays = config.GetValue<int?>("VendorPortal:TokenLifetimeDays") ?? 365;
|
||||
}
|
||||
|
||||
public async Task<VendorPortalTokenStateDTO> GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = await _vendorDataService.GetActiveTokenForVendorAsync(vendorId, cancellationToken);
|
||||
if (existing != null) return MapToken(existing);
|
||||
|
||||
var token = new VendorAccessToken
|
||||
{
|
||||
VendorId = vendorId,
|
||||
Token = GenerateToken(),
|
||||
IssuedAt = now,
|
||||
ExpiresAt = now.AddDays(_lifetimeDays),
|
||||
CreatedDate = now
|
||||
};
|
||||
return MapToken(await _vendorDataService.AddTokenAsync(token, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||
var now = DateTime.UtcNow;
|
||||
var record = await _vendorDataService.GetTokenWithVendorAsync(token, cancellationToken);
|
||||
|
||||
if (record?.Vendor == null) return null;
|
||||
|
||||
record.LastUsedAt = now;
|
||||
await _vendorDataService.SaveChangesAsync(cancellationToken);
|
||||
return new VendorPortalSession
|
||||
{
|
||||
Id = record.Vendor.Id,
|
||||
CompanyName = record.Vendor.CompanyName,
|
||||
ContactName = record.Vendor.ContactName,
|
||||
Email = record.Vendor.Email,
|
||||
Phone = record.Vendor.Phone
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<VendorPortalTokenStateDTO> RotateAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken);
|
||||
foreach (var t in active) t.RevokedAt = now;
|
||||
await _vendorDataService.SaveChangesAsync(cancellationToken);
|
||||
return await GetOrCreateActiveTokenAsync(vendorId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken);
|
||||
foreach (var t in active) t.RevokedAt = now;
|
||||
await _vendorDataService.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string GenerateToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
private static VendorPortalTokenStateDTO MapToken(VendorAccessToken token) => new()
|
||||
{
|
||||
Token = token.Token,
|
||||
IssuedAt = token.IssuedAt,
|
||||
ExpiresAt = token.ExpiresAt,
|
||||
LastUsedAt = token.LastUsedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using Data.SeaHavenIndustries;
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
|
@ -16,17 +17,26 @@ namespace SeaHaven.Services.Implementation
|
|||
{ "completed", "cancelled", "canceled" };
|
||||
|
||||
private readonly IVendorDataService _vendorDataService;
|
||||
private readonly IVendorPortalTokenService _vendorTokenService;
|
||||
private readonly IZipCodeDistance _zipDistance;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ICreateVendorValidation _createValidator;
|
||||
private readonly IUpdateVendorValidation _updateValidator;
|
||||
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
|
||||
|
||||
public VendorService(
|
||||
IVendorDataService vendorDataService,
|
||||
IVendorPortalTokenService vendorTokenService,
|
||||
IZipCodeDistance zipDistance,
|
||||
IConfiguration config,
|
||||
ICreateVendorValidation createValidator,
|
||||
IUpdateVendorValidation updateValidator,
|
||||
IWorkOrderVendorUpdateValidation workOrderUpdateValidator)
|
||||
{
|
||||
_vendorDataService = vendorDataService;
|
||||
_vendorTokenService = vendorTokenService;
|
||||
_zipDistance = zipDistance;
|
||||
_config = config;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
_workOrderUpdateValidator = workOrderUpdateValidator;
|
||||
|
|
@ -519,5 +529,136 @@ namespace SeaHaven.Services.Implementation
|
|||
.Distinct()
|
||||
.Count();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<VendorDropdownItemDTO>> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken)
|
||||
{
|
||||
var vendors = await _vendorDataService.GetActiveVendorsAsync(cancellationToken);
|
||||
|
||||
var result = vendors.Select(v =>
|
||||
{
|
||||
var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip);
|
||||
var addr = new[] { v.Address, v.City, v.State, v.Zip }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
return new VendorDropdownItemDTO
|
||||
{
|
||||
Id = v.Id,
|
||||
CompanyName = v.CompanyName,
|
||||
ContactName = v.ContactName,
|
||||
PreferredContact = v.PreferredContact,
|
||||
TradeSpecialties = v.TradeSpecialties,
|
||||
Address = string.Join(", ", addr),
|
||||
DistanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trade))
|
||||
{
|
||||
var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList();
|
||||
var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList();
|
||||
result = tradeMatched.Concat(rest).ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(siteZip))
|
||||
{
|
||||
result = result.OrderBy(v => v.DistanceMiles ?? 99999).ToList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||
{
|
||||
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
|
||||
|
||||
var companies = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
|
||||
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new VendorFacetCompanyDTO
|
||||
{
|
||||
Name = v.CompanyName,
|
||||
CompanyId = v.CompanyId,
|
||||
CompanyPhone = v.CompanyPhone,
|
||||
Email = v.Email,
|
||||
PreferredContact = v.PreferredContact,
|
||||
Address = v.Address,
|
||||
City = v.City,
|
||||
State = v.State,
|
||||
Zip = v.Zip,
|
||||
GoogleMapsUrl = v.GoogleMapsUrl
|
||||
});
|
||||
|
||||
var trades = vendors
|
||||
.SelectMany(v => (v.TradeSpecialties ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value);
|
||||
|
||||
var locations = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
|
||||
.Select(v => new VendorFacetLocationDTO
|
||||
{
|
||||
City = v.City,
|
||||
State = v.State,
|
||||
Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||
})
|
||||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(location => location.Label);
|
||||
|
||||
return new VendorFacetsDTO
|
||||
{
|
||||
Companies = companies,
|
||||
Trades = trades,
|
||||
Locations = locations,
|
||||
JobBuckets = new[]
|
||||
{
|
||||
new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" },
|
||||
new VendorJobBucketDTO { Id = "50-99", Label = "50–99" },
|
||||
new VendorJobBucketDTO { Id = "100-149", Label = "100–149" },
|
||||
new VendorJobBucketDTO { Id = "150-plus", Label = "150+" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<VendorPortalTokenDTO?> GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||||
return null;
|
||||
|
||||
var token = await _vendorTokenService.GetOrCreateActiveTokenAsync(vendorId, cancellationToken);
|
||||
return BuildPortalTokenResponse(token);
|
||||
}
|
||||
|
||||
public async Task<VendorPortalTokenDTO?> RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||||
return null;
|
||||
|
||||
var token = await _vendorTokenService.RotateAsync(vendorId, cancellationToken);
|
||||
return BuildPortalTokenResponse(token);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||||
return false;
|
||||
|
||||
await _vendorTokenService.RevokeAllAsync(vendorId, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token)
|
||||
{
|
||||
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
|
||||
return new VendorPortalTokenDTO
|
||||
{
|
||||
Token = token.Token,
|
||||
IssuedAt = token.IssuedAt,
|
||||
ExpiresAt = token.ExpiresAt,
|
||||
LastUsedAt = token.LastUsedAt,
|
||||
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
SeaHaven.Services/Interfaces/IUpliftService.cs
Normal file
14
SeaHaven.Services/Interfaces/IUpliftService.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using System.Security.Claims;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IUpliftService
|
||||
{
|
||||
Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<IEnumerable<UpliftForDispatchDTO>> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken);
|
||||
Task<UpliftApproveResultDTO> ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
|
||||
bool CanApprove(ClaimsPrincipal user, int tier);
|
||||
}
|
||||
}
|
||||
19
SeaHaven.Services/Interfaces/IVendorPortalService.cs
Normal file
19
SeaHaven.Services/Interfaces/IVendorPortalService.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IVendorPortalService
|
||||
{
|
||||
Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken);
|
||||
Task<IEnumerable<VendorDispatchSummaryDTO>> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken);
|
||||
Task<VendorDispatchDetailDTO?> GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
|
||||
Task<AcceptDispatchResultDTO> AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
|
||||
Task<ChangeStatusResultDTO> ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken);
|
||||
Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken);
|
||||
Task<ChecklistItemResultDTO> UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken);
|
||||
Task<SignoffResultDTO> AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken);
|
||||
Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken);
|
||||
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken);
|
||||
Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
12
SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs
Normal file
12
SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IVendorPortalTokenService
|
||||
{
|
||||
Task<VendorPortalTokenStateDTO> GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken);
|
||||
Task<VendorPortalTokenStateDTO> RotateAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,5 +23,10 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<int> GetTotalVendorCountAsync();
|
||||
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);
|
||||
Task<VendorDTO> UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId);
|
||||
Task<IEnumerable<VendorDropdownItemDTO>> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken);
|
||||
Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken);
|
||||
Task<VendorPortalTokenDTO?> GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task<VendorPortalTokenDTO?> RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task<bool> RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
SeaHaven.Services/Interfaces/IZipCodeDistance.cs
Normal file
7
SeaHaven.Services/Interfaces/IZipCodeDistance.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IZipCodeDistance
|
||||
{
|
||||
double? GetDistanceMiles(string? zip1, string? zip2);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue