mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
fix(vendors): source facets from companies
This commit is contained in:
parent
665880ee80
commit
fc2870869f
5 changed files with 267 additions and 22 deletions
|
|
@ -391,4 +391,107 @@ public class VendorDataServiceTests
|
||||||
if (filter == "job-bucket")
|
if (filter == "job-bucket")
|
||||||
sql.Should().Contain("TotalJobs");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -552,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,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,
|
||||||
|
|
|
||||||
|
|
@ -14,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,
|
||||||
|
|
|
||||||
|
|
@ -622,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
|
||||||
|
|
@ -649,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[]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue