mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 13:32:13 +00:00
Merge branch 'dev' into feature/sh-196-wo-uplifts
This commit is contained in:
commit
5386d6129d
47 changed files with 5522 additions and 101 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; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260730150000_WoIsAddOn")]
|
||||||
|
public partial class WoIsAddOn : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAddOn",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
// Legacy WorkOrderType.AddOn (7) → flag; do not remap type (real type unknown).
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE workOrders SET IsAddOn = 1 WHERE WorkOrderType = 7;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAddOn",
|
||||||
|
table: "workOrders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <summary>
|
||||||
|
/// SH-121 originally added <c>IsAddOn</c> for search facets. Column + legacy backfill
|
||||||
|
/// now live in <c>20260730150000_WoIsAddOn</c> (SH-184). This migration is a no-op so
|
||||||
|
/// both PRs can land without a duplicate column.
|
||||||
|
/// </summary>
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260813193000_SH121_BoardSearchFacets")]
|
||||||
|
public partial class SH121_BoardSearchFacets : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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")
|
||||||
|
|
@ -2458,6 +2490,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasMaxLength(11)
|
.HasMaxLength(11)
|
||||||
.HasColumnType("nvarchar(11)");
|
.HasColumnType("nvarchar(11)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAddOn")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool?>("IsDeleted")
|
b.Property<bool?>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,8 @@ namespace Data.SeaHavenIndustries
|
||||||
// --- Analytics slice ---
|
// --- Analytics slice ---
|
||||||
public int RescheduleCount { get; set; }
|
public int RescheduleCount { get; set; }
|
||||||
public int CarriedOver { get; set; }
|
public int CarriedOver { get; set; }
|
||||||
|
/// <summary>Recalculated on schedule set/change/clear (SH-184); create hint when unscheduled. Board/search facet.</summary>
|
||||||
|
public bool IsAddOn { get; set; }
|
||||||
public OperationalFlags OperationalFlags { get; set; }
|
public OperationalFlags OperationalFlags { get; set; }
|
||||||
|
|
||||||
// --- Completion slice ---
|
// --- Completion slice ---
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.InternalWONumber,
|
w.InternalWONumber,
|
||||||
w.RescheduleCount,
|
w.RescheduleCount,
|
||||||
w.CarriedOver,
|
w.CarriedOver,
|
||||||
|
w.IsAddOn,
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
w.SiteCode,
|
w.SiteCode,
|
||||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||||
|
|
@ -128,6 +129,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.InternalWONumber,
|
w.InternalWONumber,
|
||||||
w.RescheduleCount,
|
w.RescheduleCount,
|
||||||
w.CarriedOver,
|
w.CarriedOver,
|
||||||
|
w.IsAddOn,
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
w.SiteCode,
|
w.SiteCode,
|
||||||
w.LocationName,
|
w.LocationName,
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,10 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
|
|
||||||
var today = (utcNow ?? DateTime.UtcNow).Date;
|
var today = (utcNow ?? DateTime.UtcNow).Date;
|
||||||
var typeFilters = hasTypes ? types!.ToList() : new List<WorkOrderType>();
|
var typeFilters = hasTypes ? types!.ToList() : new List<WorkOrderType>();
|
||||||
|
var includeAddOnFlag = typeFilters.Contains(WorkOrderType.AddOn);
|
||||||
|
|
||||||
// Keep past-due predicate inline — EF cannot translate local method calls.
|
// Keep past-due predicate inline — EF cannot translate local method calls.
|
||||||
|
// Types=AddOn also matches IsAddOn (legacy type 7 OR flag on a real type).
|
||||||
if (overdue && !hasTypes)
|
if (overdue && !hasTypes)
|
||||||
{
|
{
|
||||||
return query.Where(w =>
|
return query.Where(w =>
|
||||||
|
|
@ -84,10 +86,13 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
&& w.ScheduledDate.Value.Date < today
|
&& w.ScheduledDate.Value.Date < today
|
||||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||||
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|
||||||
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)));
|
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|
||||||
|
|| (includeAddOnFlag && w.IsAddOn));
|
||||||
}
|
}
|
||||||
|
|
||||||
return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value));
|
return query.Where(w =>
|
||||||
|
(w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value))
|
||||||
|
|| (includeAddOnFlag && w.IsAddOn));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IQueryable<WorkOrder> ApplyTypeFilter(
|
public static IQueryable<WorkOrder> ApplyTypeFilter(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SH-121/SH-196 advanced search facets (severity, indicators, flag, internal WO#, uplift).
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkOrderBoardSearchFacetFilters
|
||||||
|
{
|
||||||
|
private static readonly WorkOrderType[] SeverityEligibleTypes =
|
||||||
|
{
|
||||||
|
WorkOrderType.Emergency,
|
||||||
|
WorkOrderType.Reactive,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] RevokedUpliftStatuses =
|
||||||
|
{
|
||||||
|
"Withdrawn",
|
||||||
|
"Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplySeverityFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<int>? severities)
|
||||||
|
{
|
||||||
|
if (severities == null || severities.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var allowed = severities
|
||||||
|
.Where(s => s is >= 1 and <= 5)
|
||||||
|
.Select(s => s.ToString())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (allowed.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
w.WorkOrderType != null
|
||||||
|
&& SeverityEligibleTypes.Contains(w.WorkOrderType.Value)
|
||||||
|
&& w.Severity != null
|
||||||
|
&& allowed.Contains(w.Severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyRescheduledFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool rescheduled)
|
||||||
|
{
|
||||||
|
if (!rescheduled)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.RescheduleCount >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyCarriedOverFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool carriedOver)
|
||||||
|
{
|
||||||
|
if (!carriedOver)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.CarriedOver >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyAddOnFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool addOn)
|
||||||
|
{
|
||||||
|
if (!addOn)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.IsAddOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyFlagColorFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<string>? flagColors)
|
||||||
|
{
|
||||||
|
if (flagColors == null || flagColors.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var normalized = flagColors
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||||
|
.Select(c => c.Trim().ToUpperInvariant())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (normalized.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
w.FlagColor != null && normalized.Contains(w.FlagColor.ToUpper()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyInternalOnlyFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool internalOnly)
|
||||||
|
{
|
||||||
|
if (!internalOnly)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
w.InternalWONumber != null
|
||||||
|
&& EF.Functions.Like(w.InternalWONumber, "SH%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyUpliftFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
ApplicationDbContext context,
|
||||||
|
bool hasUplift,
|
||||||
|
IReadOnlyList<string>? upliftStatuses)
|
||||||
|
{
|
||||||
|
if (!hasUplift)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var mappedStatuses = MapUpliftStatuses(upliftStatuses);
|
||||||
|
|
||||||
|
return query.Where(w => context.DispatchUpliftRequests.Any(u =>
|
||||||
|
(u.IsDeleted == null || u.IsDeleted == false)
|
||||||
|
&& u.Status != "Expired"
|
||||||
|
&& !RevokedUpliftStatuses.Contains(u.Status)
|
||||||
|
&& u.Dispatch != null
|
||||||
|
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
|
||||||
|
&& (
|
||||||
|
u.Dispatch.WorkOrderId == w.Id
|
||||||
|
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id))
|
||||||
|
&& (mappedStatuses.Count == 0 || mappedStatuses.Contains(u.Status))));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<string> MapUpliftStatuses(IReadOnlyList<string>? upliftStatuses)
|
||||||
|
{
|
||||||
|
if (upliftStatuses == null || upliftStatuses.Count == 0)
|
||||||
|
return new List<string>();
|
||||||
|
|
||||||
|
var mapped = new List<string>();
|
||||||
|
foreach (var raw in upliftStatuses)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
switch (raw.Trim().ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "pending":
|
||||||
|
mapped.Add("Pending");
|
||||||
|
mapped.Add("ChangesRequested");
|
||||||
|
break;
|
||||||
|
case "approved":
|
||||||
|
mapped.Add("Approved");
|
||||||
|
break;
|
||||||
|
case "auto_approved":
|
||||||
|
mapped.Add("NoApprovalRequired");
|
||||||
|
break;
|
||||||
|
case "rejected":
|
||||||
|
mapped.Add("Rejected");
|
||||||
|
mapped.Add("Denied");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped.Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,7 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|
||||||
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|
||||||
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|
||||||
|
|| (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|
||||||
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|
||||||
|| ("overdue".Contains(s)
|
|| ("overdue".Contains(s)
|
||||||
&& w.ScheduledDate != null
|
&& w.ScheduledDate != null
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
|
||||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplySeverityFilter(baseQuery, query.Severities);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyRescheduledFilter(baseQuery, query.Rescheduled);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyCarriedOverFilter(baseQuery, query.CarriedOver);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyAddOnFilter(baseQuery, query.AddOn);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyFlagColorFilter(baseQuery, query.FlagColors);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyInternalOnlyFilter(baseQuery, query.InternalOnly);
|
||||||
|
baseQuery = WorkOrderBoardSearchFacetFilters.ApplyUpliftFilter(
|
||||||
|
baseQuery, _context, query.HasUplift, query.UpliftStatuses);
|
||||||
|
|
||||||
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
|
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
|
||||||
if (normalizedSearch != null)
|
if (normalizedSearch != null)
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var batch = await _context.workOrders
|
var batch = await _context.workOrders
|
||||||
.Where(w => w.IsDeleted != true)
|
.Where(w => w.IsDeleted != true)
|
||||||
.Where(w => w.istemplate != true)
|
.Where(w => w.istemplate != true)
|
||||||
.Where(w => w.ScheduledDate != null)
|
.Where(w => w.DueDate != null)
|
||||||
.OrderBy(w => w.Id)
|
.OrderBy(w => w.Id)
|
||||||
.Skip(skip)
|
.Skip(skip)
|
||||||
.Take(batchSize)
|
.Take(batchSize)
|
||||||
|
|
@ -132,7 +132,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
foreach (var workOrder in batch)
|
foreach (var workOrder in batch)
|
||||||
{
|
{
|
||||||
examinedCount++;
|
examinedCount++;
|
||||||
var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
|
var shouldBePastDue = workOrder.DueDate!.Value.Date < today
|
||||||
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
|
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
|
||||||
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);
|
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
IReadOnlyList<string>? PmTypes,
|
IReadOnlyList<string>? PmTypes,
|
||||||
IReadOnlyList<int>? VendorIds,
|
IReadOnlyList<int>? VendorIds,
|
||||||
IReadOnlyList<DocStatus>? DocStatuses,
|
IReadOnlyList<DocStatus>? DocStatuses,
|
||||||
|
IReadOnlyList<int>? Severities,
|
||||||
|
bool Rescheduled,
|
||||||
|
bool CarriedOver,
|
||||||
|
bool AddOn,
|
||||||
|
IReadOnlyList<string>? FlagColors,
|
||||||
|
bool InternalOnly,
|
||||||
|
bool HasUplift,
|
||||||
|
IReadOnlyList<string>? UpliftStatuses,
|
||||||
bool MyWorkOrders,
|
bool MyWorkOrders,
|
||||||
string? CurrentUserId,
|
string? CurrentUserId,
|
||||||
int Page,
|
int Page,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
string? InternalWONumber,
|
string? InternalWONumber,
|
||||||
int RescheduleCount,
|
int RescheduleCount,
|
||||||
int CarriedOver,
|
int CarriedOver,
|
||||||
|
bool IsAddOn,
|
||||||
WorkOrderType? WorkOrderType,
|
WorkOrderType? WorkOrderType,
|
||||||
string? SiteCode,
|
string? SiteCode,
|
||||||
string? LocationName,
|
string? LocationName,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? WoNumber { get; set; }
|
public string? WoNumber { get; set; }
|
||||||
public int RescheduleCount { get; set; }
|
public int RescheduleCount { get; set; }
|
||||||
public int CarriedOver { get; set; }
|
public int CarriedOver { get; set; }
|
||||||
|
public bool IsAddOn { get; set; }
|
||||||
public WorkOrderType? WorkOrderType { get; set; }
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
public bool IsPastDue { get; set; }
|
public bool IsPastDue { get; set; }
|
||||||
public string? SiteCode { get; set; }
|
public string? SiteCode { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public string? WoNumber { get; set; }
|
public string? WoNumber { get; set; }
|
||||||
public WorkOrderType WorkOrderType { get; set; }
|
public WorkOrderType WorkOrderType { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Manual Add-On override when unscheduled. Ignored when ScheduledDate or TargetWeek is set
|
||||||
|
/// (server computes from cutoff).
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsAddOn { get; set; }
|
||||||
public string? SiteCode { get; set; }
|
public string? SiteCode { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)
|
/// Optional CRM customer name. Required when the caller is org-wide (no account_id)
|
||||||
|
|
@ -63,6 +68,14 @@ namespace SeaHaven.Services.DTOs
|
||||||
public List<string>? PmTypes { get; set; }
|
public List<string>? PmTypes { get; set; }
|
||||||
public List<int>? VendorIds { get; set; }
|
public List<int>? VendorIds { get; set; }
|
||||||
public List<DocStatus>? DocStatuses { get; set; }
|
public List<DocStatus>? DocStatuses { get; set; }
|
||||||
|
public List<int>? Severities { get; set; }
|
||||||
|
public bool Rescheduled { get; set; }
|
||||||
|
public bool CarriedOver { get; set; }
|
||||||
|
public bool AddOn { get; set; }
|
||||||
|
public List<string>? FlagColors { get; set; }
|
||||||
|
public bool InternalOnly { get; set; }
|
||||||
|
public bool HasUplift { get; set; }
|
||||||
|
public List<string>? UpliftStatuses { get; set; }
|
||||||
public bool MyWorkOrders { get; set; }
|
public bool MyWorkOrders { get; set; }
|
||||||
public string? SortBy { get; set; }
|
public string? SortBy { get; set; }
|
||||||
public string? SortDir { get; set; }
|
public string? SortDir { get; set; }
|
||||||
|
|
|
||||||
77
SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs
Normal file
77
SeaHaven.Services/Helpers/WorkOrderAddonCutoff.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side Add-On cutoff: Saturday 23:59:59 UTC of the week prior to the schedule week.
|
||||||
|
/// SH-184: recalculated whenever scheduling is set, changed, or cleared; audited on change.
|
||||||
|
/// Create may accept a client hint when unscheduled; schedule present ignores override.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkOrderAddonCutoff
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cutoff = Monday-of(anchor) − 2 days, end of day UTC (prior-week Saturday 23:59:59).
|
||||||
|
/// </summary>
|
||||||
|
public static DateTime GetAddonCutoffUtc(DateTime anchor)
|
||||||
|
{
|
||||||
|
var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(anchor);
|
||||||
|
var saturday = monday.Date.AddDays(-2);
|
||||||
|
return DateTime.SpecifyKind(
|
||||||
|
saturday.AddHours(23).AddMinutes(59).AddSeconds(59),
|
||||||
|
DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTime GetAddonCutoffUtc(DateOnly anchor)
|
||||||
|
=> GetAddonCutoffUtc(anchor.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// With schedule (ScheduledDate or TargetWeek): IsAddOn when createdAtUtc is after cutoff.
|
||||||
|
/// Without schedule: client <paramref name="manualOverride"/> (default false).
|
||||||
|
/// </summary>
|
||||||
|
public static bool ComputeIsAddOnAtCreation(
|
||||||
|
DateTime createdAtUtc,
|
||||||
|
DateTime? scheduledDate,
|
||||||
|
DateOnly? targetWeek,
|
||||||
|
bool? manualOverride)
|
||||||
|
{
|
||||||
|
return ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SH-184 authoritative recalc after schedule set/change/clear. No manual override on patch.
|
||||||
|
/// Cleared schedule yields false.
|
||||||
|
/// </summary>
|
||||||
|
public static bool RecalculateIsAddOnFromSchedule(
|
||||||
|
DateTime createdAtUtc,
|
||||||
|
DateTime? scheduledDate,
|
||||||
|
DateOnly? targetWeek)
|
||||||
|
=> ComputeFromScheduleAnchor(createdAtUtc, scheduledDate, targetWeek, manualOverride: false);
|
||||||
|
|
||||||
|
private static bool ComputeFromScheduleAnchor(
|
||||||
|
DateTime createdAtUtc,
|
||||||
|
DateTime? scheduledDate,
|
||||||
|
DateOnly? targetWeek,
|
||||||
|
bool? manualOverride)
|
||||||
|
{
|
||||||
|
DateTime? anchor = null;
|
||||||
|
if (scheduledDate.HasValue)
|
||||||
|
anchor = scheduledDate.Value;
|
||||||
|
else if (targetWeek.HasValue)
|
||||||
|
anchor = targetWeek.Value.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
if (!anchor.HasValue)
|
||||||
|
return manualOverride ?? false;
|
||||||
|
|
||||||
|
var cutoff = GetAddonCutoffUtc(anchor.Value);
|
||||||
|
var created = NormalizeUtc(createdAtUtc);
|
||||||
|
return created > cutoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime NormalizeUtc(DateTime value)
|
||||||
|
{
|
||||||
|
if (value.Kind == DateTimeKind.Utc)
|
||||||
|
return value;
|
||||||
|
if (value.Kind == DateTimeKind.Local)
|
||||||
|
return value.ToUniversalTime();
|
||||||
|
return DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
|
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
private static DateTime GetWeekStartMonday(DateTime date)
|
public static DateTime GetWeekStartMonday(DateTime date)
|
||||||
{
|
{
|
||||||
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
||||||
return date.Date.AddDays(-diff);
|
return date.Date.AddDays(-diff);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Helpers
|
namespace SeaHaven.Services.Helpers
|
||||||
|
|
@ -6,7 +6,8 @@ namespace SeaHaven.Services.Helpers
|
||||||
public static class WorkOrderDerivedFields
|
public static class WorkOrderDerivedFields
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
|
/// Past due overlay from Schedule On — never a WorkOrderType. Matches SHOC board design (SH-185).
|
||||||
|
/// Changing Due Date alone must never trigger or clear it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
||||||
{
|
{
|
||||||
|
|
@ -21,7 +22,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Board read-model past-due check. Phase 1 compares UTC calendar dates;
|
/// Board read-model past-due check against ScheduledDate. Compares UTC calendar dates;
|
||||||
/// business/location timezone support is planned for a later phase.
|
/// business/location timezone support is planned for a later phase.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsPastDue(
|
public static bool IsPastDue(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
query.PmTypes,
|
query.PmTypes,
|
||||||
query.VendorIds,
|
query.VendorIds,
|
||||||
query.DocStatuses,
|
query.DocStatuses,
|
||||||
|
query.Severities,
|
||||||
|
query.Rescheduled,
|
||||||
|
query.CarriedOver,
|
||||||
|
query.AddOn,
|
||||||
|
query.FlagColors,
|
||||||
|
query.InternalOnly,
|
||||||
|
query.HasUplift,
|
||||||
|
query.UpliftStatuses,
|
||||||
query.MyWorkOrders,
|
query.MyWorkOrders,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
||||||
|
|
||||||
|
workOrder.IsAddOn = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
workOrder.CreatedDate!.Value,
|
||||||
|
request.ScheduledDate,
|
||||||
|
request.TargetWeek,
|
||||||
|
request.IsAddOn);
|
||||||
|
|
||||||
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
||||||
{
|
{
|
||||||
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),
|
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
WoNumber = row.InternalWONumber,
|
WoNumber = row.InternalWONumber,
|
||||||
RescheduleCount = row.RescheduleCount,
|
RescheduleCount = row.RescheduleCount,
|
||||||
CarriedOver = row.CarriedOver,
|
CarriedOver = row.CarriedOver,
|
||||||
|
IsAddOn = row.IsAddOn,
|
||||||
WorkOrderType = row.WorkOrderType,
|
WorkOrderType = row.WorkOrderType,
|
||||||
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
||||||
SiteCode = row.SiteCode,
|
SiteCode = row.SiteCode,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
|
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
|
||||||
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
|
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
|
||||||
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first.");
|
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update Schedule On first.");
|
||||||
|
|
||||||
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
_mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion);
|
||||||
|
|
||||||
|
|
@ -147,8 +147,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
|
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
|
||||||
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
|
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
|
||||||
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
|
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
|
||||||
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
|
WorkOrderBoardFieldNames.TargetWeek => ApplyTargetWeek(workOrder, value, auditField),
|
||||||
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
|
WorkOrderBoardFieldNames.ScheduleWeekOnly => ApplyScheduleWeekOnly(workOrder, value, auditField),
|
||||||
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
|
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
|
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
|
||||||
|
|
@ -232,7 +232,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase))
|
||||||
throw new WorkOrderBoardValidationException(
|
throw new WorkOrderBoardValidationException(
|
||||||
"InvalidValue",
|
"InvalidValue",
|
||||||
"Overdue is not a work order type; it is derived from scheduledDate (isPastDue).");
|
"Overdue is not a work order type; it is derived from dueDate (isPastDue).");
|
||||||
|
|
||||||
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
|
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
|
||||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
||||||
|
|
@ -437,10 +437,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||||
|
AppendIsAddOnRecalculation(workOrder, changes);
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
|
private static List<FieldChange> ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
|
||||||
{
|
{
|
||||||
DateOnly? parsed = null;
|
DateOnly? parsed = null;
|
||||||
if (!string.IsNullOrWhiteSpace(value))
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
|
@ -450,13 +451,42 @@ namespace SeaHaven.Services.Implementation
|
||||||
parsed = d;
|
parsed = d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var changes = new List<FieldChange>();
|
||||||
var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd");
|
var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd");
|
||||||
var newVal = parsed?.ToString("yyyy-MM-dd");
|
var newVal = parsed?.ToString("yyyy-MM-dd");
|
||||||
if (old == newVal)
|
if (old != newVal)
|
||||||
return FieldChange.Unchanged(auditField);
|
{
|
||||||
|
workOrder.TargetWeek = parsed;
|
||||||
|
changes.Add(FieldChange.ForField(auditField, old, newVal));
|
||||||
|
}
|
||||||
|
|
||||||
workOrder.TargetWeek = parsed;
|
AppendIsAddOnRecalculation(workOrder, changes);
|
||||||
return FieldChange.ForField(auditField, old, newVal);
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FieldChange> ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly);
|
||||||
|
var changes = new List<FieldChange> { change };
|
||||||
|
if (change.HasChanged)
|
||||||
|
AppendIsAddOnRecalculation(workOrder, changes);
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List<FieldChange> changes)
|
||||||
|
{
|
||||||
|
var createdAt = workOrder.CreatedDate ?? DateTime.UtcNow;
|
||||||
|
var oldValue = workOrder.IsAddOn.ToString();
|
||||||
|
var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
|
||||||
|
createdAt,
|
||||||
|
workOrder.ScheduledDate,
|
||||||
|
workOrder.TargetWeek);
|
||||||
|
|
||||||
|
if (workOrder.IsAddOn == newIsAddOn)
|
||||||
|
return;
|
||||||
|
|
||||||
|
workOrder.IsAddOn = newIsAddOn;
|
||||||
|
changes.Add(FieldChange.ForField("IsAddOn", oldValue, newIsAddOn.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FieldChange ApplyBoolField(
|
private static FieldChange ApplyBoolField(
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
WoNumber = row.WoNumber,
|
WoNumber = row.WoNumber,
|
||||||
RescheduleCount = row.RescheduleCount,
|
RescheduleCount = row.RescheduleCount,
|
||||||
CarriedOver = row.CarriedOver,
|
CarriedOver = row.CarriedOver,
|
||||||
|
IsAddOn = row.IsAddOn,
|
||||||
WorkOrderType = row.WorkOrderType,
|
WorkOrderType = row.WorkOrderType,
|
||||||
IsPastDue = row.IsPastDue,
|
IsPastDue = row.IsPastDue,
|
||||||
SiteCode = row.SiteCode,
|
SiteCode = row.SiteCode,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Validation
|
namespace SeaHaven.Services.Validation
|
||||||
|
|
@ -12,7 +13,9 @@ namespace SeaHaven.Services.Validation
|
||||||
public WorkOrderBoardCreateValidation()
|
public WorkOrderBoardCreateValidation()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.WorkOrderType)
|
RuleFor(x => x.WorkOrderType)
|
||||||
.IsInEnum().WithMessage("workOrderType is required and must be a valid value.");
|
.IsInEnum().WithMessage("workOrderType is required and must be a valid value.")
|
||||||
|
.Must(t => t != WorkOrderType.AddOn)
|
||||||
|
.WithMessage("workOrderType AddOn is not allowed on create; use isAddOn with a real type.");
|
||||||
|
|
||||||
RuleFor(x => x.SiteCode)
|
RuleFor(x => x.SiteCode)
|
||||||
.NotEmpty().WithMessage("siteCode is required.")
|
.NotEmpty().WithMessage("siteCode is required.")
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs
Normal file
135
SeaHavenIndustries.Tests/WorkOrderAddonCutoffTests.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderAddonCutoffTests
|
||||||
|
{
|
||||||
|
// Schedule week Mon 2026-06-22 → cutoff Sat 2026-06-20 23:59:59 UTC
|
||||||
|
private static readonly DateTime ScheduleMonday = new(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static readonly DateTime ExpectedCutoff = new(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAddonCutoffUtc_FromScheduledDate_IsPriorSaturdayEndOfDay()
|
||||||
|
{
|
||||||
|
// Wednesday in schedule week
|
||||||
|
var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateTime(2026, 6, 24, 15, 0, 0, DateTimeKind.Utc));
|
||||||
|
Assert.Equal(ExpectedCutoff, cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAddonCutoffUtc_FromTargetWeekMonday_IsPriorSaturdayEndOfDay()
|
||||||
|
{
|
||||||
|
var cutoff = WorkOrderAddonCutoff.GetAddonCutoffUtc(new DateOnly(2026, 6, 22));
|
||||||
|
Assert.Equal(ExpectedCutoff, cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAddonCutoffUtc_FromMondayAnchor_MatchesGetWeekStartMondayMinusTwo()
|
||||||
|
{
|
||||||
|
var monday = WorkOrderBoardMutationRules.GetWeekStartMonday(ScheduleMonday);
|
||||||
|
Assert.Equal(ScheduleMonday.Date, monday.Date);
|
||||||
|
Assert.Equal(ExpectedCutoff, WorkOrderAddonCutoff.GetAddonCutoffUtc(ScheduleMonday));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_AfterCutoff_ReturnsTrue_IgnoresManualFalse()
|
||||||
|
{
|
||||||
|
var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
created,
|
||||||
|
scheduledDate: new DateTime(2026, 6, 24),
|
||||||
|
targetWeek: null,
|
||||||
|
manualOverride: false);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_BeforeCutoff_ReturnsFalse_IgnoresManualTrue()
|
||||||
|
{
|
||||||
|
var created = new DateTime(2026, 6, 20, 23, 59, 59, DateTimeKind.Utc);
|
||||||
|
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
created,
|
||||||
|
scheduledDate: new DateTime(2026, 6, 24),
|
||||||
|
targetWeek: null,
|
||||||
|
manualOverride: true);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_ExactlyAtCutoff_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
ExpectedCutoff,
|
||||||
|
scheduledDate: ScheduleMonday,
|
||||||
|
targetWeek: null,
|
||||||
|
manualOverride: true);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_TargetWeekOnly_UsesCutoff()
|
||||||
|
{
|
||||||
|
var after = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
scheduledDate: null,
|
||||||
|
targetWeek: new DateOnly(2026, 6, 22),
|
||||||
|
manualOverride: false);
|
||||||
|
|
||||||
|
var before = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
new DateTime(2026, 6, 19, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
scheduledDate: null,
|
||||||
|
targetWeek: new DateOnly(2026, 6, 22),
|
||||||
|
manualOverride: true);
|
||||||
|
|
||||||
|
Assert.True(after);
|
||||||
|
Assert.False(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_NoSchedule_UsesManualOverride()
|
||||||
|
{
|
||||||
|
Assert.True(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
DateTime.UtcNow, null, null, manualOverride: true));
|
||||||
|
Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
DateTime.UtcNow, null, null, manualOverride: false));
|
||||||
|
Assert.False(WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
DateTime.UtcNow, null, null, manualOverride: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_ScheduledDatePreferredOverTargetWeek()
|
||||||
|
{
|
||||||
|
// Target week far future (cutoff after created) would be false;
|
||||||
|
// scheduled date near past (cutoff before created) → true.
|
||||||
|
var created = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var result = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
||||||
|
created,
|
||||||
|
scheduledDate: new DateTime(2026, 6, 24),
|
||||||
|
targetWeek: new DateOnly(2099, 1, 5),
|
||||||
|
manualOverride: false);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecalculateIsAddOnFromSchedule_NoSchedule_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
Assert.False(WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(created, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecalculateIsAddOnFromSchedule_AfterCutoff_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var created = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var result = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule(
|
||||||
|
created,
|
||||||
|
scheduledDate: new DateTime(2026, 6, 24),
|
||||||
|
targetWeek: null);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -141,8 +141,11 @@ public class WorkOrderBoardConcurrencyTests
|
||||||
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
|
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
|
||||||
}, "actor-1");
|
}, "actor-1");
|
||||||
|
|
||||||
var auditCount = await context.WorkOrderAuditLogs.CountAsync();
|
var audits = await context.WorkOrderAuditLogs.ToListAsync();
|
||||||
Assert.Equal(2, auditCount);
|
Assert.Equal(3, audits.Count);
|
||||||
|
Assert.Contains(audits, a => a.FieldName == "IsAddOn");
|
||||||
|
Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
|
||||||
|
Assert.Contains(audits, a => a.FieldName == "RescheduleCount");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -351,4 +351,77 @@ public class WorkOrderBoardCreateServiceTests
|
||||||
|
|
||||||
Assert.Equal("VendorNotFound", ex.Code);
|
Assert.Equal("VendorNotFound", ex.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_ScheduledAfterCutoff_SetsIsAddOnTrue_KeepsRealType()
|
||||||
|
{
|
||||||
|
// UtcNow (test run) is after cutoff for a 2026-06 schedule week.
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
IsAddOn = false
|
||||||
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
|
Assert.True(result.IsAddOn);
|
||||||
|
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
|
||||||
|
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||||
|
Assert.True(wo.IsAddOn);
|
||||||
|
Assert.Equal(WorkOrderType.PM, wo.WorkOrderType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_ScheduledBeforeCutoff_SetsIsAddOnFalse_IgnoresClientTrue()
|
||||||
|
{
|
||||||
|
// Far-future schedule → cutoff after UtcNow → not an add-on.
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
ScheduledDate = new DateTime(2099, 3, 10),
|
||||||
|
IsAddOn = true
|
||||||
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
|
Assert.False(result.IsAddOn);
|
||||||
|
Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
|
||||||
|
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||||
|
Assert.False(wo.IsAddOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_NoSchedule_ManualIsAddOnTrue()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
IsAddOn = true
|
||||||
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
|
||||||
|
|
||||||
|
Assert.True(result.IsAddOn);
|
||||||
|
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||||
|
Assert.True(wo.IsAddOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WorkOrderTypeAddOn_ThrowsValidation()
|
||||||
|
{
|
||||||
|
var (_, service) = CreateSut();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
|
||||||
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.AddOn,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"));
|
||||||
|
|
||||||
|
Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ public class WorkOrderBoardMutationRulesTests
|
||||||
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
|
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
|
||||||
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
|
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
|
||||||
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
|
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
|
||||||
|
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
|
||||||
|
WorkOrderBoardFieldNames.DueDate, isPastDue: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -342,7 +342,8 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
ScheduledDate = today.AddDays(-3),
|
DueDate = today.AddDays(-3),
|
||||||
|
ScheduledDate = today.AddDays(5),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
InternalWONumber = "10000000001"
|
InternalWONumber = "10000000001"
|
||||||
},
|
},
|
||||||
|
|
@ -358,7 +359,8 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
ScheduledDate = today.AddDays(1),
|
DueDate = today.AddDays(1),
|
||||||
|
ScheduledDate = today.AddDays(-5),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
InternalWONumber = "10000000003"
|
InternalWONumber = "10000000003"
|
||||||
});
|
});
|
||||||
|
|
@ -377,7 +379,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||||
|
|
||||||
Assert.Equal(1, result.TotalCount);
|
Assert.Equal(1, result.TotalCount);
|
||||||
Assert.Equal(1, result.Items.First().Id);
|
Assert.Equal(3, result.Items.First().Id);
|
||||||
Assert.True(result.Items.First().IsPastDue);
|
Assert.True(result.Items.First().IsPastDue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -392,6 +394,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
DueDate = today.AddDays(5),
|
||||||
ScheduledDate = today.AddDays(-3),
|
ScheduledDate = today.AddDays(-3),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
InternalWONumber = "10000000001"
|
InternalWONumber = "10000000001"
|
||||||
|
|
@ -408,6 +411,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
DueDate = today.AddDays(1),
|
||||||
ScheduledDate = today.AddDays(1),
|
ScheduledDate = today.AddDays(1),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
InternalWONumber = "10000000003"
|
InternalWONumber = "10000000003"
|
||||||
|
|
@ -444,7 +448,8 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
WorkOrderType = WorkOrderType.Other,
|
WorkOrderType = WorkOrderType.Other,
|
||||||
ScheduledDate = today.AddDays(5),
|
DueDate = today.AddDays(5),
|
||||||
|
ScheduledDate = today.AddDays(-5),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
InternalWONumber = "10000000001"
|
InternalWONumber = "10000000001"
|
||||||
},
|
},
|
||||||
|
|
@ -452,7 +457,8 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
ScheduledDate = today.AddDays(-3),
|
DueDate = today.AddDays(-3),
|
||||||
|
ScheduledDate = today.AddDays(5),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
InternalWONumber = "10000000002"
|
InternalWONumber = "10000000002"
|
||||||
},
|
},
|
||||||
|
|
@ -460,6 +466,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
DueDate = today.AddDays(-2),
|
||||||
ScheduledDate = today.AddDays(-2),
|
ScheduledDate = today.AddDays(-2),
|
||||||
LifecycleStatus = LifecycleStatus.Completed,
|
LifecycleStatus = LifecycleStatus.Completed,
|
||||||
InternalWONumber = "10000000003"
|
InternalWONumber = "10000000003"
|
||||||
|
|
@ -468,6 +475,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
{
|
{
|
||||||
Id = 4,
|
Id = 4,
|
||||||
WorkOrderType = WorkOrderType.Reactive,
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
DueDate = today.AddDays(-1),
|
||||||
ScheduledDate = today.AddDays(-1),
|
ScheduledDate = today.AddDays(-1),
|
||||||
LifecycleStatus = LifecycleStatus.Canceled,
|
LifecycleStatus = LifecycleStatus.Canceled,
|
||||||
InternalWONumber = "10000000004"
|
InternalWONumber = "10000000004"
|
||||||
|
|
@ -490,9 +498,9 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
|
|
||||||
var overdue = Assert.Single(result.Items);
|
var overdue = Assert.Single(result.Items);
|
||||||
Assert.Equal(0, result.Page);
|
Assert.Equal(0, result.Page);
|
||||||
Assert.Equal(2, overdue.Id);
|
Assert.Equal(1, overdue.Id);
|
||||||
Assert.True(overdue.IsPastDue);
|
Assert.True(overdue.IsPastDue);
|
||||||
Assert.DoesNotContain(result.Items, i => i.Id == 1);
|
Assert.DoesNotContain(result.Items, i => i.Id == 2);
|
||||||
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
||||||
Assert.DoesNotContain(result.Items, i => i.Id == 4);
|
Assert.DoesNotContain(result.Items, i => i.Id == 4);
|
||||||
}
|
}
|
||||||
|
|
@ -605,4 +613,220 @@ public class WorkOrderAdvancedSearchServiceTests
|
||||||
|
|
||||||
Assert.Contains("sortBy", ex.Message);
|
Assert.Contains("sortBy", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_TypesAddOn_MatchesLegacyTypeOrIsAddOnFlag()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
WorkOrderType = WorkOrderType.AddOn,
|
||||||
|
IsAddOn = false,
|
||||||
|
ScheduledDate = today.AddDays(1),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000001"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
IsAddOn = true,
|
||||||
|
ScheduledDate = today.AddDays(2),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000002"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
IsAddOn = false,
|
||||||
|
ScheduledDate = today.AddDays(3),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000003"
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||||
|
dataService,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
|
||||||
|
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
|
||||||
|
Types = new List<WorkOrderType> { WorkOrderType.AddOn }
|
||||||
|
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.TotalCount);
|
||||||
|
Assert.Contains(result.Items, i => i.Id == 1 && i.WorkOrderType == WorkOrderType.AddOn);
|
||||||
|
Assert.Contains(result.Items, i => i.Id == 2 && i.IsAddOn && i.WorkOrderType == WorkOrderType.PM);
|
||||||
|
Assert.DoesNotContain(result.Items, i => i.Id == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_SeverityFilter_ReturnsEmergencyReactiveOnly()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
WorkOrderType = WorkOrderType.Emergency,
|
||||||
|
Severity = "1",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
WorkOrderType = WorkOrderType.Reactive,
|
||||||
|
Severity = "2",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
Severity = "1",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 25),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000003",
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||||
|
dataService,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(
|
||||||
|
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = weekStart,
|
||||||
|
DateTo = weekStart.AddDays(6),
|
||||||
|
Severities = new List<int> { 1 },
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal(1, result.Items.Single().Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_InternalOnly_ReturnsShPrefixRows()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "SH-1001",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||||
|
dataService,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(
|
||||||
|
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = weekStart,
|
||||||
|
DateTo = weekStart.AddDays(6),
|
||||||
|
InternalOnly = true,
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal("SH-1001", result.Items.Single().WoNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_HasUpliftWithPendingStatus_FiltersRows()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
||||||
|
context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1, WorkOrderId = 1 });
|
||||||
|
context.Dispatches.Add(new Dispatch { Id = 11, VendorId = 1, WorkOrderId = 2 });
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = 10,
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = 11,
|
||||||
|
});
|
||||||
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
||||||
|
{
|
||||||
|
Id = 100,
|
||||||
|
DispatchId = 10,
|
||||||
|
RequestedNTE = 1500m,
|
||||||
|
Status = "Pending",
|
||||||
|
RequiredTier = 1,
|
||||||
|
NotificationStatus = "Pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(
|
||||||
|
dataService,
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(
|
||||||
|
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
DateFrom = weekStart,
|
||||||
|
DateTo = weekStart.AddDays(6),
|
||||||
|
HasUplift = true,
|
||||||
|
UpliftStatuses = new List<string> { "pending" },
|
||||||
|
},
|
||||||
|
WorkOrderAccountTestHelpers.OrgWideAdmin(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal(1, result.Items.Single().Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,7 @@ public class WorkOrderBoardServiceTests
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
InternalWONumber = "10000000001",
|
InternalWONumber = "10000000001",
|
||||||
|
DueDate = new DateTime(2026, 6, 20),
|
||||||
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
});
|
});
|
||||||
|
|
@ -416,6 +417,37 @@ public class WorkOrderBoardServiceTests
|
||||||
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
|
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardAsync_MapsIsAddOn()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
IsAddOn = true,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var boardService = new WorkOrderBoardService(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||||
|
{
|
||||||
|
WeekStart = weekStart
|
||||||
|
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||||
|
|
||||||
|
Assert.Single(response.Scheduled);
|
||||||
|
Assert.True(response.Scheduled[0].IsAddOn);
|
||||||
|
Assert.Equal(WorkOrderType.PM, response.Scheduled[0].WorkOrderType);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
|
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,11 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
}, "actor-1");
|
}, "actor-1");
|
||||||
|
|
||||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||||
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
|
var audits = await context.WorkOrderAuditLogs.ToListAsync();
|
||||||
|
Assert.Equal(3, audits.Count);
|
||||||
|
Assert.Contains(audits, a => a.FieldName == "IsAddOn");
|
||||||
|
Assert.Contains(audits, a => a.FieldName == "ScheduledDate");
|
||||||
|
Assert.Contains(audits, a => a.Action == AuditActionType.StatusChanged.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -91,6 +95,7 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
DueDate = DateTime.UtcNow.Date.AddDays(5),
|
||||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
};
|
};
|
||||||
|
|
@ -106,6 +111,149 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
}, "actor-1"));
|
}, "actor-1"));
|
||||||
|
|
||||||
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||||
|
Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_FutureSchedule_ClearsPastDueAndAllowsStatusChange()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
DueDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||||
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var futureSchedule = DateTime.UtcNow.Date.AddDays(7).ToString("yyyy-MM-dd");
|
||||||
|
var row = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = futureSchedule,
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.False(row.IsPastDue);
|
||||||
|
Assert.Equal(DateTime.UtcNow.Date.AddDays(7), row.ScheduledDate!.Value.Date);
|
||||||
|
|
||||||
|
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
|
var afterStatus = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||||
|
Value = "Complete",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Completed, afterStatus.LifecycleStatus);
|
||||||
|
Assert.False(afterStatus.IsPastDue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_DueDateAlone_DoesNotClearPastDue()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
DueDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||||
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-1),
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var futureDue = DateTime.UtcNow.Date.AddDays(10).ToString("yyyy-MM-dd");
|
||||||
|
var afterDue = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.DueDate,
|
||||||
|
Value = futureDue,
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.True(afterDue.IsPastDue);
|
||||||
|
Assert.Equal(DateTime.UtcNow.Date.AddDays(-1), afterDue.ScheduledDate!.Value.Date);
|
||||||
|
|
||||||
|
wo = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||||
|
Value = "Complete",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||||
|
Assert.Contains("Schedule On", ex.Message, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("due date", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_DueDate_DoesNotChangeScheduledDate()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var scheduled = new DateTime(2026, 6, 20);
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
DueDate = new DateTime(2026, 6, 18),
|
||||||
|
ScheduledDate = scheduled,
|
||||||
|
RescheduleCount = 2,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.DueDate,
|
||||||
|
Value = "2026-07-01",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(new DateTime(2026, 7, 1), result.DueDate!.Value.Date);
|
||||||
|
Assert.Equal(scheduled, result.ScheduledDate);
|
||||||
|
Assert.Equal(2, result.RescheduleCount);
|
||||||
|
|
||||||
|
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
|
Assert.Equal(scheduled, persisted.ScheduledDate);
|
||||||
|
Assert.Equal(2, persisted.RescheduleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_ScheduledDate_DoesNotChangeDueDate()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var due = new DateTime(2026, 6, 18);
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
DueDate = due,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 20),
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = "2026-06-25",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate!.Value.Date);
|
||||||
|
Assert.Equal(due, result.DueDate);
|
||||||
|
|
||||||
|
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
|
Assert.Equal(due, persisted.DueDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -132,7 +280,7 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
|
|
||||||
Assert.Equal("InvalidValue", ex.Code);
|
Assert.Equal("InvalidValue", ex.Code);
|
||||||
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
|
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
|
||||||
Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -853,4 +1001,93 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
Assert.Null(result.CompletedDate);
|
Assert.Null(result.CompletedDate);
|
||||||
Assert.Null(context.workOrders.Single().CompletedDate);
|
Assert.Null(context.workOrders.Single().CompletedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_ClearsIsAddOn_WhenScheduleCleared()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 20),
|
||||||
|
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
IsAddOn = true,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = "",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.False(result.IsAddOn);
|
||||||
|
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
|
Assert.False(persisted.IsAddOn);
|
||||||
|
Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_ScheduledDate_RecalculatesIsAddOn_AfterCrossWeekMove()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 10),
|
||||||
|
CreatedDate = new DateTime(2026, 6, 21, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
IsAddOn = false,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = "2026-06-25",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.True(result.IsAddOn);
|
||||||
|
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
|
Assert.True(persisted.IsAddOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_WorkOrderType_DoesNotRecalculateIsAddOn()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
IsAddOn = true,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.WorkOrderType,
|
||||||
|
Value = "Reactive",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(WorkOrderType.Reactive, result.WorkOrderType);
|
||||||
|
Assert.True(result.IsAddOn);
|
||||||
|
var persisted = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||||
|
Assert.Equal(WorkOrderType.Reactive, persisted.WorkOrderType);
|
||||||
|
Assert.True(persisted.IsAddOn);
|
||||||
|
Assert.Empty(await context.WorkOrderAuditLogs.Where(a => a.FieldName == "IsAddOn").ToListAsync());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,28 +6,80 @@ namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public class WorkOrderDerivedFieldsTests
|
public class WorkOrderDerivedFieldsTests
|
||||||
{
|
{
|
||||||
|
private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
private static readonly DateTime Past = DateTime.UtcNow.Date.AddDays(-2);
|
||||||
|
private static readonly DateTime Future = DateTime.UtcNow.Date.AddDays(3);
|
||||||
|
private static readonly DateTime TodayDate = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
|
public void IsPastDue_WhenSchedulePastAndDueFuture_ReturnsTrue()
|
||||||
{
|
{
|
||||||
var wo = new WorkOrder
|
var wo = new WorkOrder
|
||||||
{
|
{
|
||||||
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
DueDate = Future,
|
||||||
|
ScheduledDate = Past,
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
|
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||||
|
Assert.True(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsPastDue_WhenCompleted_ReturnsFalse()
|
public void IsPastDue_WhenScheduleFutureAndDuePast_ReturnsFalse()
|
||||||
{
|
{
|
||||||
var wo = new WorkOrder
|
var wo = new WorkOrder
|
||||||
{
|
{
|
||||||
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
DueDate = Past,
|
||||||
LifecycleStatus = LifecycleStatus.Completed
|
ScheduledDate = Future,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
|
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||||
|
Assert.False(WorkOrderDerivedFields.IsPastDue(wo.ScheduledDate, wo.LifecycleStatus));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPastDue_WhenScheduledDateNull_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
DueDate = Past,
|
||||||
|
ScheduledDate = null,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||||
|
Assert.False(WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Scheduled));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPastDue_WhenScheduleEqualsToday_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
DueDate = Past,
|
||||||
|
ScheduledDate = TodayDate,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LifecycleStatus.Completed)]
|
||||||
|
[InlineData(LifecycleStatus.Canceled)]
|
||||||
|
public void IsPastDue_WhenTerminal_ReturnsFalse(LifecycleStatus status)
|
||||||
|
{
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
DueDate = Past,
|
||||||
|
ScheduledDate = Past,
|
||||||
|
LifecycleStatus = status
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, Today));
|
||||||
|
Assert.False(WorkOrderDerivedFields.IsPastDue(Past, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ public class WorkOrderDetailServiceTests
|
||||||
Id = 1,
|
Id = 1,
|
||||||
InternalWONumber = "00000000001",
|
InternalWONumber = "00000000001",
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
DueDate = DateTime.UtcNow.AddDays(3),
|
||||||
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||||
Trade = "HVAC PM",
|
Trade = "HVAC PM",
|
||||||
WorkOrderType = WorkOrderType.PM,
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
|
|
||||||
|
|
@ -240,12 +240,14 @@ public class WorkOrderWeekRolledTests
|
||||||
new WorkOrder
|
new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
DueDate = pastDate,
|
||||||
ScheduledDate = pastDate,
|
ScheduledDate = pastDate,
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
},
|
},
|
||||||
new WorkOrder
|
new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
|
DueDate = pastDate,
|
||||||
ScheduledDate = pastDate,
|
ScheduledDate = pastDate,
|
||||||
LifecycleStatus = LifecycleStatus.Completed,
|
LifecycleStatus = LifecycleStatus.Completed,
|
||||||
OperationalFlags = OperationalFlags.PastDue
|
OperationalFlags = OperationalFlags.PastDue
|
||||||
|
|
@ -253,6 +255,7 @@ public class WorkOrderWeekRolledTests
|
||||||
new WorkOrder
|
new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
|
DueDate = futureDate,
|
||||||
ScheduledDate = futureDate,
|
ScheduledDate = futureDate,
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
OperationalFlags = OperationalFlags.PastDue
|
OperationalFlags = OperationalFlags.PastDue
|
||||||
|
|
@ -307,6 +310,7 @@ public class WorkOrderWeekRolledTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
DueDate = DateTime.UtcNow.Date.AddDays(5),
|
||||||
ScheduledDate = pastDate,
|
ScheduledDate = pastDate,
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
OperationalFlags = OperationalFlags.None
|
OperationalFlags = OperationalFlags.None
|
||||||
|
|
@ -319,7 +323,7 @@ public class WorkOrderWeekRolledTests
|
||||||
|
|
||||||
Assert.NotNull(row);
|
Assert.NotNull(row);
|
||||||
Assert.True(row!.IsPastDue);
|
Assert.True(row!.IsPastDue);
|
||||||
Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false);
|
Assert.True(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -331,6 +335,7 @@ public class WorkOrderWeekRolledTests
|
||||||
context.workOrders.Add(new WorkOrder
|
context.workOrders.Add(new WorkOrder
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
|
DueDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||||
ScheduledDate = futureDate,
|
ScheduledDate = futureDate,
|
||||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
OperationalFlags = OperationalFlags.PastDue
|
OperationalFlags = OperationalFlags.PastDue
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,13 @@ Lista opções para o filtro/dropdown de assignee do board.
|
||||||
### isPastDue
|
### isPastDue
|
||||||
|
|
||||||
```
|
```
|
||||||
ScheduledDate < UTC hoje
|
DueDate != null
|
||||||
AND LifecycleStatus NOT IN (Complete, Canceled, Closed)
|
AND DueDate < UTC hoje
|
||||||
|
AND LifecycleStatus NOT IN (Completed, Canceled)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`DueDate` null → `isPastDue = false`. Independent of `ScheduledDate` (Schedule On).
|
||||||
|
|
||||||
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
|
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
|
||||||
|
|
||||||
### Vendor / Appt
|
### Vendor / Appt
|
||||||
|
|
@ -154,6 +157,18 @@ Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram al
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Add-On indicator (SH-184)
|
||||||
|
|
||||||
|
- Coluna `IsAddOn` persistida; legacy `WorkOrderType.AddOn` (7) backfilled.
|
||||||
|
- **Create:** cutoff preview (scheduled) ou hint manual quando unscheduled (`request.IsAddOn`).
|
||||||
|
- **Patch schedule** (`scheduledDate`, `targetWeek`, `scheduleWeekOnly`): servidor **recalcula** `IsAddOn` e audita mudanças.
|
||||||
|
- **Schedule cleared:** `IsAddOn = false`.
|
||||||
|
- Search `Types=AddOn` matches legacy type 7 **or** `IsAddOn`.
|
||||||
|
|
||||||
|
Contrato FE: [shoc-frontend-new PR #102](https://github.com/Sea-Haven-Industries/shoc-frontend-new/pull/102).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Próximo passo
|
## Próximo passo
|
||||||
|
|
||||||
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).
|
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).
|
||||||
|
|
|
||||||
32
infra/cdk/package-lock.json
generated
32
infra/cdk/package-lock.json
generated
|
|
@ -8,12 +8,12 @@
|
||||||
"name": "shoc-backend-cdk",
|
"name": "shoc-backend-cdk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-cdk-lib": "2.262.1",
|
"aws-cdk-lib": "2.265.0",
|
||||||
"constructs": "10.8.1"
|
"constructs": "10.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "26.2.0",
|
"@types/node": "26.2.0",
|
||||||
"aws-cdk": "2.1135.1",
|
"aws-cdk": "2.1137.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -21,9 +21,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-cdk/asset-awscli-v1": {
|
"node_modules/@aws-cdk/asset-awscli-v1": {
|
||||||
"version": "2.2.282",
|
"version": "2.2.292",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.292.tgz",
|
||||||
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
|
"integrity": "sha512-d4aMFsAFj19FtxVyw8IzlUKv5Zu4sIvgnEjI2IU6IWBJgVbJ4aFnadANqYa+6MwB1CQbGOg0jh8WE77M+Nb/9A==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
|
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
|
||||||
|
|
@ -419,9 +419,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aws-cdk": {
|
"node_modules/aws-cdk": {
|
||||||
"version": "2.1135.1",
|
"version": "2.1137.0",
|
||||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1135.1.tgz",
|
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1137.0.tgz",
|
||||||
"integrity": "sha512-g1jcMfWlyYtGamFJ/kPBOCuchl3NfwTF2UwOLTIDN0nJbGm84EAO+c8DlYnaemM8UmKFkdoq4BGdmiNL5nHWwA==",
|
"integrity": "sha512-SRy2df/5cqGvq8ncph1m93rWEh3RGbvP6uwElw+W0x135vZDzTpfbuq2L8oAHwK2yDt3RNS5sb7Phwb7xqQP2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -432,9 +432,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aws-cdk-lib": {
|
"node_modules/aws-cdk-lib": {
|
||||||
"version": "2.262.1",
|
"version": "2.265.0",
|
||||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.262.1.tgz",
|
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.265.0.tgz",
|
||||||
"integrity": "sha512-B6YP4r6ojUZCDhl+qBu/CrWzcipR8sIgshcqYvgw013sghPXmVkYdJ3yuI9+DKML3YLSjQrHy1nGJs+Nqq7JCg==",
|
"integrity": "sha512-Ur96d/ZW+gqAW4XtqKQp//D9O/jQvyRoXhAqoHGybpV03qjckGIQcLcGkLV68+jv03TX/rOXHCsa2IEFcCXa4w==",
|
||||||
"bundleDependencies": [
|
"bundleDependencies": [
|
||||||
"@aws/cloudformation-validate",
|
"@aws/cloudformation-validate",
|
||||||
"@balena/dockerignore",
|
"@balena/dockerignore",
|
||||||
|
|
@ -451,11 +451,11 @@
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-cdk/asset-awscli-v1": "2.2.282",
|
"@aws-cdk/asset-awscli-v1": "2.2.292",
|
||||||
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
|
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
|
||||||
"@aws-cdk/cloud-assembly-api": "^2.2.6",
|
"@aws-cdk/cloud-assembly-api": "^2.2.6",
|
||||||
"@aws-cdk/cloud-assembly-schema": "^54.11.0",
|
"@aws-cdk/cloud-assembly-schema": "^54.11.0",
|
||||||
"@aws/cloudformation-validate": "1.5.1-beta",
|
"@aws/cloudformation-validate": "1.7.0-beta",
|
||||||
"@balena/dockerignore": "^1.0.2",
|
"@balena/dockerignore": "^1.0.2",
|
||||||
"case": "1.6.3",
|
"case": "1.6.3",
|
||||||
"fs-extra": "^11.3.6",
|
"fs-extra": "^11.3.6",
|
||||||
|
|
@ -490,7 +490,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": {
|
"node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": {
|
||||||
"version": "1.5.1-beta",
|
"version": "1.7.0-beta",
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -511,14 +511,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
|
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/aws-cdk-lib/node_modules/case": {
|
"node_modules/aws-cdk-lib/node_modules/case": {
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@
|
||||||
"deploy": "npm run build && cdk deploy"
|
"deploy": "npm run build && cdk deploy"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-cdk-lib": "2.262.1",
|
"aws-cdk-lib": "2.265.0",
|
||||||
"constructs": "10.8.1"
|
"constructs": "10.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "26.2.0",
|
"@types/node": "26.2.0",
|
||||||
"aws-cdk": "2.1135.1",
|
"aws-cdk": "2.1137.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue