mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 10:23:28 +00:00
feat(vendors): server-owned canonical trades vocabulary for SH-249
This commit is contained in:
parent
3e2450e5b6
commit
577b7add31
12 changed files with 4283 additions and 44 deletions
|
|
@ -480,6 +480,76 @@ public class VendorDataServiceTests
|
||||||
inactive.Should().BeEmpty();
|
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]
|
[Theory]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.DataServices.Models;
|
using SeaHaven.DataServices.Models;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
|
@ -573,21 +575,8 @@ public class VendorServiceTests
|
||||||
GoogleMapsUrl = "https://owner.example/map"
|
GoogleMapsUrl = "https://owner.example/map"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Vendor>
|
.ReturnsAsync(new List<Trade>());
|
||||||
{
|
|
||||||
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 facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -608,7 +597,9 @@ public class VendorServiceTests
|
||||||
location.State.Should().Be("OS");
|
location.State.Should().Be("OS");
|
||||||
location.Label.Should().Be("OwnerCity, 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]
|
[Fact]
|
||||||
|
|
@ -622,8 +613,8 @@ public class VendorServiceTests
|
||||||
new() { Id = 2, Name = "alpha co" },
|
new() { Id = 2, Name = "alpha co" },
|
||||||
new() { Id = 3, Name = "Alpha Co" }
|
new() { Id = 3, Name = "Alpha Co" }
|
||||||
});
|
});
|
||||||
data.Setup(x => x.GetVendorsForFacetsAsync(null, It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Vendor>());
|
.ReturnsAsync(new List<Trade>());
|
||||||
|
|
||||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||||
|
|
||||||
|
|
@ -635,19 +626,68 @@ public class VendorServiceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies()
|
public async Task GetFacetsAsync_TradesComeFromCanonicalReferenceDataNotTechnicianFreeText()
|
||||||
{
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
var data = new Mock<IVendorDataService>();
|
var data = new Mock<IVendorDataService>();
|
||||||
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<VendorCompany> { new() { Id = 1, Name = "No Trade Co" } });
|
.ReturnsAsync(new List<VendorCompany> { new() { Id = 1, Name = "No Trade Co" } });
|
||||||
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
data.Setup(x => x.GetActiveTradesAsync(cts.Token))
|
||||||
.ReturnsAsync(new List<Vendor>
|
.ReturnsAsync(TradeCatalog.CanonicalTrades
|
||||||
{
|
.Select((name, index) => new Trade
|
||||||
new() { Id = 20, CompanyName = "Trade Co", TradeSpecialties = "HVAC, Roofing" }
|
{
|
||||||
});
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,19 @@ namespace Data.SeaHavenIndustries
|
||||||
.HasIndex(p => new { p.LocationId, p.VendorId })
|
.HasIndex(p => new { p.LocationId, p.VendorId })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("[Trade] IS NULL");
|
.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<Category> Categories { get; set; }
|
||||||
public DbSet<Locations> Locations { get; set; }
|
public DbSet<Locations> Locations { get; set; }
|
||||||
|
|
@ -207,6 +220,7 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<WorkOrderReconciliationJob> WorkOrderReconciliationJobs { get; set; }
|
public DbSet<WorkOrderReconciliationJob> WorkOrderReconciliationJobs { get; set; }
|
||||||
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
||||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||||
|
public DbSet<Trade> Trades { get; set; }
|
||||||
public DbSet<Vendor> Vendors { get; set; }
|
public DbSet<Vendor> Vendors { get; set; }
|
||||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||||
|
|
|
||||||
3897
Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.Designer.cs
generated
Normal file
3897
Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2003,6 +2003,38 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("Templates");
|
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 =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
|
||||||
15
Data.SeaHavenIndustries/Models/Trade.cs
Normal file
15
Data.SeaHavenIndustries/Models/Trade.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Data.SeaHavenIndustries/Models/TradeCatalog.cs
Normal file
28
Data.SeaHavenIndustries/Models/TradeCatalog.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,17 +66,13 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.ToListAsync(cancellationToken);
|
.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()
|
.AsNoTracking()
|
||||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
.Where(t => t.IsActive)
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
if (isActive.HasValue)
|
.ThenBy(t => t.Name)
|
||||||
query = query.Where(v => v.IsActive == isActive.Value);
|
|
||||||
|
|
||||||
return await query
|
|
||||||
.OrderByDescending(v => v.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken);
|
Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken);
|
||||||
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);
|
|
||||||
|
// 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,
|
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
||||||
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
||||||
|
|
|
||||||
|
|
@ -623,10 +623,12 @@ 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
|
// Companies and Locations are sourced from company-owned VendorCompany fields
|
||||||
// (status semantics aligned to the directory union); Trades remain derived from
|
// (status semantics aligned to the directory union). Trades come from the
|
||||||
// non-deleted technician Vendors.
|
// 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 companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
||||||
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
|
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
|
||||||
|
|
||||||
var companyFacets = companies
|
var companyFacets = companies
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
|
|
@ -647,11 +649,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
GoogleMapsUrl = c.GoogleMapsUrl
|
GoogleMapsUrl = c.GoogleMapsUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
var trades = vendors
|
var trades = canonicalTrades.Select(t => t.Name).ToList();
|
||||||
.SelectMany(v => (v.TradeSpecialties ?? "")
|
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.OrderBy(value => value);
|
|
||||||
|
|
||||||
var locations = companies
|
var locations = companies
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))
|
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue