mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
feat(vendors): add company roster management (SH-198) (#48)
* feat(vendors): add company roster management * fix(security): remove request-controlled write guards * fix(vendors): synchronize roster company fields * fix(vendors): source facets from companies
This commit is contained in:
parent
47c3fff4ba
commit
669e9b2932
26 changed files with 6698 additions and 43 deletions
|
|
@ -0,0 +1,216 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class VendorCompanyRosterControllerTests
|
||||||
|
{
|
||||||
|
private static VendorCompanyRosterController NewController(Mock<IVendorCompanyRosterService> service, string? userId = "42")
|
||||||
|
{
|
||||||
|
var controller = new VendorCompanyRosterController(service.Object, Mock.Of<ILogger<VendorCompanyRosterController>>());
|
||||||
|
|
||||||
|
ClaimsIdentity identity;
|
||||||
|
if (userId == null)
|
||||||
|
identity = new ClaimsIdentity();
|
||||||
|
else
|
||||||
|
identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "Test");
|
||||||
|
|
||||||
|
controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
|
||||||
|
};
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorRosterDTO SampleRoster() => new()
|
||||||
|
{
|
||||||
|
CompanyId = 7,
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Technicians = new List<VendorRosterTechnicianDTO> { new() { Id = 1, ContactName = "Riley" } }
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_Unauthenticated_Returns401()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
var controller = NewController(service, userId: null);
|
||||||
|
|
||||||
|
var result = await controller.Get(vendorId: 1, companyId: null, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
|
service.Verify(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_RosterFound_Returns200()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.GetRosterAsync(1, null, "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleRoster());
|
||||||
|
|
||||||
|
var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||||
|
ok.Value.Should().BeEquivalentTo(SampleRoster());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_RosterMissing_Returns404()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((VendorRosterDTO?)null);
|
||||||
|
|
||||||
|
var result = await NewController(service).Get(vendorId: 99, companyId: null, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_ValidationException_Returns400()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new ValidationException("A vendor id or company id is required."));
|
||||||
|
|
||||||
|
var result = await NewController(service).Get(vendorId: null, companyId: null, CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Unauthenticated_Returns401()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
var controller = NewController(service, userId: null);
|
||||||
|
|
||||||
|
var result = await controller.Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
|
service.Verify(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Returns200()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleRoster());
|
||||||
|
|
||||||
|
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_ValidationException_Returns400()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new ValidationException("At least one company phone or email is required."));
|
||||||
|
|
||||||
|
var result = await NewController(service).Create(new CreateVendorRosterDTO { Name = "Acme" }, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_Unauthenticated_Returns401()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
var controller = NewController(service, userId: null);
|
||||||
|
|
||||||
|
var result = await controller.Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
||||||
|
service.Verify(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_ValidationException_Returns400()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new ValidationException("Duplicate technician ids are not allowed."));
|
||||||
|
|
||||||
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<BadRequestObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_CompanyNotFound_Returns404()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new KeyNotFoundException("not found"));
|
||||||
|
|
||||||
|
var result = await NewController(service).Reconcile(404, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_OpenWorkOrderConflict_ReturnsStable409()
|
||||||
|
{
|
||||||
|
var blocked = new List<LinkedWorkOrderInfo>
|
||||||
|
{
|
||||||
|
new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", LifecycleStatus = LifecycleStatus.Scheduled }
|
||||||
|
};
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new VendorRosterConflictException("blocked", blocked));
|
||||||
|
|
||||||
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
|
||||||
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_StaleRowVersion_ReturnsStable409WithoutExceptionText()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.ReconcileRosterAsync(It.IsAny<int>(), It.IsAny<ReconcileVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
|
||||||
|
|
||||||
|
var result = await NewController(service).Reconcile(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
|
||||||
|
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
|
||||||
|
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_UnexpectedException_ReturnsSanitized500()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorCompanyRosterService>();
|
||||||
|
service.Setup(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), "42", It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("secret stack details"));
|
||||||
|
|
||||||
|
var result = await NewController(service).Get(vendorId: 1, companyId: null, CancellationToken.None);
|
||||||
|
|
||||||
|
var serverError = result.Should().BeOfType<ObjectResult>().Subject;
|
||||||
|
serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
|
||||||
|
serverError.Value!.ToString()!.Should().NotContain("secret stack details");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,495 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class VendorCompanyRosterDataServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext(string dbName)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(dbName)
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(ApplicationDbContext context, string companyName, params string[] technicianNames)
|
||||||
|
{
|
||||||
|
var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant() };
|
||||||
|
context.VendorCompanies.Add(company);
|
||||||
|
|
||||||
|
foreach (var name in technicianNames)
|
||||||
|
{
|
||||||
|
context.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = companyName,
|
||||||
|
ContactName = name,
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Company = company
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
return (company.Id, context.Vendors.First(v => v.CompanyId == company.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateRoster_PersistsCompanyAndTechniciansAtomically()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
Name = "Atomic Co",
|
||||||
|
Email = "atomic@example.com",
|
||||||
|
Notes = "shared notes",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "T1" },
|
||||||
|
new() { ContactName = "T2" }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
roster.CompanyId.Should().BeGreaterThan(0);
|
||||||
|
roster.Technicians.Should().HaveCount(2);
|
||||||
|
roster.Notes.Should().Be("shared notes");
|
||||||
|
|
||||||
|
var company = context.VendorCompanies.Single(c => c.Id == roster.CompanyId);
|
||||||
|
company.Name.Should().Be("Atomic Co");
|
||||||
|
context.Vendors.Count(v => v.CompanyId == company.Id).Should().Be(2);
|
||||||
|
context.Vendors.Where(v => v.CompanyId == company.Id).Should().OnlyContain(v => v.CompanyName == "Atomic Co");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoster_ByVendorId_ResolvesCompanyAndAllTechnicians()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, vendorId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Resolve Co", "A", "B");
|
||||||
|
companyId = cid;
|
||||||
|
vendorId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "A").Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
var roster = await service.GetRosterAsync(vendorId, null, CancellationToken.None);
|
||||||
|
|
||||||
|
roster.Should().NotBeNull();
|
||||||
|
roster!.CompanyId.Should().Be(companyId);
|
||||||
|
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "A", "B" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoster_WithMismatchedVendorAndCompanyIds_ReturnsNull()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int firstCompanyId, secondVendorId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (firstId, _) = await SeedCompanyWithTechnicianAsync(seed, "First Co", "A");
|
||||||
|
var (_, secondVendor) = await SeedCompanyWithTechnicianAsync(seed, "Second Co", "B");
|
||||||
|
firstCompanyId = firstId;
|
||||||
|
secondVendorId = secondVendor.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
var roster = await service.GetRosterAsync(secondVendorId, firstCompanyId, CancellationToken.None);
|
||||||
|
|
||||||
|
roster.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_RenamesCompanyAndTechnicianAndUpdatesNotes()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, techId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Old Co", "Old Name");
|
||||||
|
companyId = cid;
|
||||||
|
techId = vendor.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
var roster = await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "New Co",
|
||||||
|
Email = "new@example.com",
|
||||||
|
Notes = "updated notes",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { Id = techId, ContactName = "New Name", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
roster.Name.Should().Be("New Co");
|
||||||
|
roster.Notes.Should().Be("updated notes");
|
||||||
|
var tech = context.Vendors.Single(v => v.Id == techId);
|
||||||
|
tech.ContactName.Should().Be("New Name");
|
||||||
|
tech.CompanyName.Should().Be("New Co");
|
||||||
|
context.VendorCompanies.Single(c => c.Id == companyId).Notes.Should().Be("updated notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_SoftDeletesOmittedTechnicianWithoutOpenWorkOrders()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, keepId, dropId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Drop Co", "Keep", "Drop");
|
||||||
|
companyId = cid;
|
||||||
|
keepId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Keep").Id;
|
||||||
|
dropId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Drop").Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Drop Co",
|
||||||
|
Email = "drop@example.com",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { Id = keepId, ContactName = "Keep", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var kept = verify.Vendors.Single(v => v.Id == keepId);
|
||||||
|
kept.IsDeleted.Should().NotBeTrue();
|
||||||
|
var dropped = verify.Vendors.Single(v => v.Id == dropId);
|
||||||
|
dropped.IsDeleted.Should().BeTrue();
|
||||||
|
dropped.IsActive.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_RemovingAllTechniciansLeavesZeroVendorsAndCompanyRemains()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Empty Co", "T1", "T2");
|
||||||
|
companyId = cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Empty Co",
|
||||||
|
Email = "empty@example.com",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>()
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
verify.Vendors.Count(v => v.CompanyId == companyId && (v.IsDeleted == null || v.IsDeleted == false)).Should().Be(0);
|
||||||
|
verify.VendorCompanies.Any(c => c.Id == companyId).Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_RemovalBlockedByOpenWorkOrdersFailsWholeSave()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, vendorId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Blocked Co", "Busy");
|
||||||
|
companyId = cid;
|
||||||
|
vendorId = vendor.Id;
|
||||||
|
seed.workOrders.Add(new WorkOrder { Id = 800, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled" });
|
||||||
|
seed.Dispatches.Add(new Dispatch { VendorId = vendorId, WorkOrderId = 800, Vendor = vendor });
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
var actCall = () => service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 },
|
||||||
|
Name = "Blocked Co",
|
||||||
|
Email = "blocked@example.com",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>()
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
await actCall.Should().ThrowAsync<VendorRosterConflictException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var tech = verify.Vendors.Single(v => v.Id == vendorId);
|
||||||
|
tech.IsDeleted.Should().NotBeTrue();
|
||||||
|
tech.IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoster_ForwardsCancellation()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
var act = () => service.GetRosterAsync(1, null, cts.Token);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_PropagatesCompanyLocationAndPhoneToRetainedTechnician()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, techId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var company = new VendorCompany
|
||||||
|
{
|
||||||
|
Name = "Loc Co",
|
||||||
|
NormalizedName = "loc co",
|
||||||
|
CompanyPhone = "(111) 111-0001",
|
||||||
|
Address = "Old Addr",
|
||||||
|
City = "OldCity",
|
||||||
|
State = "OS",
|
||||||
|
Zip = "00001",
|
||||||
|
GoogleMapsUrl = "https://old.example/map"
|
||||||
|
};
|
||||||
|
seed.VendorCompanies.Add(company);
|
||||||
|
seed.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = "Loc Co",
|
||||||
|
ContactName = "Keeper",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Company = company,
|
||||||
|
CompanyPhone = "(111) 111-0001",
|
||||||
|
Address = "Old Addr",
|
||||||
|
City = "OldCity",
|
||||||
|
State = "OS",
|
||||||
|
Zip = "00001",
|
||||||
|
GoogleMapsUrl = "https://old.example/map"
|
||||||
|
});
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
companyId = company.Id;
|
||||||
|
techId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Loc Co",
|
||||||
|
CompanyPhone = "(222) 222-0002",
|
||||||
|
Address = "New Addr",
|
||||||
|
City = "NewCity",
|
||||||
|
State = "NS",
|
||||||
|
Zip = "00002",
|
||||||
|
GoogleMapsUrl = "https://new.example/map",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { Id = techId, ContactName = "Keeper", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var tech = verify.Vendors.Single(v => v.Id == techId);
|
||||||
|
tech.CompanyPhone.Should().Be("(222) 222-0002");
|
||||||
|
tech.Address.Should().Be("New Addr");
|
||||||
|
tech.City.Should().Be("NewCity");
|
||||||
|
tech.State.Should().Be("NS");
|
||||||
|
tech.Zip.Should().Be("00002");
|
||||||
|
tech.GoogleMapsUrl.Should().Be("https://new.example/map");
|
||||||
|
|
||||||
|
var directory = new VendorDataService(verify);
|
||||||
|
var newCity = await directory.GetDirectoryPagedAsync(1, 10, search: "NewCity");
|
||||||
|
newCity.Items.Should().ContainSingle(row => row.VendorId == techId);
|
||||||
|
|
||||||
|
var oldCity = await directory.GetDirectoryPagedAsync(1, 10, search: "OldCity");
|
||||||
|
oldCity.Items.Should().NotContain(row => row.VendorId == techId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_ClearsCompanyOwnedFieldsOnRetainedTechnicianWhenCompanyClearsThem()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId, techId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var company = new VendorCompany
|
||||||
|
{
|
||||||
|
Name = "Clear Co",
|
||||||
|
NormalizedName = "clear co",
|
||||||
|
CompanyPhone = "(333) 333-0003",
|
||||||
|
Address = "Keep Addr",
|
||||||
|
City = "KeepCity",
|
||||||
|
State = "KS",
|
||||||
|
Zip = "00003",
|
||||||
|
GoogleMapsUrl = "https://keep.example/map"
|
||||||
|
};
|
||||||
|
seed.VendorCompanies.Add(company);
|
||||||
|
seed.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = "Clear Co",
|
||||||
|
ContactName = "Keeper",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Company = company,
|
||||||
|
CompanyPhone = "(333) 333-0003",
|
||||||
|
Address = "Keep Addr",
|
||||||
|
City = "KeepCity",
|
||||||
|
State = "KS",
|
||||||
|
Zip = "00003",
|
||||||
|
GoogleMapsUrl = "https://keep.example/map"
|
||||||
|
});
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
companyId = company.Id;
|
||||||
|
techId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Clear Co",
|
||||||
|
CompanyPhone = null,
|
||||||
|
Address = null,
|
||||||
|
City = null,
|
||||||
|
State = null,
|
||||||
|
Zip = null,
|
||||||
|
GoogleMapsUrl = null,
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { Id = techId, ContactName = "Keeper", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var tech = verify.Vendors.Single(v => v.Id == techId);
|
||||||
|
tech.CompanyPhone.Should().BeNull();
|
||||||
|
tech.Address.Should().BeNull();
|
||||||
|
tech.City.Should().BeNull();
|
||||||
|
tech.State.Should().BeNull();
|
||||||
|
tech.Zip.Should().BeNull();
|
||||||
|
tech.GoogleMapsUrl.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRoster_NewTechnicianReceivesCompanyPhoneAndLocation()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
int companyId;
|
||||||
|
using (var seed = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var company = new VendorCompany
|
||||||
|
{
|
||||||
|
Name = "Add Co",
|
||||||
|
NormalizedName = "add co"
|
||||||
|
};
|
||||||
|
seed.VendorCompanies.Add(company);
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
companyId = company.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
int newTechId;
|
||||||
|
using (var act = NewContext(dbName))
|
||||||
|
{
|
||||||
|
var service = new VendorCompanyRosterDataService(act);
|
||||||
|
await service.SaveRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Add Co",
|
||||||
|
CompanyPhone = "(444) 444-0004",
|
||||||
|
Address = "Add Addr",
|
||||||
|
City = "AddCity",
|
||||||
|
State = "AS",
|
||||||
|
Zip = "00004",
|
||||||
|
GoogleMapsUrl = "https://add.example/map",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Fresh", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
newTechId = act.Vendors.First(v => v.CompanyId == companyId && v.ContactName == "Fresh").Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var verify = NewContext(dbName);
|
||||||
|
var tech = verify.Vendors.Single(v => v.Id == newTechId);
|
||||||
|
tech.CompanyPhone.Should().Be("(444) 444-0004");
|
||||||
|
tech.Address.Should().Be("Add Addr");
|
||||||
|
tech.City.Should().Be("AddCity");
|
||||||
|
tech.State.Should().Be("AS");
|
||||||
|
tech.Zip.Should().Be("00004");
|
||||||
|
tech.GoogleMapsUrl.Should().Be("https://add.example/map");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateRoster_NewTechnicianReceivesCompanyPhoneAndLocation()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
using var context = NewContext(dbName);
|
||||||
|
var service = new VendorCompanyRosterDataService(context);
|
||||||
|
|
||||||
|
var roster = await service.CreateRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
Name = "Create Co",
|
||||||
|
CompanyPhone = "(555) 555-0005",
|
||||||
|
Address = "Create Addr",
|
||||||
|
City = "CreateCity",
|
||||||
|
State = "CS",
|
||||||
|
Zip = "00005",
|
||||||
|
GoogleMapsUrl = "https://create.example/map",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Brand", IsActive = true }
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var tech = context.Vendors.Single(v => v.CompanyId == roster.CompanyId);
|
||||||
|
tech.CompanyPhone.Should().Be("(555) 555-0005");
|
||||||
|
tech.Address.Should().Be("Create Addr");
|
||||||
|
tech.City.Should().Be("CreateCity");
|
||||||
|
tech.State.Should().Be("CS");
|
||||||
|
tech.Zip.Should().Be("00005");
|
||||||
|
tech.GoogleMapsUrl.Should().Be("https://create.example/map");
|
||||||
|
}
|
||||||
|
}
|
||||||
383
Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs
Normal file
383
Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class VendorCompanyRosterServiceTests
|
||||||
|
{
|
||||||
|
private static VendorCompanyRosterService NewService(Mock<IVendorCompanyRosterDataService> data) => new(data.Object);
|
||||||
|
|
||||||
|
private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new()
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
Name = "Acme",
|
||||||
|
CompanyPhone = "(312) 555-0199",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 },
|
||||||
|
Technicians = technicianIds.Select(id => new VendorRosterTechnicianReadModel { Id = id, ContactName = $"Tech {id}", IsActive = true }).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_WithoutAuthenticatedUser_Throws()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var act = () => NewService(data).GetRosterAsync(1, null, "", CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||||
|
data.Verify(x => x.GetRosterAsync(It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_WithoutVendorOrCompanyId_ThrowsValidation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var act = () => NewService(data).GetRosterAsync(null, null, "42", CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithoutCompanyPhoneOrEmail_ThrowsContactGroupValidation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO { Name = "Acme" };
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_InvalidCompanyPhone_DoesNotSatisfyContactGroup()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" };
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone));
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_RejectsExistingTechnicianIds()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 7, ContactName = "Riley" } }
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_AcceptsZeroTechniciansWhenContactGroupPresent()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel());
|
||||||
|
|
||||||
|
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO>()
|
||||||
|
}, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.Technicians.Should().BeEmpty();
|
||||||
|
captured.Name.Should().Be("Acme");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_RejectsDuplicateTechnicianIds()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO>
|
||||||
|
{
|
||||||
|
new() { Id = 5, ContactName = "Dup" },
|
||||||
|
new() { Id = 5, ContactName = "Dup Again" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_RejectsMismatchedTechnicianIds()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1, 2));
|
||||||
|
|
||||||
|
var dto = new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO>
|
||||||
|
{
|
||||||
|
new() { Id = 1, ContactName = "Ok" },
|
||||||
|
new() { Id = 999, ContactName = "Foreign" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_RejectsNonHttpsMapUrl()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
GoogleMapsUrl = "http://maps.google.com/acme"
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_NormalizesTechnicianPhoneBeforeForwarding()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel());
|
||||||
|
|
||||||
|
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Riley", Phone = "+1 312 555 0100" }
|
||||||
|
}
|
||||||
|
}, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_RejectsInvalidTechnicianPhoneFormat()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO>
|
||||||
|
{
|
||||||
|
new() { ContactName = "Riley", Phone = "555-1234" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_ParsesRowVersionAndForwardsCompanyId()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme Co",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
|
}, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Should().NotBeNull();
|
||||||
|
captured!.CompanyId.Should().Be(7);
|
||||||
|
captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I="));
|
||||||
|
captured.Name.Should().Be("Acme Co");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_RejectsMalformedRowVersion()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleReadModel(7));
|
||||||
|
|
||||||
|
var dto = new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "not-valid-base64!!",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||||
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_RejectsMissingRowVersion()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<ValidationException>())
|
||||||
|
.Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion));
|
||||||
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_NullTechnicians_IsTreatedAsEmptyRoster()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel());
|
||||||
|
var dto = new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = null!
|
||||||
|
};
|
||||||
|
|
||||||
|
await NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
captured!.Technicians.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Riley", "not-an-email", "Phone")]
|
||||||
|
[InlineData("Riley", "tech@example.com", "CarrierPigeon")]
|
||||||
|
public async Task Create_RejectsInvalidTechnicianFields(string name, string email, string preferredContact)
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
var dto = new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO>
|
||||||
|
{
|
||||||
|
new() { ContactName = name, Email = email, PreferredContact = preferredContact }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).CreateRosterAsync(dto, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
data.Verify(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_AcceptsTechnicianWithoutNameOrPhone()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
VendorCompanyRosterWriteModel? captured = null;
|
||||||
|
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((model, _) => captured = model)
|
||||||
|
.ReturnsAsync(SampleReadModel());
|
||||||
|
|
||||||
|
await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO>
|
||||||
|
{
|
||||||
|
new() { ContactName = "", Phone = "", PreferredContact = "Phone" }
|
||||||
|
}
|
||||||
|
}, "42", CancellationToken.None);
|
||||||
|
|
||||||
|
captured!.Technicians.Should().ContainSingle();
|
||||||
|
captured.Technicians.Single().ContactName.Should().BeEmpty();
|
||||||
|
captured.Technicians.Single().Phone.Should().BeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_UnknownCompanyWithTechnicianIds_ThrowsValidation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 404, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((VendorCompanyRosterReadModel?)null);
|
||||||
|
|
||||||
|
var dto = new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
|
};
|
||||||
|
|
||||||
|
var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<ValidationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reconcile_ForwardsCancellation()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorCompanyRosterDataService>();
|
||||||
|
data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(SampleReadModel(7, 1));
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
RowVersion = "AAAAAAAAD8I=",
|
||||||
|
Name = "Acme",
|
||||||
|
Email = "acme@example.com",
|
||||||
|
Technicians = new List<RosterTechnicianInputDTO> { new() { Id = 1, ContactName = "T" } }
|
||||||
|
}, "42", cts.Token);
|
||||||
|
|
||||||
|
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,38 @@ public class VendorControllerTests
|
||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorList_ForwardsRequestCancellationToken()
|
||||||
|
{
|
||||||
|
var service = new Mock<IVendorService>();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var token = cancellation.Token;
|
||||||
|
service.Setup(x => x.GetVendorDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token))
|
||||||
|
.ReturnsAsync(new PagedResult<VendorDirectoryItemDTO>());
|
||||||
|
|
||||||
|
await NewController(service).GetVendorList(search: "clayton", cancellationToken: token);
|
||||||
|
|
||||||
|
service.Verify(x => x.GetVendorDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
|
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using Data.SeaHavenIndustries.Enums;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -321,4 +322,176 @@ public class VendorDataServiceTests
|
||||||
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
|
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
|
||||||
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var soloCompany = new VendorCompany { Name = "Solo Co", NormalizedName = "solo co", Notes = "company notes" };
|
||||||
|
var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" };
|
||||||
|
context.VendorCompanies.AddRange(soloCompany, techCompany);
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = "Tech Co",
|
||||||
|
ContactName = "Pat",
|
||||||
|
City = "Austin",
|
||||||
|
State = "TX",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Company = techCompany
|
||||||
|
},
|
||||||
|
new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = "Other",
|
||||||
|
ContactName = "No Company Tech",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
CompanyId = null
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
var (items, totalCount) = await service.GetDirectoryPagedAsync(1, 50, isActive: true);
|
||||||
|
|
||||||
|
totalCount.Should().Be(3);
|
||||||
|
var companyOnly = items.Single(i => i.CompanyName == "Solo Co");
|
||||||
|
companyOnly.VendorId.Should().BeNull();
|
||||||
|
companyOnly.CompanyId.Should().Be(soloCompany.Id);
|
||||||
|
companyOnly.Notes.Should().Be("company notes");
|
||||||
|
|
||||||
|
items.Should().Contain(i => i.ContactName == "Pat" && i.VendorId != null && i.CompanyId == techCompany.Id);
|
||||||
|
items.Should().Contain(i => i.ContactName == "No Company Tech" && i.VendorId != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("trade")]
|
||||||
|
[InlineData("location")]
|
||||||
|
[InlineData("job-bucket")]
|
||||||
|
public void BuildDirectoryUnionQuery_TranslatesToSqlServerUnion(string filter)
|
||||||
|
{
|
||||||
|
using var context = NewSqlServerContext();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var query = filter switch
|
||||||
|
{
|
||||||
|
"trade" => service.BuildDirectoryUnionQuery(trades: new[] { "Plumbing" }),
|
||||||
|
"location" => service.BuildDirectoryUnionQuery(locations: new[] { "Chicago, IL" }),
|
||||||
|
_ => service.BuildDirectoryUnionQuery(jobBuckets: new[] { "under-50" })
|
||||||
|
};
|
||||||
|
|
||||||
|
var sql = query.ToQueryString();
|
||||||
|
|
||||||
|
sql.Should().Contain("SELECT");
|
||||||
|
sql.Should().Contain("UNION");
|
||||||
|
if (filter == "trade")
|
||||||
|
sql.Should().Contain("TradeSpecialties");
|
||||||
|
if (filter == "location")
|
||||||
|
sql.Should().Contain("City");
|
||||||
|
if (filter == "job-bucket")
|
||||||
|
sql.Should().Contain("TotalJobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompaniesForFacetsAsync_IncludesZeroTechnicianCompanyForActiveAndNullStatus()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var rosterService = new VendorCompanyRosterDataService(context);
|
||||||
|
await rosterService.CreateRosterAsync(new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
Name = "Solo Co",
|
||||||
|
Email = "solo@example.com",
|
||||||
|
ActorUserId = "42",
|
||||||
|
Technicians = new List<RosterTechnicianWriteModel>()
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" };
|
||||||
|
context.VendorCompanies.Add(techCompany);
|
||||||
|
context.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = "Tech Co",
|
||||||
|
ContactName = "Pat",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
Company = techCompany
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
|
||||||
|
active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" });
|
||||||
|
|
||||||
|
var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None);
|
||||||
|
noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" });
|
||||||
|
|
||||||
|
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
|
||||||
|
inactive.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompaniesForFacetsAsync_InactiveStatusReturnsOnlyCompaniesWithInactiveTechnicians()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var activeOnlyCo = new VendorCompany { Name = "Active Only Co", NormalizedName = "active only co" };
|
||||||
|
var inactiveOnlyCo = new VendorCompany { Name = "Inactive Only Co", NormalizedName = "inactive only co" };
|
||||||
|
var mixedCo = new VendorCompany { Name = "Mixed Co", NormalizedName = "mixed co" };
|
||||||
|
context.VendorCompanies.AddRange(activeOnlyCo, inactiveOnlyCo, mixedCo);
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
new Vendor { CompanyName = "Active Only Co", IsActive = true, IsDeleted = false, Company = activeOnlyCo },
|
||||||
|
new Vendor { CompanyName = "Inactive Only Co", IsActive = false, IsDeleted = false, Company = inactiveOnlyCo },
|
||||||
|
new Vendor { CompanyName = "Mixed Co", IsActive = true, IsDeleted = false, Company = mixedCo },
|
||||||
|
new Vendor { CompanyName = "Mixed Co", IsActive = false, IsDeleted = false, Company = mixedCo });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
|
||||||
|
inactive.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Inactive Only Co", "Mixed Co" });
|
||||||
|
|
||||||
|
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
|
||||||
|
active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Mixed Co" });
|
||||||
|
|
||||||
|
var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None);
|
||||||
|
noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Inactive Only Co", "Mixed Co" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCompaniesForFacetsAsync_TreatsCompanyWithOnlyDeletedTechniciansAsZeroTechnician()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
var company = new VendorCompany { Name = "Ghost Co", NormalizedName = "ghost co" };
|
||||||
|
context.VendorCompanies.Add(company);
|
||||||
|
context.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = "Ghost Co",
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = true,
|
||||||
|
Company = company
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
|
||||||
|
active.Single().Name.Should().Be("Ghost Co");
|
||||||
|
|
||||||
|
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
|
||||||
|
inactive.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void BuildCompaniesForFacetsQuery_TranslatesToSqlServerWithoutClientEvaluation(bool? isActive)
|
||||||
|
{
|
||||||
|
using var context = NewSqlServerContext();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var sql = service.BuildCompaniesForFacetsQuery(isActive).ToQueryString();
|
||||||
|
|
||||||
|
sql.Should().Contain("SELECT");
|
||||||
|
sql.Should().Contain("VendorCompanies");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using FluentValidation;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
@ -146,6 +147,43 @@ public class VendorServiceTests
|
||||||
}, options => options.ExcludingMissingMembers());
|
}, options => options.ExcludingMissingMembers());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorDirectoryPaged_ForwardsCancellationToken()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var token = cancellation.Token;
|
||||||
|
data.Setup(x => x.GetDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token))
|
||||||
|
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 0));
|
||||||
|
|
||||||
|
await NewService(data).GetVendorDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
cancellationToken: token);
|
||||||
|
|
||||||
|
data.Verify(x => x.GetDirectoryPagedAsync(
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
"clayton",
|
||||||
|
true,
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||||
|
token), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||||
{
|
{
|
||||||
|
|
@ -209,20 +247,44 @@ public class VendorServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
|
public void CreateVendor_AcceptsOptionalTechnicianButRequiresContactGroupAndHttpsUrl()
|
||||||
{
|
{
|
||||||
var validator = new CreateVendorValidation();
|
var validator = new CreateVendorValidation();
|
||||||
|
|
||||||
var result = validator.Validate(new CreateVendorDTO
|
// SH-199: technician (ContactName) is optional; zero-technician create is valid
|
||||||
|
// as long as a company phone or email is present and the map URL is HTTPS.
|
||||||
|
var valid = validator.Validate(new CreateVendorDTO
|
||||||
{
|
{
|
||||||
Name = "Gateway Plumbing",
|
Name = "Gateway Plumbing",
|
||||||
ContactName = "",
|
ContactName = "",
|
||||||
|
Email = "dispatch@gateway.example",
|
||||||
|
GoogleMapsUrl = "https://maps.google.com/gateway"
|
||||||
|
});
|
||||||
|
|
||||||
|
valid.IsValid.Should().BeTrue();
|
||||||
|
valid.Errors.Should().NotContain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
|
||||||
|
|
||||||
|
// Missing both company phone and email fails the contact-group rule.
|
||||||
|
var noContact = validator.Validate(new CreateVendorDTO
|
||||||
|
{
|
||||||
|
Name = "Gateway Plumbing",
|
||||||
|
ContactName = "",
|
||||||
|
GoogleMapsUrl = "https://maps.google.com/gateway"
|
||||||
|
});
|
||||||
|
|
||||||
|
noContact.IsValid.Should().BeFalse();
|
||||||
|
noContact.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.CompanyPhone));
|
||||||
|
|
||||||
|
// Non-HTTPS map URL still rejected.
|
||||||
|
var badUrl = validator.Validate(new CreateVendorDTO
|
||||||
|
{
|
||||||
|
Name = "Gateway Plumbing",
|
||||||
|
Email = "dispatch@gateway.example",
|
||||||
GoogleMapsUrl = "http://maps.google.com/gateway"
|
GoogleMapsUrl = "http://maps.google.com/gateway"
|
||||||
});
|
});
|
||||||
|
|
||||||
result.IsValid.Should().BeFalse();
|
badUrl.IsValid.Should().BeFalse();
|
||||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
|
badUrl.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
||||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -283,7 +345,8 @@ public class VendorServiceTests
|
||||||
{
|
{
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
CompanyId = 77,
|
CompanyId = 77,
|
||||||
ContactName = "Riley"
|
ContactName = "Riley",
|
||||||
|
Email = "acme@example.com"
|
||||||
}, "42");
|
}, "42");
|
||||||
|
|
||||||
saved.Should().NotBeNull();
|
saved.Should().NotBeNull();
|
||||||
|
|
@ -327,7 +390,8 @@ public class VendorServiceTests
|
||||||
{
|
{
|
||||||
Name = "Acme",
|
Name = "Acme",
|
||||||
ContactName = "Riley",
|
ContactName = "Riley",
|
||||||
Phone = "+1 312 555 0100"
|
Phone = "+1 312 555 0100",
|
||||||
|
Email = "acme@example.com"
|
||||||
}, "42");
|
}, "42");
|
||||||
|
|
||||||
saved!.Phone.Should().Be("(312) 555-0100");
|
saved!.Phone.Should().Be("(312) 555-0100");
|
||||||
|
|
@ -488,4 +552,102 @@ public class VendorServiceTests
|
||||||
.WithMessage("*404*not found");
|
.WithMessage("*404*not found");
|
||||||
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
|
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFacetsAsync_CompaniesAndLocationsUseCompanyOwnedFieldsOverTechnicianFields()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<VendorCompany>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Name = "Owner Co",
|
||||||
|
CompanyPhone = "(555) 000-0001",
|
||||||
|
Email = "owner@example.com",
|
||||||
|
Address = "Owner Addr",
|
||||||
|
City = "OwnerCity",
|
||||||
|
State = "OS",
|
||||||
|
Zip = "00001",
|
||||||
|
GoogleMapsUrl = "https://owner.example/map"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Vendor>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
CompanyName = "Owner Co",
|
||||||
|
ContactName = "Pat",
|
||||||
|
City = "TechCity",
|
||||||
|
State = "TS",
|
||||||
|
Email = "tech@example.com",
|
||||||
|
CompanyPhone = "(555) 000-9999",
|
||||||
|
TradeSpecialties = "Plumbing"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||||
|
|
||||||
|
var company = facets.Companies.Single();
|
||||||
|
company.Name.Should().Be("Owner Co");
|
||||||
|
company.CompanyId.Should().Be(1);
|
||||||
|
company.CompanyPhone.Should().Be("(555) 000-0001");
|
||||||
|
company.Email.Should().Be("owner@example.com");
|
||||||
|
company.Address.Should().Be("Owner Addr");
|
||||||
|
company.City.Should().Be("OwnerCity");
|
||||||
|
company.State.Should().Be("OS");
|
||||||
|
company.Zip.Should().Be("00001");
|
||||||
|
company.GoogleMapsUrl.Should().Be("https://owner.example/map");
|
||||||
|
company.PreferredContact.Should().BeNull();
|
||||||
|
|
||||||
|
var location = facets.Locations.Single();
|
||||||
|
location.City.Should().Be("OwnerCity");
|
||||||
|
location.State.Should().Be("OS");
|
||||||
|
location.Label.Should().Be("OwnerCity, OS");
|
||||||
|
|
||||||
|
facets.Trades.Should().BeEquivalentTo(new[] { "Plumbing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFacetsAsync_CompaniesAreUniqueAndOrderedCaseInsensitively()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<VendorCompany>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Name = "Beta Co" },
|
||||||
|
new() { Id = 2, Name = "alpha co" },
|
||||||
|
new() { Id = 3, Name = "Alpha Co" }
|
||||||
|
});
|
||||||
|
data.Setup(x => x.GetVendorsForFacetsAsync(null, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Vendor>());
|
||||||
|
|
||||||
|
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
// Case-insensitive dedup collapses "alpha co"/"Alpha Co" to one entry, and
|
||||||
|
// ordering places "alpha co" before "Beta Co" (case-insensitive, not Ordinal).
|
||||||
|
facets.Companies.Select(c => c.Name).Should().BeEquivalentTo(
|
||||||
|
new[] { "alpha co", "Beta Co" },
|
||||||
|
opts => opts.WithStrictOrdering());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies()
|
||||||
|
{
|
||||||
|
var data = new Mock<IVendorDataService>();
|
||||||
|
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<VendorCompany> { new() { Id = 1, Name = "No Trade Co" } });
|
||||||
|
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Vendor>
|
||||||
|
{
|
||||||
|
new() { Id = 20, CompanyName = "Trade Co", TradeSpecialties = "HVAC, Roofing" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||||
|
|
||||||
|
facets.Trades.Should().BeEquivalentTo(new[] { "HVAC", "Roofing" }, opts => opts.WithStrictOrdering());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
using Api.SeaHavenIndustries.Helper;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
{
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/vendor-company-roster")]
|
||||||
|
public class VendorCompanyRosterController : Controller
|
||||||
|
{
|
||||||
|
private readonly IVendorCompanyRosterService _rosterService;
|
||||||
|
private readonly ILogger<VendorCompanyRosterController> _logger;
|
||||||
|
|
||||||
|
public VendorCompanyRosterController(
|
||||||
|
IVendorCompanyRosterService rosterService,
|
||||||
|
ILogger<VendorCompanyRosterController> logger)
|
||||||
|
{
|
||||||
|
_rosterService = rosterService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get(
|
||||||
|
[FromQuery] int? vendorId,
|
||||||
|
[FromQuery] int? companyId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userId == null)
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var roster = await _rosterService.GetRosterAsync(vendorId, companyId, userId, cancellationToken);
|
||||||
|
if (roster == null)
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Vendor roster not found" });
|
||||||
|
|
||||||
|
return Ok(roster);
|
||||||
|
}
|
||||||
|
catch (ValidationException vex)
|
||||||
|
{
|
||||||
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
|
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateVendorRosterDTO model, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userId == null)
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var roster = await _rosterService.CreateRosterAsync(model, userId, cancellationToken);
|
||||||
|
return Ok(roster);
|
||||||
|
}
|
||||||
|
catch (ValidationException vex)
|
||||||
|
{
|
||||||
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
|
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{companyId:int}")]
|
||||||
|
public async Task<IActionResult> Reconcile(
|
||||||
|
int companyId,
|
||||||
|
[FromBody] ReconcileVendorRosterDTO model,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userId == null)
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var roster = await _rosterService.ReconcileRosterAsync(companyId, model, userId, cancellationToken);
|
||||||
|
return Ok(roster);
|
||||||
|
}
|
||||||
|
catch (ValidationException vex)
|
||||||
|
{
|
||||||
|
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||||
|
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
|
||||||
|
}
|
||||||
|
catch (VendorRosterConflictException conflict)
|
||||||
|
{
|
||||||
|
return Conflict(new
|
||||||
|
{
|
||||||
|
Status = "Conflict",
|
||||||
|
Message = _logger.Sanitize(conflict, "Vendor roster changes conflict with open work orders"),
|
||||||
|
BlockedWorkOrders = conflict.BlockedWorkOrders
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
return Conflict(new
|
||||||
|
{
|
||||||
|
Status = "Conflict",
|
||||||
|
Message = "The vendor company was modified by another user. Refresh and retry.",
|
||||||
|
Code = 409
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,9 +35,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[FromQuery] string[]? companies = null,
|
[FromQuery] string[]? companies = null,
|
||||||
[FromQuery] string[]? trades = null,
|
[FromQuery] string[]? trades = null,
|
||||||
[FromQuery] string[]? locations = null,
|
[FromQuery] string[]? locations = null,
|
||||||
[FromQuery] string[]? jobBuckets = null)
|
[FromQuery] string[]? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var pagedResult = await _vendorService.GetVendorsPagedAsync(
|
var pagedResult = await _vendorService.GetVendorDirectoryPagedAsync(
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
search,
|
search,
|
||||||
|
|
@ -45,12 +46,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
companies,
|
companies,
|
||||||
trades,
|
trades,
|
||||||
locations,
|
locations,
|
||||||
jobBuckets);
|
jobBuckets,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var data = pagedResult.Items.Select(v => new
|
var data = pagedResult.Items.Select(v => new
|
||||||
{
|
{
|
||||||
v.Id,
|
v.Id,
|
||||||
CompanyName = v.Name,
|
CompanyName = v.CompanyName,
|
||||||
v.CompanyId,
|
v.CompanyId,
|
||||||
v.ContactName,
|
v.ContactName,
|
||||||
v.Email,
|
v.Email,
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,10 @@ namespace Data.SeaHavenIndustries
|
||||||
.HasIndex(c => c.NormalizedName)
|
.HasIndex(c => c.NormalizedName)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
builder.Entity<VendorCompany>()
|
||||||
|
.Property(c => c.RowVersion)
|
||||||
|
.IsRowVersion();
|
||||||
|
|
||||||
builder.Entity<Vendor>()
|
builder.Entity<Vendor>()
|
||||||
.HasOne(v => v.Company)
|
.HasOne(v => v.Company)
|
||||||
.WithMany(c => c.Vendors)
|
.WithMany(c => c.Vendors)
|
||||||
|
|
@ -242,6 +246,12 @@ namespace Data.SeaHavenIndustries
|
||||||
if (entry.State == EntityState.Modified)
|
if (entry.State == EntityState.Modified)
|
||||||
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var entry in ChangeTracker.Entries<VendorCompany>())
|
||||||
|
{
|
||||||
|
if (entry.State == EntityState.Modified)
|
||||||
|
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] IncrementRowVersion(byte[]? current)
|
private static byte[] IncrementRowVersion(byte[]? current)
|
||||||
|
|
|
||||||
3787
Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.Designer.cs
generated
Normal file
3787
Data.SeaHavenIndustries/Migrations/20260731230547_SH198_VendorCompanyNotesAndRowVersion.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,67 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SH198_VendorCompanyNotesAndRowVersion : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Notes",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<byte[]>(
|
||||||
|
name: "RowVersion",
|
||||||
|
table: "VendorCompanies",
|
||||||
|
type: "rowversion",
|
||||||
|
rowVersion: true,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Backfill VendorCompany.Notes (vendor-directory source) deterministically
|
||||||
|
// from the most recently modified then highest-id nonempty Vendor.Notes.
|
||||||
|
// Vendor.Notes is retained separately for work-order editor compatibility;
|
||||||
|
// roster/directory paths read VendorCompany.Notes.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
;WITH SourceNotes AS (
|
||||||
|
SELECT
|
||||||
|
v.[CompanyId],
|
||||||
|
v.[Notes],
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY v.[CompanyId]
|
||||||
|
ORDER BY
|
||||||
|
v.[LastModificationTime] DESC,
|
||||||
|
v.[Id] DESC
|
||||||
|
) AS [RowNumber]
|
||||||
|
FROM [Vendors] v
|
||||||
|
WHERE v.[CompanyId] IS NOT NULL
|
||||||
|
AND v.[Notes] IS NOT NULL
|
||||||
|
AND LTRIM(RTRIM(v.[Notes])) <> ''
|
||||||
|
)
|
||||||
|
UPDATE vc
|
||||||
|
SET vc.[Notes] = src.[Notes]
|
||||||
|
FROM [VendorCompanies] vc
|
||||||
|
INNER JOIN SourceNotes src
|
||||||
|
ON src.[CompanyId] = vc.[Id]
|
||||||
|
WHERE src.[RowNumber] = 1;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Notes",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RowVersion",
|
||||||
|
table: "VendorCompanies");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2194,6 +2194,14 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<byte[]>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("rowversion");
|
||||||
|
|
||||||
b.Property<string>("State")
|
b.Property<string>("State")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace Data.SeaHavenIndustries
|
namespace Data.SeaHavenIndustries
|
||||||
|
|
@ -13,6 +14,10 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? Zip { get; set; }
|
public string? Zip { get; set; }
|
||||||
public string? GoogleMapsUrl { get; set; }
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
[Timestamp]
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
public ICollection<Vendor>? Vendors { get; set; }
|
public ICollection<Vendor>? Vendors { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class VendorCompanyRosterDataService : IVendorCompanyRosterDataService
|
||||||
|
{
|
||||||
|
private static readonly string[] TerminalLegacyStatuses = { "completed", "cancelled", "canceled" };
|
||||||
|
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public VendorCompanyRosterDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||||
|
int? vendorId,
|
||||||
|
int? companyId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int? resolvedCompanyId = companyId;
|
||||||
|
|
||||||
|
if (vendorId.HasValue)
|
||||||
|
{
|
||||||
|
var vendorCompanyId = await _context.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.Id == vendorId.Value && (v.IsDeleted == null || v.IsDeleted == false))
|
||||||
|
.Select(v => v.CompanyId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!vendorCompanyId.HasValue ||
|
||||||
|
(companyId.HasValue && vendorCompanyId.Value != companyId.Value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
resolvedCompanyId = vendorCompanyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedCompanyId.HasValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var company = await _context.VendorCompanies
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.Id == resolvedCompanyId.Value)
|
||||||
|
.Select(c => new VendorCompanyRosterReadModel
|
||||||
|
{
|
||||||
|
CompanyId = c.Id,
|
||||||
|
Name = c.Name,
|
||||||
|
CompanyPhone = c.CompanyPhone,
|
||||||
|
Email = c.Email,
|
||||||
|
Address = c.Address,
|
||||||
|
City = c.City,
|
||||||
|
State = c.State,
|
||||||
|
Zip = c.Zip,
|
||||||
|
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||||
|
Notes = c.Notes,
|
||||||
|
RowVersion = c.RowVersion
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (company == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var technicians = await _context.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.CompanyId == company.CompanyId
|
||||||
|
&& (v.IsDeleted == null || v.IsDeleted == false))
|
||||||
|
.OrderBy(v => v.Id)
|
||||||
|
.Select(v => new VendorRosterTechnicianReadModel
|
||||||
|
{
|
||||||
|
Id = v.Id,
|
||||||
|
ContactName = v.ContactName,
|
||||||
|
Phone = v.Phone,
|
||||||
|
Email = v.Email,
|
||||||
|
PreferredContact = v.PreferredContact,
|
||||||
|
TradeSpecialties = v.TradeSpecialties,
|
||||||
|
IsActive = v.IsActive,
|
||||||
|
TotalJobs = v.TotalJobs
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
company.Technicians = technicians;
|
||||||
|
return company;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VendorCompanyRosterReadModel> SaveRosterAsync(
|
||||||
|
VendorCompanyRosterWriteModel roster,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var company = await _context.VendorCompanies
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == roster.CompanyId, cancellationToken);
|
||||||
|
|
||||||
|
if (company == null)
|
||||||
|
throw new KeyNotFoundException($"Vendor company with ID {roster.CompanyId} not found.");
|
||||||
|
|
||||||
|
// Concurrency: pin the client-supplied row version as the original value so a
|
||||||
|
// stale save throws DbUpdateConcurrencyException (mapped to a stable 409).
|
||||||
|
if (roster.RowVersion != null && roster.RowVersion.Length > 0)
|
||||||
|
_context.Entry(company).Property(c => c.RowVersion).OriginalValue = roster.RowVersion;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var trimmedName = roster.Name.Trim();
|
||||||
|
|
||||||
|
company.Name = trimmedName;
|
||||||
|
company.NormalizedName = trimmedName.ToLowerInvariant();
|
||||||
|
company.CompanyPhone = roster.CompanyPhone;
|
||||||
|
company.Email = roster.Email;
|
||||||
|
company.Address = roster.Address;
|
||||||
|
company.City = roster.City;
|
||||||
|
company.State = roster.State;
|
||||||
|
company.Zip = roster.Zip;
|
||||||
|
company.GoogleMapsUrl = roster.GoogleMapsUrl;
|
||||||
|
company.Notes = roster.Notes;
|
||||||
|
company.LastModificationTime = now;
|
||||||
|
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
|
||||||
|
if (actorId.HasValue)
|
||||||
|
company.LastModifierUserId = actorId;
|
||||||
|
|
||||||
|
var existingTechnicians = await _context.Vendors
|
||||||
|
.Where(v => v.CompanyId == company.Id && (v.IsDeleted == null || v.IsDeleted == false))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var snapshotIds = roster.Technicians
|
||||||
|
.Where(t => t.Id.HasValue)
|
||||||
|
.Select(t => t.Id!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var toRemove = existingTechnicians
|
||||||
|
.Where(e => !snapshotIds.Contains(e.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// A technician omitted from the snapshot may only be soft-deleted when it has no
|
||||||
|
// open linked work orders; otherwise the whole reconcile fails (nothing saved).
|
||||||
|
if (toRemove.Count > 0)
|
||||||
|
{
|
||||||
|
var blocked = await GetOpenLinkedWorkOrdersAsync(
|
||||||
|
toRemove.Select(vendor => vendor.Id).ToArray(),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (blocked.Count > 0)
|
||||||
|
throw new VendorRosterConflictException(
|
||||||
|
"One or more technicians cannot be removed because they have open linked work orders.",
|
||||||
|
blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var removed in toRemove)
|
||||||
|
{
|
||||||
|
removed.IsActive = false;
|
||||||
|
removed.IsDeleted = true;
|
||||||
|
removed.DeletionTime = now;
|
||||||
|
removed.LastModificationTime = now;
|
||||||
|
if (actorId.HasValue)
|
||||||
|
removed.LastModifierUserId = actorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var technician in roster.Technicians)
|
||||||
|
{
|
||||||
|
if (technician.Id.HasValue)
|
||||||
|
{
|
||||||
|
var existing = existingTechnicians.FirstOrDefault(e => e.Id == technician.Id.Value);
|
||||||
|
if (existing == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
existing.ContactName = technician.ContactName;
|
||||||
|
existing.Phone = technician.Phone;
|
||||||
|
existing.Email = technician.Email;
|
||||||
|
existing.PreferredContact = technician.PreferredContact;
|
||||||
|
existing.TradeSpecialties = technician.TradeSpecialties;
|
||||||
|
existing.IsActive = technician.IsActive;
|
||||||
|
existing.CompanyName = trimmedName;
|
||||||
|
existing.CompanyPhone = company.CompanyPhone;
|
||||||
|
existing.Address = company.Address;
|
||||||
|
existing.City = company.City;
|
||||||
|
existing.State = company.State;
|
||||||
|
existing.Zip = company.Zip;
|
||||||
|
existing.GoogleMapsUrl = company.GoogleMapsUrl;
|
||||||
|
existing.LastModificationTime = now;
|
||||||
|
if (actorId.HasValue)
|
||||||
|
existing.LastModifierUserId = actorId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var vendor = new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = trimmedName,
|
||||||
|
ContactName = technician.ContactName,
|
||||||
|
Phone = technician.Phone,
|
||||||
|
Email = technician.Email,
|
||||||
|
PreferredContact = technician.PreferredContact,
|
||||||
|
TradeSpecialties = technician.TradeSpecialties,
|
||||||
|
IsActive = technician.IsActive,
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CompanyPhone = company.CompanyPhone,
|
||||||
|
Address = company.Address,
|
||||||
|
City = company.City,
|
||||||
|
State = company.State,
|
||||||
|
Zip = company.Zip,
|
||||||
|
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||||
|
CreatedDate = now,
|
||||||
|
createdby = roster.ActorUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _context.Vendors.AddAsync(vendor, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return await GetRosterAsync(null, company.Id, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Roster could not be reloaded after save.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VendorCompanyRosterReadModel> CreateRosterAsync(
|
||||||
|
VendorCompanyRosterWriteModel roster,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var trimmedName = roster.Name.Trim();
|
||||||
|
|
||||||
|
var company = new VendorCompany
|
||||||
|
{
|
||||||
|
Name = trimmedName,
|
||||||
|
NormalizedName = trimmedName.ToLowerInvariant(),
|
||||||
|
CompanyPhone = roster.CompanyPhone,
|
||||||
|
Email = roster.Email,
|
||||||
|
Address = roster.Address,
|
||||||
|
City = roster.City,
|
||||||
|
State = roster.State,
|
||||||
|
Zip = roster.Zip,
|
||||||
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
||||||
|
Notes = roster.Notes,
|
||||||
|
CreatedDate = now,
|
||||||
|
createdby = roster.ActorUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _context.VendorCompanies.AddAsync(company, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var technician in roster.Technicians)
|
||||||
|
{
|
||||||
|
var vendor = new Vendor
|
||||||
|
{
|
||||||
|
CompanyName = trimmedName,
|
||||||
|
ContactName = technician.ContactName,
|
||||||
|
Phone = technician.Phone,
|
||||||
|
Email = technician.Email,
|
||||||
|
PreferredContact = technician.PreferredContact,
|
||||||
|
TradeSpecialties = technician.TradeSpecialties,
|
||||||
|
IsActive = technician.IsActive,
|
||||||
|
Company = company,
|
||||||
|
CompanyPhone = company.CompanyPhone,
|
||||||
|
Address = company.Address,
|
||||||
|
City = company.City,
|
||||||
|
State = company.State,
|
||||||
|
Zip = company.Zip,
|
||||||
|
GoogleMapsUrl = company.GoogleMapsUrl,
|
||||||
|
CreatedDate = now,
|
||||||
|
createdby = roster.ActorUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _context.Vendors.AddAsync(vendor, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return await GetRosterAsync(null, company.Id, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Roster could not be reloaded after create.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(
|
||||||
|
IReadOnlyCollection<int> vendorIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var directLinks = _context.Dispatches
|
||||||
|
.Where(d => vendorIds.Contains(d.VendorId) && d.WorkOrderId.HasValue)
|
||||||
|
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value });
|
||||||
|
|
||||||
|
var multiLinks = _context.DispatchWorkOrders
|
||||||
|
.Where(link => link.Dispatch != null && vendorIds.Contains(link.Dispatch.VendorId))
|
||||||
|
.Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId });
|
||||||
|
|
||||||
|
var linkedIds = await directLinks
|
||||||
|
.Concat(multiLinks)
|
||||||
|
.Join(_context.workOrders,
|
||||||
|
link => link.WorkOrderId,
|
||||||
|
wo => wo.Id,
|
||||||
|
(link, wo) => new LinkedWorkOrderInfo
|
||||||
|
{
|
||||||
|
WorkOrderId = wo.Id,
|
||||||
|
WorkOrderNumber = wo.WorkerOrderNumber,
|
||||||
|
WorkOrderTitle = wo.WorkerOrderTitle,
|
||||||
|
Status = wo.Status,
|
||||||
|
LifecycleStatus = wo.LifecycleStatus,
|
||||||
|
ScheduledDate = wo.ScheduledDate,
|
||||||
|
DispatchId = link.DispatchId
|
||||||
|
})
|
||||||
|
.OrderBy(wo => wo.WorkOrderId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return linkedIds
|
||||||
|
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
|
||||||
|
.DistinctBy(x => x.WorkOrderId)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus)
|
||||||
|
{
|
||||||
|
if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(legacyStatus) &&
|
||||||
|
TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant()))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -79,6 +80,39 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal IQueryable<VendorCompany> BuildCompaniesForFacetsQuery(bool? isActive)
|
||||||
|
{
|
||||||
|
var query = _context.VendorCompanies
|
||||||
|
.AsNoTracking();
|
||||||
|
|
||||||
|
if (isActive == true)
|
||||||
|
{
|
||||||
|
// Active: companies with zero non-deleted technicians (company-only rows)
|
||||||
|
// or at least one active non-deleted technician.
|
||||||
|
query = query.Where(c =>
|
||||||
|
!_context.Vendors.Any(v =>
|
||||||
|
v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false))
|
||||||
|
|| _context.Vendors.Any(v =>
|
||||||
|
v.CompanyId == c.Id
|
||||||
|
&& (v.IsDeleted == null || v.IsDeleted == false)
|
||||||
|
&& v.IsActive));
|
||||||
|
}
|
||||||
|
else if (isActive == false)
|
||||||
|
{
|
||||||
|
// Inactive: companies with at least one inactive non-deleted technician.
|
||||||
|
query = query.Where(c =>
|
||||||
|
_context.Vendors.Any(v =>
|
||||||
|
v.CompanyId == c.Id
|
||||||
|
&& (v.IsDeleted == null || v.IsDeleted == false)
|
||||||
|
&& !v.IsActive));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.OrderBy(c => c.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||||
|
=> await BuildCompaniesForFacetsQuery(isActive).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|
@ -107,6 +141,196 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (items, totalCount);
|
return (items, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filtered = BuildDirectoryUnionQuery(
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
|
||||||
|
var totalCount = await filtered.CountAsync(cancellationToken);
|
||||||
|
var items = await filtered
|
||||||
|
.OrderByDescending(row => row.VendorId.HasValue)
|
||||||
|
.ThenByDescending(row => row.VendorId ?? 0)
|
||||||
|
.ThenByDescending(row => row.CompanyId)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (items, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IQueryable<VendorDirectoryRow> BuildDirectoryUnionQuery(
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null)
|
||||||
|
{
|
||||||
|
// Arm A: existing per-technician rows (every non-deleted vendor, including
|
||||||
|
// those with no company). Notes come from the linked VendorCompany.
|
||||||
|
var perTechnician = _context.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||||
|
|
||||||
|
if (isActive.HasValue)
|
||||||
|
perTechnician = perTechnician.Where(v => v.IsActive == isActive.Value);
|
||||||
|
|
||||||
|
var armA = perTechnician.Select(v => new VendorDirectoryRow
|
||||||
|
{
|
||||||
|
VendorId = v.Id,
|
||||||
|
CompanyId = v.CompanyId ?? 0,
|
||||||
|
CompanyName = v.CompanyName,
|
||||||
|
ContactName = v.ContactName,
|
||||||
|
Email = v.Email,
|
||||||
|
Phone = v.Phone,
|
||||||
|
CompanyPhone = v.CompanyPhone,
|
||||||
|
PreferredContact = v.PreferredContact,
|
||||||
|
Address = v.Address,
|
||||||
|
City = v.City,
|
||||||
|
State = v.State,
|
||||||
|
Zip = v.Zip,
|
||||||
|
TradeSpecialties = v.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||||
|
Notes = v.Company != null ? v.Company.Notes : null,
|
||||||
|
IsActive = v.IsActive,
|
||||||
|
TotalJobs = _context.Dispatches
|
||||||
|
.Where(dispatch =>
|
||||||
|
dispatch.VendorId == v.Id &&
|
||||||
|
dispatch.WorkOrderId.HasValue)
|
||||||
|
.Select(dispatch => dispatch.WorkOrderId!.Value)
|
||||||
|
.Union(
|
||||||
|
_context.DispatchWorkOrders
|
||||||
|
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
||||||
|
.Select(link => link.WorkOrderId))
|
||||||
|
.Count()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Arm B: company-only rows for companies with zero non-deleted technicians.
|
||||||
|
// These carry a null VendorId and a populated CompanyId. A company has no
|
||||||
|
// active/inactive flag of its own, so it is shown unless the caller explicitly
|
||||||
|
// asks for inactive vendors only.
|
||||||
|
var includeCompanyRows = !isActive.HasValue || isActive.Value;
|
||||||
|
var union = armA;
|
||||||
|
|
||||||
|
if (includeCompanyRows)
|
||||||
|
{
|
||||||
|
var armB = _context.VendorCompanies
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => !_context.Vendors
|
||||||
|
.Any(v => v.CompanyId == c.Id && (v.IsDeleted == null || v.IsDeleted == false)))
|
||||||
|
.Select(c => new VendorDirectoryRow
|
||||||
|
{
|
||||||
|
VendorId = (int?)null,
|
||||||
|
CompanyId = c.Id,
|
||||||
|
CompanyName = c.Name,
|
||||||
|
ContactName = null,
|
||||||
|
Email = c.Email,
|
||||||
|
Phone = null,
|
||||||
|
CompanyPhone = c.CompanyPhone,
|
||||||
|
PreferredContact = null,
|
||||||
|
Address = c.Address,
|
||||||
|
City = c.City,
|
||||||
|
State = c.State,
|
||||||
|
Zip = c.Zip,
|
||||||
|
TradeSpecialties = null,
|
||||||
|
GoogleMapsUrl = c.GoogleMapsUrl,
|
||||||
|
Notes = c.Notes,
|
||||||
|
IsActive = true,
|
||||||
|
TotalJobs = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
union = armA.Concat(armB);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplyDirectoryRowFilters(
|
||||||
|
union,
|
||||||
|
search,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<VendorDirectoryRow> ApplyDirectoryRowFilters(
|
||||||
|
IQueryable<VendorDirectoryRow> query,
|
||||||
|
string? search,
|
||||||
|
IReadOnlyCollection<string>? companies,
|
||||||
|
IReadOnlyCollection<string>? trades,
|
||||||
|
IReadOnlyCollection<string>? locations,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var normalizedSearch = search.ToLower();
|
||||||
|
query = query.Where(row =>
|
||||||
|
(row.CompanyName ?? "").ToLower().Contains(normalizedSearch) ||
|
||||||
|
(row.ContactName ?? "").ToLower().Contains(normalizedSearch) ||
|
||||||
|
(row.Email ?? "").ToLower().Contains(normalizedSearch) ||
|
||||||
|
(row.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) ||
|
||||||
|
(row.City ?? "").ToLower().Contains(normalizedSearch) ||
|
||||||
|
(row.State ?? "").ToLower().Contains(normalizedSearch) ||
|
||||||
|
(row.Zip ?? "").ToLower().Contains(normalizedSearch));
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedCompanies = companies?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedCompanies.Length > 0)
|
||||||
|
query = query.Where(row =>
|
||||||
|
selectedCompanies.Contains((row.CompanyName ?? "").ToLower()));
|
||||||
|
|
||||||
|
var selectedTrades = trades?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedTrades.Length > 0)
|
||||||
|
query = query.Where(BuildTradePredicate<VendorDirectoryRow>(selectedTrades));
|
||||||
|
|
||||||
|
var selectedLocations = locations?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedLocations.Length > 0)
|
||||||
|
query = query.Where(BuildLocationPredicate<VendorDirectoryRow>(selectedLocations));
|
||||||
|
|
||||||
|
var selectedJobBuckets = jobBuckets?
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim().ToLower())
|
||||||
|
.Distinct()
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
if (selectedJobBuckets.Length == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var under50 = selectedJobBuckets.Contains("under-50");
|
||||||
|
var from50To99 = selectedJobBuckets.Contains("50-99");
|
||||||
|
var from100To149 = selectedJobBuckets.Contains("100-149");
|
||||||
|
var from150 = selectedJobBuckets.Contains("150-plus");
|
||||||
|
|
||||||
|
return query.Where(row =>
|
||||||
|
(under50 && row.TotalJobs < 50) ||
|
||||||
|
(from50To99 && row.TotalJobs >= 50 && row.TotalJobs <= 99) ||
|
||||||
|
(from100To149 && row.TotalJobs >= 100 && row.TotalJobs <= 149) ||
|
||||||
|
(from150 && row.TotalJobs >= 150));
|
||||||
|
}
|
||||||
|
|
||||||
internal IQueryable<Vendor> BuildDirectoryQuery(
|
internal IQueryable<Vendor> BuildDirectoryQuery(
|
||||||
string? search = null,
|
string? search = null,
|
||||||
bool? isActive = true,
|
bool? isActive = true,
|
||||||
|
|
@ -150,7 +374,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray() ?? Array.Empty<string>();
|
.ToArray() ?? Array.Empty<string>();
|
||||||
if (selectedTrades.Length > 0)
|
if (selectedTrades.Length > 0)
|
||||||
query = query.Where(BuildTradePredicate(selectedTrades));
|
query = query.Where(BuildTradePredicate<Vendor>(selectedTrades));
|
||||||
|
|
||||||
var selectedLocations = locations?
|
var selectedLocations = locations?
|
||||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
|
@ -158,7 +382,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray() ?? Array.Empty<string>();
|
.ToArray() ?? Array.Empty<string>();
|
||||||
if (selectedLocations.Length > 0)
|
if (selectedLocations.Length > 0)
|
||||||
query = query.Where(BuildLocationPredicate(selectedLocations));
|
query = query.Where(BuildLocationPredicate<Vendor>(selectedLocations));
|
||||||
|
|
||||||
var result = ProjectWithTotalJobs(query);
|
var result = ProjectWithTotalJobs(query);
|
||||||
var selectedJobBuckets = jobBuckets?
|
var selectedJobBuckets = jobBuckets?
|
||||||
|
|
@ -216,10 +440,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
private static readonly MethodInfo StringConcatMethod =
|
private static readonly MethodInfo StringConcatMethod =
|
||||||
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
|
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
|
||||||
|
|
||||||
private static Expression<Func<Vendor, bool>> BuildTradePredicate(
|
private static Expression<Func<T, bool>> BuildTradePredicate<T>(
|
||||||
IReadOnlyCollection<string> selectedTrades)
|
IReadOnlyCollection<string> selectedTrades)
|
||||||
{
|
{
|
||||||
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||||
var specialties = Expression.Coalesce(
|
var specialties = Expression.Coalesce(
|
||||||
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||||
Expression.Constant(string.Empty));
|
Expression.Constant(string.Empty));
|
||||||
|
|
@ -247,13 +471,13 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
|
private static Expression<Func<T, bool>> BuildLocationPredicate<T>(
|
||||||
IReadOnlyCollection<string> selectedLocations)
|
IReadOnlyCollection<string> selectedLocations)
|
||||||
{
|
{
|
||||||
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
var vendor = Expression.Parameter(typeof(T), "vendor");
|
||||||
var label = BuildLocationLabelBody(vendor);
|
var label = BuildLocationLabelBody(vendor);
|
||||||
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
||||||
|
|
||||||
|
|
@ -261,7 +485,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
foreach (var location in selectedLocations)
|
foreach (var location in selectedLocations)
|
||||||
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
||||||
|
|
||||||
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
return Expression.Lambda<Func<T, bool>>(body ?? Expression.Constant(false), vendor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IVendorCompanyRosterDataService
|
||||||
|
{
|
||||||
|
// Loads the company roster from either a vendor row id and/or a company id,
|
||||||
|
// returning the company plus all non-deleted technician rows.
|
||||||
|
Task<VendorCompanyRosterReadModel?> GetRosterAsync(
|
||||||
|
int? vendorId,
|
||||||
|
int? companyId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// Reconciles the company roster (add/update/soft-delete/rename) and updates the
|
||||||
|
// company Notes in a single SaveChangesAsync. A technician omitted from the
|
||||||
|
// snapshot is soft-deleted only when it has no open linked work orders; otherwise
|
||||||
|
// the whole save fails (VendorRosterConflictException) and nothing is persisted.
|
||||||
|
Task<VendorCompanyRosterReadModel> SaveRosterAsync(
|
||||||
|
VendorCompanyRosterWriteModel roster,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// Creates a new company together with its initial technicians (which may be zero)
|
||||||
|
// in a single SaveChangesAsync.
|
||||||
|
Task<VendorCompanyRosterReadModel> CreateRosterAsync(
|
||||||
|
VendorCompanyRosterWriteModel roster,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Interfaces
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
|
|
@ -13,6 +14,13 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
Task<IEnumerable<Vendor>> GetAllAsync();
|
||||||
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
|
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
||||||
|
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
||||||
|
// companies; active includes companies with zero non-deleted technicians or at
|
||||||
|
// least one active non-deleted technician; inactive includes companies with at
|
||||||
|
// least one inactive non-deleted technician.
|
||||||
|
Task<IReadOnlyList<VendorCompany>> GetCompaniesForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
|
||||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|
@ -22,6 +30,20 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null);
|
IReadOnlyCollection<string>? jobBuckets = null);
|
||||||
|
|
||||||
|
// SH-198 vendor directory: per-technician rows UNION company-only rows
|
||||||
|
// (companies with zero non-deleted technicians). Company-only rows carry a
|
||||||
|
// null VendorId and a populated CompanyId. Notes come from VendorCompany.Notes.
|
||||||
|
Task<(IReadOnlyList<VendorDirectoryRow> Items, int TotalCount)> GetDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
Task<Vendor> AddAsync(Vendor vendor);
|
Task<Vendor> AddAsync(Vendor vendor);
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
|
|
||||||
74
SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs
Normal file
74
SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Models
|
||||||
|
{
|
||||||
|
public sealed class VendorCompanyRosterReadModel
|
||||||
|
{
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? Email { 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 string? Notes { get; set; }
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VendorRosterTechnicianReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? Email { 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 string? Notes { get; set; }
|
||||||
|
public string? ActorUserId { get; set; }
|
||||||
|
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RosterTechnicianWriteModel
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raised by the roster data service when a technician removed from the snapshot
|
||||||
|
// still has open linked work orders, so the whole reconcile must fail. Carries the
|
||||||
|
// blocking work orders so the API can surface a stable 409 without leaking internals.
|
||||||
|
public sealed class VendorRosterConflictException : Exception
|
||||||
|
{
|
||||||
|
public IReadOnlyList<LinkedWorkOrderInfo> BlockedWorkOrders { get; }
|
||||||
|
|
||||||
|
public VendorRosterConflictException(string message, IReadOnlyList<LinkedWorkOrderInfo> blockedWorkOrders)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
BlockedWorkOrders = blockedWorkOrders;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs
Normal file
23
SeaHaven.DataServices/Models/VendorDirectoryReadModels.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace SeaHaven.DataServices.Models
|
||||||
|
{
|
||||||
|
public sealed class VendorDirectoryRow
|
||||||
|
{
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { 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? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
70
SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs
Normal file
70
SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
namespace SeaHaven.Services.DTOs
|
||||||
|
{
|
||||||
|
public class VendorRosterDTO
|
||||||
|
{
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? Email { 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 string? Notes { get; set; }
|
||||||
|
public string? RowVersion { get; set; }
|
||||||
|
public List<VendorRosterTechnicianDTO> Technicians { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VendorRosterTechnicianDTO
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RosterTechnicianInputDTO
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateVendorRosterDTO
|
||||||
|
{
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? Email { 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 string? Notes { get; set; }
|
||||||
|
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReconcileVendorRosterDTO
|
||||||
|
{
|
||||||
|
public required string RowVersion { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? Email { 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 string? Notes { get; set; }
|
||||||
|
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,29 @@ namespace SeaHaven.Services.DTOs
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SH-198 vendor directory row. Id is the nullable vendor id (null for company-only
|
||||||
|
// rows that have zero technicians); CompanyId is always populated.
|
||||||
|
public class VendorDirectoryItemDTO
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public int? CompanyId { get; set; }
|
||||||
|
public string? CompanyName { get; set; }
|
||||||
|
public string? ContactName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? CompanyPhone { get; set; }
|
||||||
|
public string? PreferredContact { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zipcode { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
public string? GoogleMapsUrl { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class CreateVendorDTO
|
public class CreateVendorDTO
|
||||||
{
|
{
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
|
|
||||||
288
SeaHaven.Services/Implementation/VendorCompanyRosterService.cs
Normal file
288
SeaHaven.Services/Implementation/VendorCompanyRosterService.cs
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
using FluentValidation;
|
||||||
|
using FluentValidation.Results;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class VendorCompanyRosterService : IVendorCompanyRosterService
|
||||||
|
{
|
||||||
|
private readonly IVendorCompanyRosterDataService _rosterDataService;
|
||||||
|
|
||||||
|
public VendorCompanyRosterService(IVendorCompanyRosterDataService rosterDataService)
|
||||||
|
{
|
||||||
|
_rosterDataService = rosterDataService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VendorRosterDTO?> GetRosterAsync(
|
||||||
|
int? vendorId,
|
||||||
|
int? companyId,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated(userId);
|
||||||
|
|
||||||
|
if (!vendorId.HasValue && !companyId.HasValue)
|
||||||
|
throw new ValidationException("A vendor id or company id is required.");
|
||||||
|
|
||||||
|
var roster = await _rosterDataService.GetRosterAsync(vendorId, companyId, cancellationToken);
|
||||||
|
return roster == null ? null : MapToDTO(roster);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VendorRosterDTO> CreateRosterAsync(
|
||||||
|
CreateVendorRosterDTO dto,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated(userId);
|
||||||
|
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
|
||||||
|
NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians);
|
||||||
|
|
||||||
|
if (dto.Technicians.Any(technician => technician.Id.HasValue))
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure(
|
||||||
|
nameof(CreateVendorRosterDTO.Technicians),
|
||||||
|
"Technician ids are not allowed when creating a vendor company.")
|
||||||
|
});
|
||||||
|
|
||||||
|
var writeModel = new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
Name = dto.Name,
|
||||||
|
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
||||||
|
Email = dto.Email,
|
||||||
|
Address = dto.Address,
|
||||||
|
City = dto.City,
|
||||||
|
State = dto.State,
|
||||||
|
Zip = dto.Zip,
|
||||||
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
|
Notes = dto.Notes,
|
||||||
|
ActorUserId = userId,
|
||||||
|
Technicians = MapTechnicians(dto.Technicians)
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await _rosterDataService.CreateRosterAsync(writeModel, cancellationToken);
|
||||||
|
return MapToDTO(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VendorRosterDTO> ReconcileRosterAsync(
|
||||||
|
int companyId,
|
||||||
|
ReconcileVendorRosterDTO dto,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated(userId);
|
||||||
|
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
|
||||||
|
NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians);
|
||||||
|
|
||||||
|
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
|
||||||
|
|
||||||
|
// Mismatched ids: every non-null technician id must belong to this company.
|
||||||
|
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
|
||||||
|
|
||||||
|
var writeModel = new VendorCompanyRosterWriteModel
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
RowVersion = rowVersion,
|
||||||
|
Name = dto.Name,
|
||||||
|
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
||||||
|
Email = dto.Email,
|
||||||
|
Address = dto.Address,
|
||||||
|
City = dto.City,
|
||||||
|
State = dto.State,
|
||||||
|
Zip = dto.Zip,
|
||||||
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||||
|
Notes = dto.Notes,
|
||||||
|
ActorUserId = userId,
|
||||||
|
Technicians = MapTechnicians(dto.Technicians)
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await _rosterDataService.SaveRosterAsync(writeModel, cancellationToken);
|
||||||
|
return MapToDTO(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureAuthenticated(string userId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
throw new UnauthorizedAccessException("An authenticated user is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NormalizeAndValidateCompanyFields(
|
||||||
|
string name,
|
||||||
|
string? companyPhone,
|
||||||
|
string? email,
|
||||||
|
string? googleMapsUrl,
|
||||||
|
List<RosterTechnicianInputDTO> technicians)
|
||||||
|
{
|
||||||
|
var failures = new List<ValidationFailure>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Name), "Company name is required."));
|
||||||
|
|
||||||
|
// Contact group: the company must expose at least one contact channel.
|
||||||
|
var normalizedCompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(companyPhone);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedCompanyPhone) && string.IsNullOrWhiteSpace(email))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(CreateVendorRosterDTO.CompanyPhone),
|
||||||
|
"At least one company phone or email is required."));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedCompanyPhone) &&
|
||||||
|
!VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedCompanyPhone))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(CreateVendorRosterDTO.CompanyPhone),
|
||||||
|
"Company phone must be in North American format: (XXX) XXX-XXXX"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(email) && !BeValidEmail(email))
|
||||||
|
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Email), "Invalid email address."));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(googleMapsUrl) && !BeValidAbsoluteHttpsUrl(googleMapsUrl))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(CreateVendorRosterDTO.GoogleMapsUrl),
|
||||||
|
"Google Maps URL must be an absolute HTTPS URL."));
|
||||||
|
|
||||||
|
// Technician phones must be valid North American format when provided.
|
||||||
|
for (var i = 0; i < technicians.Count; i++)
|
||||||
|
{
|
||||||
|
var technician = technicians[i];
|
||||||
|
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
|
||||||
|
technician.Phone = normalized;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
|
||||||
|
"Phone must be in North American format: (XXX) XXX-XXXX"));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
|
||||||
|
"Invalid email address."));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
|
||||||
|
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
|
||||||
|
"PreferredContact must be one of: Phone, Email, Text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate technician ids are not allowed.
|
||||||
|
var duplicateIds = technicians
|
||||||
|
.Where(t => t.Id.HasValue)
|
||||||
|
.GroupBy(t => t.Id!.Value)
|
||||||
|
.Where(group => group.Count() > 1)
|
||||||
|
.Select(group => group.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (duplicateIds.Count > 0)
|
||||||
|
failures.Add(new ValidationFailure(
|
||||||
|
nameof(CreateVendorRosterDTO.Technicians),
|
||||||
|
"Duplicate technician ids are not allowed."));
|
||||||
|
|
||||||
|
if (failures.Count > 0)
|
||||||
|
throw new ValidationException(failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
|
||||||
|
int companyId,
|
||||||
|
List<RosterTechnicianInputDTO> technicians,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestedIds = technicians
|
||||||
|
.Where(t => t.Id.HasValue)
|
||||||
|
.Select(t => t.Id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (requestedIds.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken);
|
||||||
|
if (roster == null)
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.Name), $"No vendor company exists with ID {companyId}.")
|
||||||
|
});
|
||||||
|
|
||||||
|
var validIds = roster.Technicians.Select(t => t.Id).ToHashSet();
|
||||||
|
var mismatched = requestedIds.Where(id => !validIds.Contains(id)).ToList();
|
||||||
|
|
||||||
|
if (mismatched.Count > 0)
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure(
|
||||||
|
nameof(ReconcileVendorRosterDTO.Technicians),
|
||||||
|
$"Technician ids do not belong to company {companyId}: {string.Join(", ", mismatched)}.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RosterTechnicianWriteModel> MapTechnicians(List<RosterTechnicianInputDTO> technicians)
|
||||||
|
{
|
||||||
|
return technicians.Select(t => new RosterTechnicianWriteModel
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
ContactName = t.ContactName,
|
||||||
|
Phone = t.Phone,
|
||||||
|
Email = t.Email,
|
||||||
|
PreferredContact = t.PreferredContact,
|
||||||
|
TradeSpecialties = t.TradeSpecialties,
|
||||||
|
IsActive = t.IsActive ?? true
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ParseRequiredRowVersion(string? base64)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(base64))
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Row version is required.")
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.FromBase64String(base64);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new ValidationException(new[]
|
||||||
|
{
|
||||||
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Invalid row version format.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool BeValidEmail(string? value) =>
|
||||||
|
System.Net.Mail.MailAddress.TryCreate(value, out var address) && address.Address == value;
|
||||||
|
|
||||||
|
private static bool BeValidAbsoluteHttpsUrl(string? value) =>
|
||||||
|
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
|
||||||
|
|
||||||
|
private static VendorRosterDTO MapToDTO(VendorCompanyRosterReadModel roster) => new()
|
||||||
|
{
|
||||||
|
CompanyId = roster.CompanyId,
|
||||||
|
Name = roster.Name,
|
||||||
|
CompanyPhone = roster.CompanyPhone,
|
||||||
|
Email = roster.Email,
|
||||||
|
Address = roster.Address,
|
||||||
|
City = roster.City,
|
||||||
|
State = roster.State,
|
||||||
|
Zip = roster.Zip,
|
||||||
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
||||||
|
Notes = roster.Notes,
|
||||||
|
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
|
||||||
|
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
ContactName = t.ContactName,
|
||||||
|
Phone = t.Phone,
|
||||||
|
Email = t.Email,
|
||||||
|
PreferredContact = t.PreferredContact,
|
||||||
|
TradeSpecialties = t.TradeSpecialties,
|
||||||
|
IsActive = t.IsActive,
|
||||||
|
TotalJobs = t.TotalJobs
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ using FluentValidation;
|
||||||
using FluentValidation.Results;
|
using FluentValidation.Results;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
@ -89,6 +90,58 @@ namespace SeaHaven.Services.Implementation
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var (items, totalCount) = await _vendorDataService.GetDirectoryPagedAsync(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
isActive,
|
||||||
|
companies,
|
||||||
|
trades,
|
||||||
|
locations,
|
||||||
|
jobBuckets,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new PagedResult<VendorDirectoryItemDTO>
|
||||||
|
{
|
||||||
|
Items = items.Select(MapToDirectoryItem).ToList(),
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VendorDirectoryItemDTO MapToDirectoryItem(VendorDirectoryRow row) => new()
|
||||||
|
{
|
||||||
|
Id = row.VendorId,
|
||||||
|
CompanyId = row.CompanyId == 0 ? null : row.CompanyId,
|
||||||
|
CompanyName = row.CompanyName,
|
||||||
|
ContactName = row.ContactName,
|
||||||
|
Email = row.Email,
|
||||||
|
Phone = row.Phone,
|
||||||
|
CompanyPhone = row.CompanyPhone,
|
||||||
|
PreferredContact = row.PreferredContact,
|
||||||
|
Address = row.Address,
|
||||||
|
City = row.City,
|
||||||
|
State = row.State,
|
||||||
|
Zipcode = row.Zip,
|
||||||
|
TradeSpecialties = row.TradeSpecialties,
|
||||||
|
GoogleMapsUrl = row.GoogleMapsUrl,
|
||||||
|
Notes = row.Notes,
|
||||||
|
IsActive = row.IsActive,
|
||||||
|
TotalJobs = row.TotalJobs
|
||||||
|
};
|
||||||
|
|
||||||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||||||
{
|
{
|
||||||
NormalizePhoneFields(dto);
|
NormalizePhoneFields(dto);
|
||||||
|
|
@ -569,25 +622,29 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
public async Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
public async Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Companies and Locations are sourced from company-owned VendorCompany fields
|
||||||
|
// (status semantics aligned to the directory union); Trades remain derived from
|
||||||
|
// non-deleted technician Vendors.
|
||||||
|
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
||||||
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
|
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
|
||||||
|
|
||||||
var companies = vendors
|
var companyFacets = companies
|
||||||
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
|
.GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(group => group.First())
|
.Select(group => group.First())
|
||||||
.OrderBy(v => v.CompanyName)
|
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(v => new VendorFacetCompanyDTO
|
.Select(c => new VendorFacetCompanyDTO
|
||||||
{
|
{
|
||||||
Name = v.CompanyName,
|
Name = c.Name,
|
||||||
CompanyId = v.CompanyId,
|
CompanyId = c.Id,
|
||||||
CompanyPhone = v.CompanyPhone,
|
CompanyPhone = c.CompanyPhone,
|
||||||
Email = v.Email,
|
Email = c.Email,
|
||||||
PreferredContact = v.PreferredContact,
|
PreferredContact = null,
|
||||||
Address = v.Address,
|
Address = c.Address,
|
||||||
City = v.City,
|
City = c.City,
|
||||||
State = v.State,
|
State = c.State,
|
||||||
Zip = v.Zip,
|
Zip = c.Zip,
|
||||||
GoogleMapsUrl = v.GoogleMapsUrl
|
GoogleMapsUrl = c.GoogleMapsUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
var trades = vendors
|
var trades = vendors
|
||||||
|
|
@ -596,20 +653,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(value => value);
|
.OrderBy(value => value);
|
||||||
|
|
||||||
var locations = vendors
|
var locations = companies
|
||||||
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
|
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))
|
||||||
.Select(v => new VendorFacetLocationDTO
|
.Select(c => new VendorFacetLocationDTO
|
||||||
{
|
{
|
||||||
City = v.City,
|
City = c.City,
|
||||||
State = v.State,
|
State = c.State,
|
||||||
Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
|
Label = string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||||
})
|
})
|
||||||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(location => location.Label);
|
.OrderBy(location => location.Label);
|
||||||
|
|
||||||
return new VendorFacetsDTO
|
return new VendorFacetsDTO
|
||||||
{
|
{
|
||||||
Companies = companies,
|
Companies = companyFacets,
|
||||||
Trades = trades,
|
Trades = trades,
|
||||||
Locations = locations,
|
Locations = locations,
|
||||||
JobBuckets = new[]
|
JobBuckets = new[]
|
||||||
|
|
|
||||||
24
SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs
Normal file
24
SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IVendorCompanyRosterService
|
||||||
|
{
|
||||||
|
Task<VendorRosterDTO?> GetRosterAsync(
|
||||||
|
int? vendorId,
|
||||||
|
int? companyId,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<VendorRosterDTO> CreateRosterAsync(
|
||||||
|
CreateVendorRosterDTO dto,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<VendorRosterDTO> ReconcileRosterAsync(
|
||||||
|
int companyId,
|
||||||
|
ReconcileVendorRosterDTO dto,
|
||||||
|
string userId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,18 @@ namespace SeaHaven.Services.Interfaces
|
||||||
IReadOnlyCollection<string>? trades = null,
|
IReadOnlyCollection<string>? trades = null,
|
||||||
IReadOnlyCollection<string>? locations = null,
|
IReadOnlyCollection<string>? locations = null,
|
||||||
IReadOnlyCollection<string>? jobBuckets = null);
|
IReadOnlyCollection<string>? jobBuckets = null);
|
||||||
|
|
||||||
|
// SH-198 vendor directory: per-technician rows UNION company-only rows.
|
||||||
|
Task<PagedResult<VendorDirectoryItemDTO>> GetVendorDirectoryPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search = null,
|
||||||
|
bool? isActive = true,
|
||||||
|
IReadOnlyCollection<string>? companies = null,
|
||||||
|
IReadOnlyCollection<string>? trades = null,
|
||||||
|
IReadOnlyCollection<string>? locations = null,
|
||||||
|
IReadOnlyCollection<string>? jobBuckets = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
||||||
Task DeleteVendorAsync(int id, string userId);
|
Task DeleteVendorAsync(int id, string userId);
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,16 @@ namespace SeaHaven.Services.Validation
|
||||||
.EmailAddress().WithMessage("Invalid email address")
|
.EmailAddress().WithMessage("Invalid email address")
|
||||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||||
|
|
||||||
|
// SH-199: technician (ContactName/Phone) is optional. The company must still
|
||||||
|
// expose at least one contact channel: a company phone or an email.
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(dto => !string.IsNullOrWhiteSpace(dto.CompanyPhone) || !string.IsNullOrWhiteSpace(dto.Email))
|
||||||
|
.WithMessage("At least one company phone or email is required")
|
||||||
|
.OverridePropertyName(nameof(CreateVendorDTO.CompanyPhone));
|
||||||
|
|
||||||
RuleFor(x => x.ContactName)
|
RuleFor(x => x.ContactName)
|
||||||
.NotEmpty().WithMessage("Technician name is required")
|
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
|
||||||
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters");
|
.When(x => !string.IsNullOrEmpty(x.ContactName));
|
||||||
|
|
||||||
RuleFor(x => x.PreferredContact)
|
RuleFor(x => x.PreferredContact)
|
||||||
.Must(VendorValidationRules.BeValidPreferredContact)
|
.Must(VendorValidationRules.BeValidPreferredContact)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue