Merge pull request #71 from Sea-Haven-Industries/feat/sh-249-trades-vocabulary

feat(vendors): server-owned canonical trades vocabulary (SH-249)
This commit is contained in:
Alexandre Brandizzi 2026-08-18 16:54:14 -03:00 • committed by GitHub
commit 91fb3286bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 4283 additions and 44 deletions

View file

@ -480,6 +480,76 @@ public class VendorDataServiceTests
inactive.Should().BeEmpty();
}
[Fact]
public async Task GetActiveTradesAsync_ReturnsEmptyOnEmptyDatabase()
{
await using var context = NewContext();
var service = new VendorDataService(context);
var trades = await service.GetActiveTradesAsync(CancellationToken.None);
trades.Should().BeEmpty();
}
[Fact]
public async Task GetActiveTradesAsync_FiltersInactiveAndOrdersBySortOrderThenName()
{
await using var context = NewContext();
context.Trades.AddRange(
new Trade { Name = "Janitorial", NormalizedName = "janitorial", SortOrder = 7 },
new Trade { Name = "Plumbing", NormalizedName = "plumbing", SortOrder = 1 },
new Trade { Name = "Electrical", NormalizedName = "electrical", SortOrder = 2 },
new Trade { Name = "Retired Trade", NormalizedName = "retired trade", SortOrder = 0, IsActive = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var trades = await service.GetActiveTradesAsync(CancellationToken.None);
trades.Select(t => t.Name).Should().Equal("Plumbing", "Electrical", "Janitorial");
}
[Fact]
public async Task GetPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues()
{
await using var context = NewContext();
context.Vendors.AddRange(
VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3),
VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5));
await context.SaveChangesAsync();
var service = new VendorDataService(context);
// SH-249: stored TradeSpecialties stay free text; filtering by a legacy
// value that is not in the canonical catalog must keep matching.
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
trades: new[] { "Backflow Testing" });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be("Legacy Co");
}
[Fact]
public async Task GetDirectoryPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues()
{
await using var context = NewContext();
context.Vendors.AddRange(
VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3),
VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5));
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetDirectoryPagedAsync(
1,
12,
isActive: true,
trades: new[] { "Backflow Testing" });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be("Legacy Co");
}
[Theory]
[InlineData(true)]
[InlineData(false)]

View file

@ -2,8 +2,10 @@ using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
@ -573,21 +575,8 @@ public class VendorServiceTests
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"
}
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
@ -608,7 +597,9 @@ public class VendorServiceTests
location.State.Should().Be("OS");
location.Label.Should().Be("OwnerCity, OS");
facets.Trades.Should().BeEquivalentTo(new[] { "Plumbing" });
// SH-249: technician free text is no longer a trades source; the canonical
// vocabulary comes from the Trade reference table instead.
facets.Trades.Should().BeEmpty();
}
[Fact]
@ -622,8 +613,8 @@ public class VendorServiceTests
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>());
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
@ -635,19 +626,68 @@ public class VendorServiceTests
}
[Fact]
public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies()
public async Task GetFacetsAsync_TradesComeFromCanonicalReferenceDataNotTechnicianFreeText()
{
using var cts = new CancellationTokenSource();
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" }
});
data.Setup(x => x.GetActiveTradesAsync(cts.Token))
.ReturnsAsync(TradeCatalog.CanonicalTrades
.Select((name, index) => new Trade
{
Id = index + 1,
Name = name,
NormalizedName = TradeCatalog.Normalize(name),
SortOrder = index + 1
})
.ToList());
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
facets.Trades.Should().BeEquivalentTo(new[] { "HVAC", "Roofing" }, opts => opts.WithStrictOrdering());
// SH-249: facets serve the canonical Trade vocabulary in catalog order,
// regardless of technician TradeSpecialties free text.
facets.Trades.Should().BeEquivalentTo(
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
// G7 evidence: the caller's cancellation token reaches the data service.
data.Verify(x => x.GetActiveTradesAsync(cts.Token), Times.Once);
}
[Fact]
public async Task GetFacetsAsync_ReturnsCanonicalTradesOnEmptyVendorDatabase()
{
await using var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
// Empty vendor/company data; only the canonical Trade reference rows exist
// (in production these are seeded by the SH249_TradesVocabulary migration).
context.Trades.AddRange(TradeCatalog.CanonicalTrades.Select((name, index) => new Trade
{
Id = index + 1,
Name = name,
NormalizedName = TradeCatalog.Normalize(name),
SortOrder = index + 1
}));
await context.SaveChangesAsync();
var service = new VendorService(
new VendorDataService(context),
Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
new CreateVendorValidation(),
new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation());
var facets = await service.GetFacetsAsync(null, CancellationToken.None);
facets.Companies.Should().BeEmpty();
facets.Locations.Should().BeEmpty();
facets.Trades.Should().BeEquivalentTo(
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
}
}

View file

@ -186,6 +186,19 @@ namespace Data.SeaHavenIndustries
.HasIndex(p => new { p.LocationId, p.VendorId })
.IsUnique()
.HasFilter("[Trade] IS NULL");
// SH-249 bounded lengths: unique index keys must not be nvarchar(max).
builder.Entity<Trade>()
.Property(t => t.Name)
.HasMaxLength(128);
builder.Entity<Trade>()
.Property(t => t.NormalizedName)
.HasMaxLength(128);
builder.Entity<Trade>()
.HasIndex(t => t.NormalizedName)
.IsUnique();
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -207,6 +220,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderReconciliationJob> WorkOrderReconciliationJobs { get; set; }
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Trade> Trades { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class SH249_TradesVocabulary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Trades",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Trades", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Trades_NormalizedName",
table: "Trades",
column: "NormalizedName",
unique: true);
// SH-249 canonical trade vocabulary. PROVISIONAL: sourced from the
// in-repo prototype seed in Program.cs; Product must confirm before
// release. Keep literals in sync with TradeCatalog.CanonicalTrades
// (tests assert the applied table matches the catalog). Extend the
// vocabulary after this ships only through new migrations.
migrationBuilder.InsertData(
table: "Trades",
columns: new[] { "Id", "Name", "NormalizedName", "SortOrder", "IsActive" },
values: new object[,]
{
{ 1, "Plumbing", "plumbing", 1, true },
{ 2, "Electrical", "electrical", 2, true },
{ 3, "HVAC", "hvac", 3, true },
{ 4, "Fire/Life Safety", "fire/life safety", 4, true },
{ 5, "General", "general", 5, true },
{ 6, "Roofing", "roofing", 6, true },
{ 7, "Janitorial", "janitorial", 7, true },
{ 8, "Painting", "painting", 8, true },
{ 9, "Locksmith", "locksmith", 9, true },
{ 10, "Carpentry", "carpentry", 10, true }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Trades");
}
}
}

View file

@ -2003,6 +2003,38 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Templates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{
b.Property<int>("Id")

View file

@ -0,0 +1,15 @@
namespace Data.SeaHavenIndustries
{
// SH-249: server-owned trade reference data backing the vendor facets
// vocabulary. Canonical options no longer derive from free-text
// Vendor.TradeSpecialties values; stored vendor strings are preserved
// unchanged and remain filterable.
public class Trade
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string NormalizedName { get; set; } = "";
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
}

View file

@ -0,0 +1,28 @@
namespace Data.SeaHavenIndustries
{
// SH-249 PROVISIONAL canonical trade vocabulary. Sourced from the
// in-repo prototype seed (Api.SeaHavenIndustries/Program.cs, disabled
// development seeding) because no authoritative Product list exists in
// the repo. Product must confirm the list before release; after the
// SH249_TradesVocabulary migration ships, extend the vocabulary only
// through new migrations (and keep this catalog in sync — tests assert
// the seeded table matches this catalog).
public static class TradeCatalog
{
public static readonly IReadOnlyList<string> CanonicalTrades = new[]
{
"Plumbing",
"Electrical",
"HVAC",
"Fire/Life Safety",
"General",
"Roofing",
"Janitorial",
"Painting",
"Locksmith",
"Carpentry"
};
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
}
}

View file

@ -66,17 +66,13 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
public async Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken)
{
var query = _context.Vendors
return await _context.Trades
.AsNoTracking()
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
if (isActive.HasValue)
query = query.Where(v => v.IsActive == isActive.Value);
return await query
.OrderByDescending(v => v.Id)
.Where(t => t.IsActive)
.OrderBy(t => t.SortOrder)
.ThenBy(t => t.Name)
.ToListAsync(cancellationToken);
}

View file

@ -13,7 +13,11 @@ namespace SeaHaven.DataServices.Interfaces
Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken);
Task<IEnumerable<Vendor>> GetAllAsync();
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
// SH-249: canonical trade vocabulary (server-owned reference data) for
// vendor facets. Active trades ordered by SortOrder then Name; independent
// of technician Vendor.TradeSpecialties free text.
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all

View file

@ -623,10 +623,12 @@ 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.
// (status semantics aligned to the directory union). Trades come from the
// SH-249 server-owned canonical Trade reference data — a stable list that
// does not derive from technician TradeSpecialties free text, so it is
// populated even on an empty database and independent of the status filter.
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
var companyFacets = companies
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
@ -647,11 +649,7 @@ namespace SeaHaven.Services.Implementation
GoogleMapsUrl = c.GoogleMapsUrl
});
var trades = vendors
.SelectMany(v => (v.TradeSpecialties ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value);
var trades = canonicalTrades.Select(t => t.Name).ToList();
var locations = companies
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))

View file

@ -0,0 +1,80 @@
using System.Reflection;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Migrations;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace SeaHavenIndustries.Tests;
// SH-249: provider-backed migration evidence for the Trades reference table.
// Applies the real migration operations (Up, then Down) against SQLite and
// asserts the seeded canonical vocabulary matches TradeCatalog.
public class SH249TradesVocabularyMigrationTests
{
[Fact]
public async Task Up_CreatesTradesTableSeededWithCanonicalCatalogOnEmptyDatabase()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await ApplyMigrationAsync(context, "Up");
var seeded = await context.Trades
.OrderBy(t => t.SortOrder)
.ToListAsync();
Assert.Equal(
TradeCatalog.CanonicalTrades,
seeded.Select(t => t.Name).ToArray());
Assert.All(seeded, t => Assert.True(t.IsActive));
Assert.Equal(
TradeCatalog.CanonicalTrades.Select(TradeCatalog.Normalize).ToArray(),
seeded.Select(t => t.NormalizedName).ToArray());
}
[Fact]
public async Task Down_DropsTradesTable()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await ApplyMigrationAsync(context, "Up");
await ApplyMigrationAsync(context, "Down");
await using var command = connection.CreateCommand();
command.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'Trades';";
var tableCount = Convert.ToInt32(await command.ExecuteScalarAsync());
Assert.Equal(0, tableCount);
}
// Mirrors Phase4SearchIndexesSqlServerTests: invoke the protected Up/Down
// body, translate through the configured provider's SQL generator, and
// execute the produced commands against the live database.
private static async Task ApplyMigrationAsync(ApplicationDbContext context, string direction)
{
var builder = new MigrationBuilder(context.Database.ProviderName!);
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(new SH249_TradesVocabulary(), [builder]);
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
// InsertData operations need model metadata for column type mapping.
var commands = sqlGenerator.Generate(builder.Operations, context.Model);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
}