fix(vendors): source facets from companies

This commit is contained in:
Alexandre Brandizzi 2026-08-03 14:41:49 -03:00
parent 665880ee80
commit fc2870869f
5 changed files with 267 additions and 22 deletions

View file

@ -391,4 +391,107 @@ public class VendorDataServiceTests
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");
}
}

View file

@ -552,4 +552,102 @@ public class VendorServiceTests
.WithMessage("*404*not found");
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());
}
}

View file

@ -80,6 +80,39 @@ namespace SeaHaven.DataServices.Implementation
.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(
int page,
int pageSize,

View file

@ -14,6 +14,13 @@ namespace SeaHaven.DataServices.Interfaces
Task<IEnumerable<Vendor>> GetAllAsync();
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(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(
int page,
int pageSize,

View file

@ -622,25 +622,29 @@ namespace SeaHaven.Services.Implementation
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 companies = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
var companyFacets = companies
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.GroupBy(c => c.Name!.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(v => v.CompanyName)
.Select(v => new VendorFacetCompanyDTO
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(c => new VendorFacetCompanyDTO
{
Name = v.CompanyName,
CompanyId = v.CompanyId,
CompanyPhone = v.CompanyPhone,
Email = v.Email,
PreferredContact = v.PreferredContact,
Address = v.Address,
City = v.City,
State = v.State,
Zip = v.Zip,
GoogleMapsUrl = v.GoogleMapsUrl
Name = c.Name,
CompanyId = c.Id,
CompanyPhone = c.CompanyPhone,
Email = c.Email,
PreferredContact = null,
Address = c.Address,
City = c.City,
State = c.State,
Zip = c.Zip,
GoogleMapsUrl = c.GoogleMapsUrl
});
var trades = vendors
@ -649,20 +653,20 @@ namespace SeaHaven.Services.Implementation
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value);
var locations = vendors
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
.Select(v => new VendorFacetLocationDTO
var locations = companies
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))
.Select(c => new VendorFacetLocationDTO
{
City = v.City,
State = v.State,
Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
City = c.City,
State = c.State,
Label = string.Join(", ", new[] { c.City, c.State }.Where(value => !string.IsNullOrWhiteSpace(value)))
})
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
.OrderBy(location => location.Label);
return new VendorFacetsDTO
{
Companies = companies,
Companies = companyFacets,
Trades = trades,
Locations = locations,
JobBuckets = new[]