mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
merge: integrate origin/dev into PR #22 flag-color base
Brings in dev's Phase 5 (PR #17) + vendor PRs (#25/#28/#29) atop the Phase 6/7 + flagColor base (PR #22). Preserves dev Phase 1-5 behavior and PR #22 Phase 6/7 + flagColor behavior. Conflict resolutions (16 files): - Migrations Phase4_SearchIndexes/.Designer + Phase5_DomainEvents/.Designer: take dev (Phase4 incl. SQL Server SiteCode/InternalWONumber index-compat shrink fix; Phase5 identical). ModelSnapshot union: Vendor CompanyId index + Phase7 ServiceNotes/ExternalWorkOrderId index. - ApplicationDbContext: keep dev SiteCode/InternalWONumber MaxLength (Phase1-5 + unguarded model test) + HEAD CompletionDocTemplate/ExternalWorkOrderId. - WorkOrderAuditService: unify on dev async staging API; convert Phase6 CompletionService 2 call sites to await StageFieldChangedAsync (drops HEAD sync duplicate; only callers, no test refs). - Hosted services: take HEAD (retry-on-failure, coherent with Phase7 WorkOrderJobRunStateAccessor/OpsHealth). Program.cs keeps dev vendor DI (ClamAV/VendorDocumentScanWorker/ArgumentExceptionFilter) + HEAD Phase7. - WorkOrderController: keep HEAD Phase6/7 service params + dev doc comment. - VendorController/WorkOrderBoardCreateService/QueryFilters/appsettings: union / dev-correct. - WorkOrderBoardUpdateServiceTests: union of HEAD (Phase6/7+flagColor) and dev (Phase1-5) test methods. Verified WorkOrderType.Other (enum 99) is a legit category, not an overdue sentinel; overdue uses dedicated OperationalFlags.PastDue + IsPastDue, and 'Overdue' is rejected as a WorkOrderType (no PR #23 import needed). Removed dev duplicate Api.Options.WorkOrderJobRunState (HEAD defines it in Services.Implementation alongside the Accessor; Services cannot reference Api).
This commit is contained in:
commit
e5cf4cec09
107 changed files with 12199 additions and 4151 deletions
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal file
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorControllerTests
|
||||
{
|
||||
private static VendorController NewController(Mock<IVendorService> service)
|
||||
{
|
||||
var controller = new VendorController(
|
||||
service.Object,
|
||||
db: null!,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "42")
|
||||
}, "Test"))
|
||||
}
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new VendorDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var model = new EditVendor_DTO
|
||||
{
|
||||
Id = 8,
|
||||
CompanyName = "Updated Name"
|
||||
};
|
||||
|
||||
var result = await controller.Update(8, model);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.Verify(x => x.UpdateVendorAsync(
|
||||
8,
|
||||
It.Is<UpdateVendorDTO>(dto => dto.IsActive == null),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new VendorDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var model = new EditVendor_DTO
|
||||
{
|
||||
Id = 8,
|
||||
CompanyName = "Updated Name",
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
await controller.Update(8, model);
|
||||
|
||||
service.Verify(x => x.UpdateVendorAsync(
|
||||
8,
|
||||
It.Is<UpdateVendorDTO>(dto => dto.IsActive == false),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutIsActive_DefaultsToActive()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.CreateVendorAsync(It.IsAny<CreateVendorDTO>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new VendorDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var model = new Vendor_DTO
|
||||
{
|
||||
CompanyName = "New Co"
|
||||
};
|
||||
|
||||
await controller.Create(model);
|
||||
|
||||
service.Verify(x => x.CreateVendorAsync(
|
||||
It.Is<CreateVendorDTO>(dto => dto.IsActive == true),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "Active Co", "Inactive Co")]
|
||||
[InlineData(false, "Inactive Co", "Active Co")]
|
||||
public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors(
|
||||
bool isActive,
|
||||
string expected,
|
||||
string excluded)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = new VendorController(
|
||||
Mock.Of<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
var result = await controller.GetFacets(isActive);
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
|
||||
payload.Should().Contain(expected);
|
||||
payload.Should().NotContain(excluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = new VendorController(
|
||||
Mock.Of<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
var result = await controller.GetFacets();
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
|
||||
payload.Should().Contain("Active Co");
|
||||
payload.Should().Contain("Inactive Co");
|
||||
}
|
||||
}
|
||||
324
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
324
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorDataServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext NewSqlServerContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True")
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static Vendor VendorWithJobs(
|
||||
string company,
|
||||
string city,
|
||||
string state,
|
||||
string trades,
|
||||
int jobCount,
|
||||
bool isActive = true)
|
||||
{
|
||||
return new Vendor
|
||||
{
|
||||
CompanyName = company,
|
||||
ContactName = $"{company} Technician",
|
||||
City = city,
|
||||
State = state,
|
||||
TradeSpecialties = trades,
|
||||
IsActive = isActive,
|
||||
IsDeleted = false,
|
||||
Dispatches = Enumerable.Range(1, jobCount)
|
||||
.Select(workOrderId => new Dispatch { WorkOrderId = workOrderId })
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task SeedBoundaryVendors(ApplicationDbContext context)
|
||||
{
|
||||
context.Vendors.AddRange(
|
||||
VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49),
|
||||
VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50),
|
||||
VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100),
|
||||
VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("under-50", "Under Co")]
|
||||
[InlineData("50-99", "Fifty Co")]
|
||||
[InlineData("100-149", "Hundred Co")]
|
||||
[InlineData("150-plus", "One Fifty Co")]
|
||||
public async Task GetPagedAsync_AppliesExactJobBucketBoundaries(
|
||||
string bucket,
|
||||
string expectedCompany)
|
||||
{
|
||||
await using var context = NewContext();
|
||||
await SeedBoundaryVendors(context);
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var (items, totalCount) = await service.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
isActive: true,
|
||||
jobBuckets: new[] { bucket });
|
||||
|
||||
totalCount.Should().Be(1);
|
||||
items.Single().CompanyName.Should().Be(expectedCompany);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
await SeedBoundaryVendors(context);
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var (items, totalCount) = await service.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
isActive: true,
|
||||
companies: new[] { "Under Co", "Hundred Co" },
|
||||
trades: new[] { "HVAC", "Plumbing" },
|
||||
locations: new[] { "Chicago, IL" },
|
||||
jobBuckets: new[] { "under-50", "100-149" });
|
||||
|
||||
totalCount.Should().Be(1);
|
||||
items.Single().CompanyName.Should().Be("Under Co");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1),
|
||||
VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false));
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var active = await service.GetPagedAsync(1, 1, isActive: true);
|
||||
var inactive = await service.GetPagedAsync(1, 1, isActive: false);
|
||||
|
||||
active.TotalCount.Should().Be(1);
|
||||
inactive.TotalCount.Should().Be(1);
|
||||
active.Items.Single().CompanyName.Should().Be("Active Co");
|
||||
inactive.Items.Single().CompanyName.Should().Be("Inactive Co");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" });
|
||||
cityOnly.TotalCount.Should().Be(1);
|
||||
cityOnly.Items.Single().CompanyName.Should().Be("CityOnly");
|
||||
|
||||
var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" });
|
||||
stateOnly.TotalCount.Should().Be(1);
|
||||
stateOnly.Items.Single().CompanyName.Should().Be("StateOnly");
|
||||
|
||||
var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" });
|
||||
full.TotalCount.Should().Be(1);
|
||||
full.Items.Single().CompanyName.Should().Be("FullLoc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" });
|
||||
hvac.TotalCount.Should().Be(1);
|
||||
hvac.Items.Single().CompanyName.Should().Be("HvacOnly");
|
||||
|
||||
var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" });
|
||||
hvacControls.TotalCount.Should().Be(1);
|
||||
hvacControls.Items.Single().CompanyName.Should().Be("HvacControls");
|
||||
|
||||
var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" });
|
||||
plumb.TotalCount.Should().Be(0);
|
||||
plumb.Items.Should().BeEmpty();
|
||||
|
||||
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
|
||||
plumbing.TotalCount.Should().Be(1);
|
||||
plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
|
||||
plumbing.TotalCount.Should().Be(2);
|
||||
plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle");
|
||||
|
||||
var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" });
|
||||
roofing.TotalCount.Should().Be(1);
|
||||
roofing.Items.Single().CompanyName.Should().Be("Middle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var vendor = new Vendor
|
||||
{
|
||||
CompanyName = "History Co",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 100,
|
||||
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||
{
|
||||
new() { WorkOrderId = 100 },
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
},
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
};
|
||||
context.Vendors.Add(vendor);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var result = await service.GetPagedAsync(1, 12, isActive: true);
|
||||
|
||||
result.Items.Single().TotalJobs.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2);
|
||||
context.Vendors.Add(vendor);
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var result = await service.GetByIdAsync(vendor.Id);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
context.Entry(result!).State.Should().Be(EntityState.Unchanged);
|
||||
context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse();
|
||||
result!.Dispatches.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("trade")]
|
||||
[InlineData("location")]
|
||||
[InlineData("job-bucket")]
|
||||
public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter)
|
||||
{
|
||||
using var context = NewSqlServerContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var query = filter switch
|
||||
{
|
||||
"trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }),
|
||||
"location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }),
|
||||
_ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" })
|
||||
};
|
||||
|
||||
var sql = query.ToQueryString();
|
||||
|
||||
sql.Should().Contain("SELECT");
|
||||
if (filter == "trade")
|
||||
sql.Should().Contain("TradeSpecialties");
|
||||
if (filter == "location")
|
||||
sql.Should().Contain("City");
|
||||
if (filter == "job-bucket")
|
||||
sql.Should().Contain("UNION");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation()
|
||||
{
|
||||
using var context = NewSqlServerContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString();
|
||||
|
||||
sql.Should().Contain("SELECT");
|
||||
sql.Should().Contain("Dispatches");
|
||||
sql.Should().Contain("DispatchWorkOrders");
|
||||
sql.Should().Contain("workOrders");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true };
|
||||
context.Vendors.Add(vendor);
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 701,
|
||||
WorkerOrderNumber = "WO-701",
|
||||
WorkerOrderTitle = "Direct And Multi",
|
||||
Status = "Scheduled",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 702,
|
||||
WorkerOrderNumber = "WO-702",
|
||||
WorkerOrderTitle = "Multi Only",
|
||||
Status = "Scheduled",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
|
||||
var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor };
|
||||
var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor };
|
||||
context.Dispatches.AddRange(directDispatch, multiDispatch);
|
||||
context.DispatchWorkOrders.AddRange(
|
||||
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 },
|
||||
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var result = await service.GetLinkedWorkOrdersAsync(60);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
|
||||
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
}
|
||||
313
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
313
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
using System.Collections;
|
||||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorOperationsControllerTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static VendorOperationsController NewController(ApplicationDbContext context)
|
||||
{
|
||||
var controller = new VendorOperationsController(
|
||||
context,
|
||||
new DispatchDataService(context),
|
||||
TimeProvider.System);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static object Prop(object source, string name) =>
|
||||
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||
|
||||
[Fact]
|
||||
public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-48",
|
||||
WorkerOrderTitle = "Needs vendor",
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(47)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetNotifications();
|
||||
|
||||
var value = ((OkObjectResult)result).Value!;
|
||||
var items = ((IEnumerable)Prop(value, "Items")).Cast<object>().ToList();
|
||||
items.Should().ContainSingle();
|
||||
Prop(items[0], "Type").Should().Be("NoVendor");
|
||||
Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-ASSIGNED",
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(24)
|
||||
};
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = workOrder.ScheduledDate
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetNotifications();
|
||||
|
||||
var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast<object>();
|
||||
items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var site = new Locations { Name = "St Louis" };
|
||||
var preferred = new Vendor
|
||||
{
|
||||
CompanyName = "Preferred",
|
||||
ContactName = "Alex",
|
||||
IsActive = true,
|
||||
TradeSpecialties = "HVAC",
|
||||
AvailabilityStatus = "Available",
|
||||
AvailabilityUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-BUSY",
|
||||
ScheduledStart = DateTime.UtcNow.AddHours(1),
|
||||
ScheduledEnd = DateTime.UtcNow.AddHours(3)
|
||||
};
|
||||
context.AddRange(site, preferred, busy, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.SitePreferredVendors.Add(new SitePreferredVendor
|
||||
{
|
||||
LocationId = site.Id,
|
||||
VendorId = preferred.Id,
|
||||
Trade = "HVAC",
|
||||
SortOrder = 0
|
||||
});
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = busy.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = workOrder.ScheduledStart
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetAvailability(
|
||||
DateTime.UtcNow.AddHours(2),
|
||||
DateTime.UtcNow.AddHours(4),
|
||||
site.Id,
|
||||
"HVAC");
|
||||
|
||||
var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast<object>().ToList();
|
||||
Prop(rows[0], "CompanyName").Should().Be("Preferred");
|
||||
Prop(rows[0], "IsPreferred").Should().Be(true);
|
||||
var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy");
|
||||
Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceSitePreferences_RejectsInactiveVendor()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var site = new Locations { Name = "St Louis" };
|
||||
var inactive = new Vendor { CompanyName = "Inactive", IsActive = false };
|
||||
context.AddRange(site, inactive);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).ReplaceSitePreferences(site.Id,
|
||||
new List<VendorOperationsController.SitePreferenceRequest>
|
||||
{
|
||||
new() { VendorId = inactive.Id }
|
||||
});
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.SitePreferredVendors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsightsExports_ReturnStableCsvAndPdfFormats()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false };
|
||||
var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Completed",
|
||||
DispatchedAt = DateTime.UtcNow.AddHours(-4),
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(-2),
|
||||
AcknowledgedAt = DateTime.UtcNow.AddHours(-3),
|
||||
CompletedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var controller = NewController(context);
|
||||
|
||||
var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null);
|
||||
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null);
|
||||
|
||||
csv.ContentType.Should().Be("text/csv");
|
||||
var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents);
|
||||
csvText.Should().Contain("AverageCycleHours");
|
||||
csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\"");
|
||||
pdf.ContentType.Should().Be("application/pdf");
|
||||
System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_RejectsInactiveVendor()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Inactive", IsActive = false };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
workOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id });
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.Dispatches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var start = DateTime.UtcNow.AddHours(2);
|
||||
var vendor = new Vendor
|
||||
{
|
||||
CompanyName = "Gateway",
|
||||
IsActive = true,
|
||||
AvailabilityStatus = "Available",
|
||||
AvailabilityUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var existingWorkOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-EXISTING",
|
||||
ScheduledStart = start,
|
||||
ScheduledEnd = start.AddHours(2)
|
||||
};
|
||||
var assignedWorkOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-NEW",
|
||||
ScheduledStart = start.AddMinutes(30),
|
||||
ScheduledEnd = start.AddHours(1)
|
||||
};
|
||||
context.AddRange(vendor, existingWorkOrder, assignedWorkOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = existingWorkOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = existingWorkOrder.ScheduledStart
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var eta = start.AddMinutes(45);
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
assignedWorkOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
ScheduledStart = assignedWorkOrder.ScheduledStart,
|
||||
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
|
||||
EstimatedArrivalAt = eta,
|
||||
EtaManualOverride = true
|
||||
});
|
||||
|
||||
var value = ((OkObjectResult)result).Value!;
|
||||
Prop(value, "AvailabilityStatus").Should().Be("Unavailable");
|
||||
((IEnumerable)Prop(value, "Conflicts")).Cast<object>().Should().ContainSingle();
|
||||
var dispatch = await context.Dispatches.SingleAsync(item =>
|
||||
item.WorkOrderId == assignedWorkOrder.Id);
|
||||
dispatch.EstimatedArrivalAt.Should().Be(eta);
|
||||
dispatch.EtaManualOverride.Should().BeTrue();
|
||||
dispatch.DispatchNumber.Should().StartWith("DISP-");
|
||||
context.WorkOrderAuditLogs.Should().ContainSingle(log =>
|
||||
log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var originalVendor = new Vendor { CompanyName = "Original", IsActive = true };
|
||||
var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-LINKED",
|
||||
ScheduledStart = DateTime.UtcNow.AddHours(2),
|
||||
ScheduledEnd = DateTime.UtcNow.AddHours(4)
|
||||
};
|
||||
context.AddRange(originalVendor, replacementVendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = originalVendor.Id,
|
||||
Status = "Sent",
|
||||
DispatchNumber = "DISP-LINKED"
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
workOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest
|
||||
{
|
||||
VendorId = replacementVendor.Id,
|
||||
ScheduledStart = workOrder.ScheduledStart,
|
||||
ScheduledEnd = workOrder.ScheduledEnd
|
||||
});
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.Dispatches.Should().ContainSingle();
|
||||
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
|
||||
}
|
||||
}
|
||||
164
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
164
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class VendorPortalDocumentTests : IDisposable
|
||||
{
|
||||
private const string Token = "vendor-document-test-token";
|
||||
private readonly string _contentRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"seahaven-vendor-documents-{Guid.NewGuid():N}");
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private VendorPortalController NewController(ApplicationDbContext context)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["VendorPortal:TokenLifetimeDays"] = "365",
|
||||
["SendGrid:ApiKey"] = "not-used"
|
||||
})
|
||||
.Build();
|
||||
var environment = new Mock<IWebHostEnvironment>();
|
||||
environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
|
||||
var controller = new VendorPortalController(
|
||||
context,
|
||||
new VendorPortalTokenService(context, configuration),
|
||||
configuration,
|
||||
new SendMessage(configuration),
|
||||
environment.Object);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
};
|
||||
controller.Request.Headers["X-Vendor-Token"] = Token;
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch(
|
||||
ApplicationDbContext context)
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Completed"
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.VendorAccessTokens.Add(new VendorAccessToken
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
Token = Token,
|
||||
IssuedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(1)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return (vendor, dispatch);
|
||||
}
|
||||
|
||||
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
|
||||
{
|
||||
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = contentType
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf"));
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var controller = NewController(context);
|
||||
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
|
||||
|
||||
var upload = await controller.UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(pdf, "completion.pdf", "application/pdf"));
|
||||
|
||||
upload.Should().BeOfType<OkObjectResult>();
|
||||
var document = await context.VendorCompletionDocuments.SingleAsync();
|
||||
document.ScanStatus.Should().Be("Pending");
|
||||
document.ReviewStatus.Should().Be("Processing");
|
||||
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
|
||||
|
||||
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
|
||||
|
||||
var locked = download.Should().BeOfType<ObjectResult>().Subject;
|
||||
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(pdf, "completion.pdf", "Application/PDF"));
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 };
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(png, "photo.png", "Image/PNG"));
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_contentRoot))
|
||||
{
|
||||
Directory.Delete(_contentRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
481
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
481
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorServiceTests
|
||||
{
|
||||
private static VendorService NewService(Mock<IVendorDataService> data) =>
|
||||
new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation());
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_PersistsCompleteProfile()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; });
|
||||
|
||||
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||
{
|
||||
Name = "Gateway Plumbing",
|
||||
ContactName = "Jordan Lee",
|
||||
Email = "dispatch@gateway.example",
|
||||
Phone = "(555) 555-0100",
|
||||
CompanyPhone = "(555) 555-0199",
|
||||
Address = "10 Lake St",
|
||||
City = "Chicago",
|
||||
State = "IL",
|
||||
Zipcode = "60601",
|
||||
TradeSpecialties = "Plumbing, Backflow",
|
||||
GoogleMapsUrl = "https://maps.google.com/example",
|
||||
Notes = "After-hours dispatch available",
|
||||
IsActive = true
|
||||
}, "42");
|
||||
|
||||
saved.Should().NotBeNull();
|
||||
saved!.ContactName.Should().Be("Jordan Lee");
|
||||
saved.TradeSpecialties.Should().Be("Plumbing, Backflow");
|
||||
saved.CompanyPhone.Should().Be("(555) 555-0199");
|
||||
saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example");
|
||||
saved.Notes.Should().Be("After-hours dispatch available");
|
||||
saved.City.Should().Be("Chicago");
|
||||
result.IsActive.Should().BeTrue();
|
||||
result.ContactName.Should().Be("Jordan Lee");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_UpdatesProfileAndActiveState()
|
||||
{
|
||||
var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO
|
||||
{
|
||||
Name = "New",
|
||||
ContactName = "Casey",
|
||||
TradeSpecialties = "HVAC",
|
||||
IsActive = false
|
||||
}, "42");
|
||||
|
||||
existing.CompanyName.Should().Be("New");
|
||||
existing.ContactName.Should().Be("Casey");
|
||||
existing.TradeSpecialties.Should().Be("HVAC");
|
||||
existing.IsActive.Should().BeFalse();
|
||||
existing.LastModifierUserId.Should().Be(42);
|
||||
result.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVendor_DeactivatesWithoutDeletingHistory()
|
||||
{
|
||||
var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).DeleteVendorAsync(9, "42");
|
||||
|
||||
existing.IsActive.Should().BeFalse();
|
||||
existing.IsDeleted.Should().NotBeTrue();
|
||||
existing.DeletionTime.Should().BeNull();
|
||||
existing.LastModificationTime.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"chicago",
|
||||
false,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.ReturnsAsync((new[]
|
||||
{
|
||||
new Vendor
|
||||
{
|
||||
Id = 10,
|
||||
CompanyName = "Inactive Vendor",
|
||||
ContactName = "Taylor",
|
||||
City = "Chicago",
|
||||
State = "IL",
|
||||
TradeSpecialties = "Electrical",
|
||||
IsActive = false
|
||||
}
|
||||
}.AsEnumerable(), 1));
|
||||
|
||||
var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false);
|
||||
|
||||
result.TotalCount.Should().Be(1);
|
||||
result.Items.Single().Should().BeEquivalentTo(new
|
||||
{
|
||||
Id = 10,
|
||||
Name = "Inactive Vendor",
|
||||
ContactName = "Taylor",
|
||||
City = "Chicago",
|
||||
State = "IL",
|
||||
TradeSpecialties = "Electrical",
|
||||
IsActive = false
|
||||
}, options => options.ExcludingMissingMembers());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||
{
|
||||
var vendor = new Vendor
|
||||
{
|
||||
Id = 11,
|
||||
CompanyName = "History Vendor",
|
||||
Dispatches = new List<Dispatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 100,
|
||||
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||
{
|
||||
new() { WorkOrderId = 100 },
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
},
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor);
|
||||
|
||||
var result = await NewService(data).GetVendorByIdAsync(11);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.TotalJobs.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorsPaged_ForwardsFacetFilters()
|
||||
{
|
||||
var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" };
|
||||
var trades = new[] { "Backflow Preventers" };
|
||||
var locations = new[] { "Dallas, TX" };
|
||||
var jobBuckets = new[] { "100-149" };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetPagedAsync(
|
||||
1,
|
||||
25,
|
||||
"gateway",
|
||||
true,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets))
|
||||
.ReturnsAsync((Enumerable.Empty<Vendor>(), 0));
|
||||
|
||||
await NewService(data).GetVendorsPagedAsync(
|
||||
1,
|
||||
25,
|
||||
"gateway",
|
||||
true,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
data.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
|
||||
{
|
||||
var validator = new CreateVendorValidation();
|
||||
|
||||
var result = validator.Validate(new CreateVendorDTO
|
||||
{
|
||||
Name = "Gateway Plumbing",
|
||||
ContactName = "",
|
||||
GoogleMapsUrl = "http://maps.google.com/gateway"
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
|
||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
|
||||
{
|
||||
var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(20))
|
||||
.ReturnsAsync(new List<LinkedWorkOrderInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 500,
|
||||
WorkOrderNumber = "WO-500",
|
||||
Status = "Scheduled",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
}
|
||||
});
|
||||
|
||||
var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42");
|
||||
|
||||
await act.Should().ThrowAsync<VendorDeactivationBlockedException>();
|
||||
existing.IsActive.Should().BeTrue();
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName()
|
||||
{
|
||||
var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null);
|
||||
|
||||
var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO
|
||||
{
|
||||
CompanyId = 999,
|
||||
Name = "Fallback Try"
|
||||
}, "42");
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny<string>()), Times.Never);
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" };
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; });
|
||||
|
||||
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||
{
|
||||
Name = "Acme",
|
||||
CompanyId = 77,
|
||||
ContactName = "Riley"
|
||||
}, "42");
|
||||
|
||||
saved.Should().NotBeNull();
|
||||
saved!.CompanyId.Should().Be(77);
|
||||
result.CompanyId.Should().Be(77);
|
||||
data.Verify(x => x.AddCompanyAsync(It.IsAny<VendorCompany>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact()
|
||||
{
|
||||
var validator = new CreateVendorValidation();
|
||||
|
||||
var result = validator.Validate(new CreateVendorDTO
|
||||
{
|
||||
Name = "Acme",
|
||||
ContactName = "Riley",
|
||||
Phone = "555-1234",
|
||||
PreferredContact = "Fax"
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone));
|
||||
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.ReturnsAsync((Vendor vendor) =>
|
||||
{
|
||||
saved = vendor;
|
||||
vendor.Id = 41;
|
||||
return vendor;
|
||||
});
|
||||
|
||||
await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||
{
|
||||
Name = "Acme",
|
||||
ContactName = "Riley",
|
||||
Phone = "+1 312 555 0100"
|
||||
}, "42");
|
||||
|
||||
saved!.Phone.Should().Be("(312) 555-0100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 42,
|
||||
CompanyId = 77,
|
||||
CompanyName = "Acme",
|
||||
Address = "Old address",
|
||||
IsActive = true
|
||||
};
|
||||
var company = new VendorCompany
|
||||
{
|
||||
Id = 77,
|
||||
Name = "Acme",
|
||||
NormalizedName = "acme",
|
||||
Address = "Old address"
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
|
||||
data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask);
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO
|
||||
{
|
||||
Name = "Acme Services",
|
||||
Address = "200 New Street"
|
||||
}, "42");
|
||||
|
||||
company.Name.Should().Be("Acme Services");
|
||||
company.NormalizedName.Should().Be("acme services");
|
||||
company.Address.Should().Be("200 New Street");
|
||||
existing.Address.Should().Be("200 New Street");
|
||||
data.Verify(x => x.UpdateCompanyAsync(company), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 43,
|
||||
CompanyId = 77,
|
||||
CompanyName = "Acme",
|
||||
Address = "Keep this address",
|
||||
CompanyPhone = "(312) 555-0199",
|
||||
IsActive = true
|
||||
};
|
||||
var sparseCompany = new VendorCompany
|
||||
{
|
||||
Id = 77,
|
||||
Name = "Acme",
|
||||
NormalizedName = "acme"
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany);
|
||||
data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask);
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO
|
||||
{
|
||||
Name = "Acme"
|
||||
}, "42");
|
||||
|
||||
existing.Address.Should().Be("Keep this address");
|
||||
existing.CompanyPhone.Should().Be("(312) 555-0199");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact()
|
||||
{
|
||||
var validator = new WorkOrderVendorUpdateValidation();
|
||||
|
||||
var result = validator.Validate(new WorkOrderVendorUpdateDTO
|
||||
{
|
||||
WorkOrderId = 5,
|
||||
Phone = "(312) 555-0100",
|
||||
PreferredContact = "Text"
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder()
|
||||
{
|
||||
var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false);
|
||||
|
||||
var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
|
||||
{
|
||||
WorkOrderId = 777,
|
||||
ContactName = "Should Not Apply"
|
||||
}, "42");
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
existing.ContactName.Should().Be("Original");
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 50,
|
||||
CompanyName = "Assigned",
|
||||
ContactName = "Old Name",
|
||||
Phone = "(312) 555-0000",
|
||||
PreferredContact = "Phone",
|
||||
IsActive = true
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true);
|
||||
var auditLogs = new List<VendorAuditLog>();
|
||||
data.Setup(x => x.UpdateWithAuditLogsAsync(
|
||||
existing,
|
||||
It.IsAny<IReadOnlyCollection<VendorAuditLog>>()))
|
||||
.Callback<Vendor, IReadOnlyCollection<VendorAuditLog>>((_, logs) => auditLogs.AddRange(logs))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
|
||||
{
|
||||
WorkOrderId = 777,
|
||||
ContactName = "New Name",
|
||||
Phone = "(312) 555-0199",
|
||||
PreferredContact = "Email"
|
||||
}, "42");
|
||||
|
||||
existing.ContactName.Should().Be("New Name");
|
||||
existing.Phone.Should().Be("(312) 555-0199");
|
||||
existing.PreferredContact.Should().Be("Email");
|
||||
auditLogs.Should().NotBeEmpty();
|
||||
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name");
|
||||
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199");
|
||||
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeactivationImpact_RejectsUnknownVendor()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false);
|
||||
|
||||
var act = () => NewService(data).GetDeactivationImpactAsync(404);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*404*not found");
|
||||
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,7 @@ public class WorkOrderControllerSearchTests
|
|||
Mock.Of<IWorkOrderCompletionService>(),
|
||||
Mock.Of<IWorkOrderMediaService>());
|
||||
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
|
|
|
|||
|
|
@ -32,16 +32,45 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpGet("GetVendorList")]
|
||||
public async Task<IActionResult> GetVendorList(string? search = "", int page = 1, int pageSize = 10)
|
||||
public async Task<IActionResult> GetVendorList(
|
||||
string? search = "",
|
||||
int page = 1,
|
||||
int pageSize = 10,
|
||||
bool? isActive = true,
|
||||
[FromQuery] string[]? companies = null,
|
||||
[FromQuery] string[]? trades = null,
|
||||
[FromQuery] string[]? locations = null,
|
||||
[FromQuery] string[]? jobBuckets = null)
|
||||
{
|
||||
var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search);
|
||||
var pagedResult = await _vendorService.GetVendorsPagedAsync(
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
var data = pagedResult.Items.Select(v => new
|
||||
{
|
||||
v.Id,
|
||||
CompanyName = v.Name,
|
||||
v.CompanyId,
|
||||
v.ContactName,
|
||||
v.Email,
|
||||
v.Phone
|
||||
v.Phone,
|
||||
v.CompanyPhone,
|
||||
v.PreferredContact,
|
||||
v.Address,
|
||||
v.City,
|
||||
v.State,
|
||||
Zip = v.Zipcode,
|
||||
v.TradeSpecialties,
|
||||
v.GoogleMapsUrl,
|
||||
v.Notes,
|
||||
v.IsActive,
|
||||
v.TotalJobs
|
||||
});
|
||||
|
||||
return Ok(new Pagination_DTO
|
||||
|
|
@ -56,17 +85,37 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("GetById")]
|
||||
public async Task<IActionResult> GetVendorById([FromQuery] int? id)
|
||||
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
|
||||
{
|
||||
if (id == null)
|
||||
var vendorId = id ?? queryId;
|
||||
if (vendorId == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Id is required" });
|
||||
|
||||
var vendor = await _vendorService.GetVendorByIdAsync(id.Value);
|
||||
var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value);
|
||||
|
||||
if (vendor == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||
|
||||
return Ok(vendor);
|
||||
return Ok(new
|
||||
{
|
||||
vendor.Id,
|
||||
CompanyName = vendor.Name,
|
||||
vendor.CompanyId,
|
||||
vendor.ContactName,
|
||||
vendor.Email,
|
||||
vendor.Phone,
|
||||
vendor.CompanyPhone,
|
||||
vendor.PreferredContact,
|
||||
vendor.Address,
|
||||
vendor.City,
|
||||
vendor.State,
|
||||
Zip = vendor.Zipcode,
|
||||
vendor.TradeSpecialties,
|
||||
vendor.GoogleMapsUrl,
|
||||
vendor.Notes,
|
||||
vendor.IsActive,
|
||||
vendor.TotalJobs
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
|
@ -77,7 +126,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
var createDto = new CreateVendorDTO
|
||||
{
|
||||
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required")
|
||||
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"),
|
||||
CompanyId = model.CompanyId,
|
||||
ContactName = model.ContactName,
|
||||
Email = model.Email,
|
||||
Phone = model.Phone,
|
||||
CompanyPhone = model.CompanyPhone,
|
||||
PreferredContact = model.PreferredContact,
|
||||
Address = model.Address,
|
||||
City = model.City,
|
||||
State = model.State,
|
||||
Zipcode = model.Zip,
|
||||
TradeSpecialties = model.TradeSpecialties,
|
||||
GoogleMapsUrl = model.GoogleMapsUrl,
|
||||
Notes = model.Notes,
|
||||
IsActive = model.IsActive ?? true
|
||||
};
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
|
@ -111,7 +174,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
var updateDto = new UpdateVendorDTO
|
||||
{
|
||||
Name = model.CompanyName
|
||||
Name = model.CompanyName,
|
||||
CompanyId = model.CompanyId,
|
||||
ContactName = model.ContactName,
|
||||
Email = model.Email,
|
||||
Phone = model.Phone,
|
||||
CompanyPhone = model.CompanyPhone,
|
||||
PreferredContact = model.PreferredContact,
|
||||
Address = model.Address,
|
||||
City = model.City,
|
||||
State = model.State,
|
||||
Zipcode = model.Zip,
|
||||
TradeSpecialties = model.TradeSpecialties,
|
||||
GoogleMapsUrl = model.GoogleMapsUrl,
|
||||
Notes = model.Notes,
|
||||
IsActive = model.IsActive
|
||||
};
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
|
@ -126,7 +203,16 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
catch (VendorDeactivationBlockedException dbex)
|
||||
{
|
||||
return Conflict(new
|
||||
{
|
||||
Status = "Conflict",
|
||||
Message = dbex.Message,
|
||||
OpenWorkOrders = dbex.OpenWorkOrders
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||
}
|
||||
|
|
@ -138,7 +224,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
|
||||
[HttpDelete("{id}")]
|
||||
[HttpPost("Delete")]
|
||||
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery] int? queryId = null)
|
||||
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -152,9 +238,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||
|
||||
await _vendorService.DeleteVendorAsync(vendorId, userId);
|
||||
return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" });
|
||||
return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
catch (VendorDeactivationBlockedException dbex)
|
||||
{
|
||||
return Conflict(new
|
||||
{
|
||||
Status = "Conflict",
|
||||
Message = dbex.Message,
|
||||
OpenWorkOrders = dbex.OpenWorkOrders
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||
}
|
||||
|
|
@ -164,6 +259,64 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/deactivation-impact")]
|
||||
public async Task<IActionResult> GetDeactivationImpact(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var impact = await _vendorService.GetDeactivationImpactAsync(id);
|
||||
return Ok(impact);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/work-order-update")]
|
||||
public async Task<IActionResult> UpdateFromWorkOrder(int id, [FromBody] WorkOrderVendorUpdate_DTO model)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (model == null || model.WorkOrderId <= 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "A valid workOrderId is required" });
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId == null)
|
||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||
|
||||
var dto = new WorkOrderVendorUpdateDTO
|
||||
{
|
||||
WorkOrderId = model.WorkOrderId,
|
||||
ContactName = model.ContactName,
|
||||
PreferredContact = model.PreferredContact,
|
||||
Phone = model.Phone,
|
||||
Email = model.Email,
|
||||
Notes = model.Notes
|
||||
};
|
||||
|
||||
var result = await _vendorService.UpdateVendorFromWorkOrderAsync(id, dto, userId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
{
|
||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Vendor or work order link not found" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("Dropdown")]
|
||||
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null)
|
||||
{
|
||||
|
|
@ -175,6 +328,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
v.Id,
|
||||
v.CompanyName,
|
||||
v.ContactName,
|
||||
v.PreferredContact,
|
||||
|
||||
v.TradeSpecialties,
|
||||
v.Address,
|
||||
v.City,
|
||||
|
|
@ -193,6 +348,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
v.Id,
|
||||
v.CompanyName,
|
||||
v.ContactName,
|
||||
v.PreferredContact,
|
||||
|
||||
v.TradeSpecialties,
|
||||
address = string.Join(", ", addr),
|
||||
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
|
||||
|
|
@ -214,6 +371,66 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("facets")]
|
||||
public async Task<IActionResult> GetFacets([FromQuery] bool? isActive = null)
|
||||
{
|
||||
var query = _db.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
var vendors = await query
|
||||
.OrderByDescending(v => v.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var companies = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyName))
|
||||
.GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new
|
||||
{
|
||||
Name = v.CompanyName,
|
||||
v.CompanyId,
|
||||
v.CompanyPhone,
|
||||
v.Email,
|
||||
v.PreferredContact,
|
||||
v.Address,
|
||||
v.City,
|
||||
v.State,
|
||||
Zip = v.Zip,
|
||||
v.GoogleMapsUrl
|
||||
});
|
||||
|
||||
var trades = vendors
|
||||
.SelectMany(v => (v.TradeSpecialties ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value);
|
||||
|
||||
var locations = vendors
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State))
|
||||
.Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) })
|
||||
.DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(location => location.Label);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Companies = companies,
|
||||
Trades = trades,
|
||||
Locations = locations,
|
||||
JobBuckets = new[]
|
||||
{
|
||||
new { Id = "under-50", Label = "Under 50" },
|
||||
new { Id = "50-99", Label = "50–99" },
|
||||
new { Id = "100-149", Label = "100–149" },
|
||||
new { Id = "150-plus", Label = "150+" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/portal-token")]
|
||||
public async Task<IActionResult> GetPortalToken(int id)
|
||||
{
|
||||
|
|
|
|||
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/vendor-operations")]
|
||||
public class VendorOperationsController : ControllerBase
|
||||
{
|
||||
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IDispatchDataService _dispatchDataService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VendorOperationsController(
|
||||
ApplicationDbContext db,
|
||||
IDispatchDataService dispatchDataService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_db = db;
|
||||
_dispatchDataService = dispatchDataService;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
[HttpGet("notifications")]
|
||||
public async Task<IActionResult> GetNotifications()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var cutoff = now.AddHours(48);
|
||||
|
||||
var directlyAssignedIds = await _db.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
|
||||
.Select(d => d.WorkOrderId!.Value)
|
||||
.ToListAsync();
|
||||
var multiplyAssignedIds = await _db.DispatchWorkOrders
|
||||
.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||
.Select(link => link.WorkOrderId)
|
||||
.ToListAsync();
|
||||
var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
|
||||
|
||||
var noVendor = await _db.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(wo => wo.ScheduledDate.HasValue
|
||||
&& wo.ScheduledDate.Value <= cutoff
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& !assignedWorkOrderIds.Contains(wo.Id))
|
||||
.OrderBy(wo => wo.ScheduledDate)
|
||||
.Select(wo => new
|
||||
{
|
||||
Id = $"no-vendor-{wo.Id}",
|
||||
Type = "NoVendor",
|
||||
Severity = "Medium",
|
||||
Group = "No Vendor",
|
||||
Title = "No vendor assigned — 48h to service",
|
||||
WorkOrderId = wo.Id,
|
||||
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
|
||||
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
|
||||
ScheduledAt = wo.ScheduledDate,
|
||||
Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var conflicts = BuildConflictPairs(dispatches)
|
||||
.Select(pair => new
|
||||
{
|
||||
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
||||
Type = "VendorConflict",
|
||||
Severity = "Medium",
|
||||
Group = "Vendor Conflict",
|
||||
Title = "Vendor has overlapping appointments",
|
||||
VendorId = pair.Left.VendorId,
|
||||
VendorName = pair.Left.VendorName,
|
||||
WorkOrders = new[]
|
||||
{
|
||||
new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End },
|
||||
new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End }
|
||||
},
|
||||
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
||||
Actions = new[]
|
||||
{
|
||||
new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
|
||||
new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = now,
|
||||
Items = noVendor.Cast<object>().Concat(conflicts).OrderBy(item =>
|
||||
item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("availability")]
|
||||
public async Task<IActionResult> GetAvailability(
|
||||
[FromQuery] DateTime? start,
|
||||
[FromQuery] DateTime? end,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var requestedStart = start ?? now;
|
||||
var requestedEnd = end ?? requestedStart.AddHours(2);
|
||||
if (requestedEnd <= requestedStart)
|
||||
return BadRequest(new Response { Status = "Error", Message = "end must be after start" });
|
||||
|
||||
var vendors = await _db.Vendors.AsNoTracking()
|
||||
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
|
||||
.ToListAsync();
|
||||
|
||||
var preferences = locationId.HasValue
|
||||
? await _db.SitePreferredVendors.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId.Value)
|
||||
.ToListAsync()
|
||||
: new List<SitePreferredVendor>();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var result = vendors.Select(vendor =>
|
||||
{
|
||||
var matchingDispatches = dispatches
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id
|
||||
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
|
||||
.ToList();
|
||||
var matchingPreference = preferences
|
||||
.Where(preference => preference.VendorId == vendor.Id
|
||||
&& (string.IsNullOrWhiteSpace(preference.Trade)
|
||||
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(preference => preference.SortOrder)
|
||||
.FirstOrDefault();
|
||||
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
|
||||
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
|
||||
var status = matchingDispatches.Count > 0
|
||||
? "Unavailable"
|
||||
: signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus)
|
||||
? vendor.AvailabilityStatus
|
||||
: "Unknown";
|
||||
|
||||
return new
|
||||
{
|
||||
vendor.Id,
|
||||
vendor.CompanyName,
|
||||
vendor.ContactName,
|
||||
vendor.TradeSpecialties,
|
||||
AvailabilityStatus = status,
|
||||
vendor.AvailabilityUpdatedAt,
|
||||
IsPreferred = matchingPreference != null,
|
||||
PreferenceRank = matchingPreference?.SortOrder,
|
||||
Conflicts = matchingDispatches.Select(dispatch => new
|
||||
{
|
||||
dispatch.WorkOrderId,
|
||||
dispatch.WorkOrderNumber,
|
||||
dispatch.Start,
|
||||
dispatch.End
|
||||
})
|
||||
};
|
||||
})
|
||||
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
|
||||
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
|
||||
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
|
||||
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
|
||||
.ThenBy(vendor => vendor.CompanyName)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result });
|
||||
}
|
||||
|
||||
[HttpPut("vendors/{vendorId:int}/availability")]
|
||||
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body)
|
||||
{
|
||||
var normalized = body.Status?.Trim();
|
||||
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" });
|
||||
|
||||
var vendor = await _db.Vendors.FindAsync(vendorId);
|
||||
if (vendor == null) return NotFound();
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var previous = vendor.AvailabilityStatus;
|
||||
vendor.AvailabilityStatus = normalized;
|
||||
vendor.AvailabilityUpdatedAt = now;
|
||||
vendor.LastModificationTime = now;
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = nameof(Vendor.AvailabilityStatus),
|
||||
OldValue = previous,
|
||||
NewValue = normalized,
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt });
|
||||
}
|
||||
|
||||
[HttpGet("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> GetSitePreferences(int locationId)
|
||||
{
|
||||
var preferences = await _db.SitePreferredVendors
|
||||
.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId)
|
||||
.Include(p => p.Vendor)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.LocationId,
|
||||
p.VendorId,
|
||||
VendorName = p.Vendor!.CompanyName,
|
||||
p.Trade,
|
||||
p.SortOrder,
|
||||
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
|
||||
p.LastModificationTime,
|
||||
p.UpdatedBy
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(preferences);
|
||||
}
|
||||
|
||||
[HttpPut("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body)
|
||||
{
|
||||
if (!await _db.Locations.AnyAsync(location => location.Id == locationId))
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" });
|
||||
|
||||
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
|
||||
var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id);
|
||||
foreach (var item in body)
|
||||
{
|
||||
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" });
|
||||
if (!string.IsNullOrWhiteSpace(item.Trade)
|
||||
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" });
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync();
|
||||
_db.SitePreferredVendors.RemoveRange(existing);
|
||||
_db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor
|
||||
{
|
||||
LocationId = locationId,
|
||||
VendorId = item.VendorId,
|
||||
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
|
||||
SortOrder = item.SortOrder ?? index,
|
||||
CreatedDate = now,
|
||||
LastModificationTime = now,
|
||||
createdby = actor,
|
||||
UpdatedBy = actor
|
||||
}));
|
||||
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
|
||||
{
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = $"SitePreference:{locationId}",
|
||||
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetSitePreferences(locationId);
|
||||
}
|
||||
|
||||
[HttpPut("documents/{documentId:int}/review")]
|
||||
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body)
|
||||
{
|
||||
if (body.ReviewStatus is not ("Approved" or "Rejected"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" });
|
||||
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
|
||||
return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" });
|
||||
|
||||
var document = await _db.VendorCompletionDocuments.FindAsync(documentId);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" });
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previous = document.ReviewStatus;
|
||||
document.ReviewStatus = body.ReviewStatus;
|
||||
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
|
||||
document.ReviewedAt = now;
|
||||
document.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Completion Document {document.OriginalFileName}",
|
||||
OldValue = previous,
|
||||
NewValue = document.ReviewStatus,
|
||||
Action = "completion_document_reviewed",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt });
|
||||
}
|
||||
|
||||
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
|
||||
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body)
|
||||
{
|
||||
var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" };
|
||||
var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
|
||||
if (!invoiceStatuses.Contains(body.InvoiceStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" });
|
||||
if (!paymentStatuses.Contains(body.PaymentStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" });
|
||||
|
||||
var dispatch = await _db.Dispatches.FindAsync(dispatchId);
|
||||
if (dispatch == null) return NotFound();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
|
||||
dispatch.InvoiceStatus = body.InvoiceStatus;
|
||||
dispatch.PaymentStatus = body.PaymentStatus;
|
||||
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
|
||||
dispatch.CommercialStatusUpdatedAt = now;
|
||||
dispatch.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
|
||||
OldValue = oldValue,
|
||||
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
|
||||
Action = "commercial_status_updated",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("work-orders/{workOrderId:int}/assignment")]
|
||||
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body)
|
||||
{
|
||||
var workOrder = await _db.workOrders.FindAsync(workOrderId);
|
||||
if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" });
|
||||
var vendor = await _db.Vendors.FindAsync(body.VendorId);
|
||||
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" });
|
||||
|
||||
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
|
||||
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
|
||||
var scheduledDispatches = start.HasValue && end.HasValue
|
||||
? await ActiveScheduledDispatches()
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId)
|
||||
.ToListAsync()
|
||||
: new List<ScheduledDispatch>();
|
||||
var conflicts = start.HasValue && end.HasValue
|
||||
? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
|
||||
: new List<ScheduledDispatch>();
|
||||
|
||||
var linkedDispatchIds = _db.DispatchWorkOrders
|
||||
.Where(link => link.WorkOrderId == workOrderId)
|
||||
.Select(link => link.DispatchId);
|
||||
var dispatch = await _db.Dispatches
|
||||
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
|
||||
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
|
||||
.OrderByDescending(item => item.CreatedDate)
|
||||
.FirstOrDefaultAsync();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previousVendorId = dispatch?.VendorId;
|
||||
if (dispatch == null)
|
||||
{
|
||||
dispatch = new Dispatch
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
VendorId = vendor.Id,
|
||||
DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(),
|
||||
PONumber = await _dispatchDataService.GenerateNextPONumberAsync(),
|
||||
Status = "Sent",
|
||||
ScheduledDate = start,
|
||||
EstimatedArrivalAt = body.EstimatedArrivalAt,
|
||||
EtaManualOverride = body.EtaManualOverride,
|
||||
DispatchedAt = now,
|
||||
CreatedDate = now,
|
||||
createdby = Actor()
|
||||
};
|
||||
_db.Dispatches.Add(dispatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
dispatch.VendorId = vendor.Id;
|
||||
dispatch.ScheduledDate = start;
|
||||
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
|
||||
dispatch.EtaManualOverride = body.EtaManualOverride;
|
||||
dispatch.LastModificationTime = now;
|
||||
}
|
||||
|
||||
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
|
||||
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
|
||||
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
|
||||
workOrder.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = "Vendor Assignment",
|
||||
OldValue = previousVendorId?.ToString(),
|
||||
NewValue = vendor.Id.ToString(),
|
||||
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.VendorId,
|
||||
VendorName = vendor.CompanyName,
|
||||
dispatch.EstimatedArrivalAt,
|
||||
dispatch.EtaManualOverride,
|
||||
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
|
||||
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
|
||||
Conflicts = conflicts.Select(conflict => new
|
||||
{
|
||||
conflict.WorkOrderId,
|
||||
conflict.WorkOrderNumber,
|
||||
conflict.Start,
|
||||
conflict.End
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights")]
|
||||
public async Task<IActionResult> GetInsights(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime,
|
||||
Filters = new { from, to, locationId, trade, vendorId },
|
||||
MetricDefinitions = MetricDefinitions(),
|
||||
Data = rows
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights.csv")]
|
||||
public async Task<IActionResult> ExportInsightsCsv(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
|
||||
foreach (var row in rows)
|
||||
{
|
||||
csv.AppendLine(string.Join(",",
|
||||
row.VendorId,
|
||||
Csv(row.Vendor),
|
||||
row.CompletedJobs,
|
||||
row.CancellationRefusalRate.ToString("0.####"),
|
||||
row.OnTimeArrivalRate.ToString("0.####"),
|
||||
row.OnTimeCompletionRate.ToString("0.####"),
|
||||
row.AverageCycleHours?.ToString("0.##") ?? ""));
|
||||
}
|
||||
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
|
||||
[HttpGet("insights.pdf")]
|
||||
public async Task<IActionResult> ExportInsightsPdf(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var lines = new List<string>
|
||||
{
|
||||
"Vendor Performance Insights",
|
||||
$"Generated: {_timeProvider.GetUtcNow():u}",
|
||||
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
|
||||
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
|
||||
};
|
||||
lines.AddRange(rows.Take(35).Select(row =>
|
||||
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
|
||||
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf");
|
||||
}
|
||||
|
||||
private IQueryable<ScheduledDispatch> ActiveScheduledDispatches()
|
||||
{
|
||||
var direct = _db.Dispatches.AsNoTracking()
|
||||
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
|
||||
&& dispatch.WorkOrderId.HasValue
|
||||
&& (dispatch.ScheduledDate.HasValue
|
||||
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
|
||||
.Select(dispatch => new ScheduledDispatch
|
||||
{
|
||||
Id = dispatch.Id,
|
||||
VendorId = dispatch.VendorId,
|
||||
VendorName = dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
|
||||
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
|
||||
End = dispatch.WorkOrder.ScheduledEnd
|
||||
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
var linked = _db.DispatchWorkOrders.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& link.WorkOrder != null
|
||||
&& link.Dispatch.WorkOrderId != link.WorkOrderId
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
|
||||
.Select(link => new ScheduledDispatch
|
||||
{
|
||||
Id = link.DispatchId,
|
||||
VendorId = link.Dispatch!.VendorId,
|
||||
VendorName = link.Dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = link.WorkOrderId,
|
||||
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
|
||||
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
|
||||
End = link.WorkOrder.ScheduledEnd
|
||||
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
return direct.Concat(linked);
|
||||
}
|
||||
|
||||
private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List<ScheduledDispatch> dispatches)
|
||||
{
|
||||
for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++)
|
||||
{
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++)
|
||||
{
|
||||
var left = dispatches[leftIndex];
|
||||
var right = dispatches[rightIndex];
|
||||
if (left.VendorId == right.VendorId
|
||||
&& left.WorkOrderId != right.WorkOrderId
|
||||
&& WindowsOverlap(left.Start, left.End, right.Start, right.End))
|
||||
yield return (left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<VendorInsightRow>> BuildInsightRows(
|
||||
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId)
|
||||
{
|
||||
var query = _db.Dispatches.AsNoTracking()
|
||||
.Include(dispatch => dispatch.Vendor)
|
||||
.Include(dispatch => dispatch.WorkOrder)
|
||||
.Where(dispatch => dispatch.Vendor != null);
|
||||
if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from);
|
||||
if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to);
|
||||
if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId);
|
||||
if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade);
|
||||
if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId);
|
||||
|
||||
var dispatches = await query.ToListAsync();
|
||||
return dispatches.GroupBy(dispatch => dispatch.VendorId)
|
||||
.Select(group =>
|
||||
{
|
||||
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
|
||||
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
|
||||
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
|
||||
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList();
|
||||
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
|
||||
return new VendorInsightRow
|
||||
{
|
||||
VendorId = group.Key,
|
||||
Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}",
|
||||
CompletedJobs = completed.Count,
|
||||
CancellationRefusalRate = Rate(canceled, group.Count()),
|
||||
OnTimeArrivalRate = Rate(
|
||||
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
|
||||
acknowledgedWithSchedule.Count),
|
||||
OnTimeCompletionRate = Rate(
|
||||
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate),
|
||||
completedWithDue.Count),
|
||||
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
|
||||
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
|
||||
};
|
||||
})
|
||||
.OrderBy(row => row.Vendor)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
|
||||
=> leftStart < rightEnd && rightStart < leftEnd;
|
||||
|
||||
private static decimal Rate(int numerator, int denominator)
|
||||
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
|
||||
? $"'{value}"
|
||||
: value;
|
||||
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
private static object MetricDefinitions() => new
|
||||
{
|
||||
CompletedJobs = "Dispatches with Completed or Verified status.",
|
||||
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
|
||||
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
|
||||
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
|
||||
AverageCycleHours = "Average elapsed hours from dispatch to completion."
|
||||
};
|
||||
|
||||
private static byte[] SimplePdf(IEnumerable<string> lines)
|
||||
{
|
||||
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
|
||||
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
|
||||
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
|
||||
+ " ET";
|
||||
var objects = new[]
|
||||
{
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
||||
};
|
||||
var builder = new StringBuilder("%PDF-1.4\n");
|
||||
var offsets = new List<int> { 0 };
|
||||
for (var index = 0; index < objects.Length; index++)
|
||||
{
|
||||
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
|
||||
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
|
||||
}
|
||||
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
|
||||
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
|
||||
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
|
||||
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
|
||||
return Encoding.ASCII.GetBytes(builder.ToString());
|
||||
}
|
||||
|
||||
public sealed class AvailabilityRequest
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SitePreferenceRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DocumentReviewRequest
|
||||
{
|
||||
public string ReviewStatus { get; set; } = "";
|
||||
public string? RejectionReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CommercialStatusRequest
|
||||
{
|
||||
public string InvoiceStatus { get; set; } = "Not Submitted";
|
||||
public string PaymentStatus { get; set; } = "Unavailable";
|
||||
public string? InvoiceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VendorAssignmentRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
public DateTime? ScheduledEnd { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ScheduledDispatch
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int VendorId { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public DateTime Start { get; set; }
|
||||
public DateTime End { get; set; }
|
||||
}
|
||||
|
||||
private sealed class VendorInsightRow
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string Vendor { get; set; } = "";
|
||||
public int CompletedJobs { get; set; }
|
||||
public decimal CancellationRefusalRate { get; set; }
|
||||
public decimal OnTimeArrivalRate { get; set; }
|
||||
public decimal OnTimeCompletionRate { get; set; }
|
||||
public double? AverageCycleHours { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly VendorPortalTokenService _tokens;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly SendMessage _sendMessage;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
|
||||
public VendorPortalController(
|
||||
ApplicationDbContext db,
|
||||
VendorPortalTokenService tokens,
|
||||
IConfiguration config,
|
||||
SendMessage sendMessage,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_db = db;
|
||||
_tokens = tokens;
|
||||
_config = config;
|
||||
_sendMessage = sendMessage;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpGet("session")]
|
||||
|
|
@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Id,
|
||||
d.DispatchNumber,
|
||||
d.PONumber,
|
||||
d.InvoiceNumber,
|
||||
d.InvoiceStatus,
|
||||
d.PaymentStatus,
|
||||
d.Status,
|
||||
d.NTEAmount,
|
||||
d.ScheduledDate,
|
||||
|
|
@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var documents = await _db.VendorCompletionDocuments
|
||||
.Where(document => document.DispatchId == id && document.VendorId == vendor.Id)
|
||||
.OrderByDescending(document => document.CreatedDate)
|
||||
.Select(document => new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.ContentType,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.RejectionReason,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId,
|
||||
document.CreatedDate,
|
||||
document.ScannedAt,
|
||||
document.ReviewedAt,
|
||||
CanDownload = document.ScanStatus == "Passed"
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rawUplifts = await (from u in _db.DispatchUpliftRequests
|
||||
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
|
|
@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
dispatch.Id,
|
||||
dispatch.DispatchNumber,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt,
|
||||
dispatch.Status,
|
||||
dispatch.NTEAmount,
|
||||
dispatch.Description,
|
||||
|
|
@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
},
|
||||
Checklist = checklist,
|
||||
Signoffs = signoffs,
|
||||
Documents = documents,
|
||||
Comments = comments,
|
||||
UpliftRequests = upliftRequests
|
||||
}
|
||||
|
|
@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/documents")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadCompletionDocument(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] int? replacesDocumentId = null)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" });
|
||||
if (file.Length > 10 * 1024 * 1024)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" });
|
||||
|
||||
var allowed = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["application/pdf"] = new[] { ".pdf" },
|
||||
["image/jpeg"] = new[] { ".jpg", ".jpeg" },
|
||||
["image/png"] = new[] { ".png" }
|
||||
};
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
if (!allowed.TryGetValue(file.ContentType, out var extensions)
|
||||
|| !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" });
|
||||
|
||||
await using var signatureStream = file.OpenReadStream();
|
||||
if (!await HasExpectedSignature(signatureStream, file.ContentType))
|
||||
return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" });
|
||||
|
||||
var workOrderId = dispatch.WorkOrderId
|
||||
?? await _db.DispatchWorkOrders
|
||||
.Where(link => link.DispatchId == dispatch.Id)
|
||||
.OrderBy(link => link.Id)
|
||||
.Select(link => link.WorkOrderId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (workOrderId == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" });
|
||||
|
||||
VendorCompletionDocument? replaced = null;
|
||||
if (replacesDocumentId.HasValue)
|
||||
{
|
||||
replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document =>
|
||||
document.Id == replacesDocumentId.Value
|
||||
&& document.DispatchId == id
|
||||
&& document.VendorId == vendor.Id);
|
||||
if (replaced == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var document = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = id,
|
||||
WorkOrderId = workOrderId,
|
||||
OriginalFileName = Path.GetFileName(file.FileName),
|
||||
StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}",
|
||||
ContentType = file.ContentType,
|
||||
SizeBytes = file.Length,
|
||||
ScanStatus = "Pending",
|
||||
ReviewStatus = "Processing",
|
||||
Version = (replaced?.Version ?? 0) + 1,
|
||||
ReplacesDocumentId = replaced?.Id,
|
||||
CreatedDate = now,
|
||||
createdby = $"vendor:{vendor.Id}"
|
||||
};
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using (var target = System.IO.File.Create(path))
|
||||
{
|
||||
await file.CopyToAsync(target);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_db.VendorCompletionDocuments.Add(document);
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
FieldName = "Completion Document",
|
||||
OldValue = replaced?.OriginalFileName,
|
||||
NewValue = document.OriginalFileName,
|
||||
Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
|
||||
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item =>
|
||||
item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return StatusCode(StatusCodes.Status423Locked,
|
||||
new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" });
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
if (!System.IO.File.Exists(path)) return NotFound();
|
||||
return PhysicalFile(path, document.ContentType, document.OriginalFileName);
|
||||
}
|
||||
|
||||
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
|
||||
{
|
||||
try
|
||||
|
|
@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> HasExpectedSignature(Stream stream, string contentType)
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
var count = await stream.ReadAsync(bytes);
|
||||
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D;
|
||||
if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<Vendor?> ResolveVendorAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms;
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.VisualBasic;
|
||||
|
|
@ -18,6 +19,7 @@ using static System.Runtime.InteropServices.JavaScript.JSType;
|
|||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -105,28 +107,46 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_workOrderMediaService = workOrderMediaService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weekly board payload.
|
||||
/// When <paramref name="myWorkOrders"/> is true, the authenticated user filter wins and
|
||||
/// <paramref name="dispatchers"/> is ignored (not AND-combined).
|
||||
/// Weekend WOs in a Mon–Sun window may have <c>dayGroup = null</c>; FE should handle that.
|
||||
/// </summary>
|
||||
[HttpGet("board")]
|
||||
public async Task<IActionResult> GetBoard(
|
||||
[FromQuery] DateOnly weekStart,
|
||||
[FromQuery, BindRequired] DateOnly weekStart,
|
||||
[FromQuery] DateOnly? weekEnd = null,
|
||||
[FromQuery] List<string>? dispatchers = null,
|
||||
[FromQuery] bool myWorkOrders = false,
|
||||
[FromQuery] List<WorkOrderType>? types = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var query = new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = weekStart,
|
||||
WeekEnd = weekEnd,
|
||||
Dispatchers = dispatchers,
|
||||
MyWorkOrders = myWorkOrders,
|
||||
Types = types,
|
||||
Search = search
|
||||
};
|
||||
var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4);
|
||||
var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd);
|
||||
if (weekValidationError != null)
|
||||
return BadRequest(weekValidationError);
|
||||
|
||||
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
|
||||
return Ok(result);
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var query = new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = weekStart,
|
||||
WeekEnd = weekEnd,
|
||||
Dispatchers = dispatchers,
|
||||
MyWorkOrders = myWorkOrders,
|
||||
Types = types,
|
||||
Search = search
|
||||
};
|
||||
|
||||
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("board/search")]
|
||||
|
|
@ -144,6 +164,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatcher dropdown options. Currently returns all non-deleted users
|
||||
/// (role-filtered assignee list deferred until roles settle).
|
||||
/// </summary>
|
||||
[HttpGet("lookups/dispatchers")]
|
||||
public async Task<IActionResult> GetDispatcherLookups()
|
||||
{
|
||||
|
|
@ -167,6 +191,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
CurrentState = ex.CurrentState
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
|
|
@ -203,6 +235,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
|
|
@ -222,6 +262,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var row = await _boardCancelService.CancelAsync(id, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
|
|
@ -861,6 +909,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Description,
|
||||
d.Status,
|
||||
d.ScheduledDate,
|
||||
d.EstimatedArrivalAt,
|
||||
d.EtaManualOverride,
|
||||
d.CompletedDate,
|
||||
d.DispatchedAt,
|
||||
d.EmailSent,
|
||||
|
|
@ -1230,7 +1280,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
foreach (var vendorId in model.VendorIds)
|
||||
{
|
||||
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
|
||||
if (vendor == null) continue;
|
||||
if (vendor == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} was not found" });
|
||||
if (!vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} is inactive and cannot receive a new assignment" });
|
||||
|
||||
var poNumber = $"VPO-{nextPO:D5}";
|
||||
nextPO++;
|
||||
|
|
@ -1249,6 +1302,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Description = model.Description,
|
||||
Status = "Sent",
|
||||
ScheduledDate = model.ScheduledDate,
|
||||
EstimatedArrivalAt = model.EstimatedArrivalAt,
|
||||
EtaManualOverride = model.EtaManualOverride,
|
||||
DispatchedAt = DateTime.UtcNow,
|
||||
ReplyToAddress = replyTo,
|
||||
DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList()
|
||||
|
|
@ -1328,6 +1383,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Action = AuditAction.Dispatch.ToString(),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
if (model.EstimatedArrivalAt.HasValue)
|
||||
{
|
||||
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = wo.Id,
|
||||
UserId = userId,
|
||||
FieldName = "Vendor ETA",
|
||||
OldValue = "",
|
||||
NewValue = model.EstimatedArrivalAt.Value.ToUniversalTime().ToString("O"),
|
||||
Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_set",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count });
|
||||
|
|
@ -1354,6 +1422,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Description,
|
||||
d.Status,
|
||||
d.ScheduledDate,
|
||||
d.EstimatedArrivalAt,
|
||||
d.EtaManualOverride,
|
||||
d.CompletedDate,
|
||||
d.DispatchedAt,
|
||||
d.EmailSent,
|
||||
|
|
@ -1380,6 +1450,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
x.Description,
|
||||
x.Status,
|
||||
x.ScheduledDate,
|
||||
x.EstimatedArrivalAt,
|
||||
x.EtaManualOverride,
|
||||
x.CompletedDate,
|
||||
x.DispatchedAt,
|
||||
x.EmailSent,
|
||||
|
|
@ -1468,6 +1540,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
dispatch.Status = model.Status;
|
||||
dispatch.NTEAmount = model.NTEAmount;
|
||||
dispatch.ScheduledDate = model.ScheduledDate;
|
||||
if (dispatch.EstimatedArrivalAt != model.EstimatedArrivalAt)
|
||||
{
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = "Vendor ETA",
|
||||
OldValue = dispatch.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "",
|
||||
NewValue = model.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "",
|
||||
Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_change",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
dispatch.EstimatedArrivalAt = model.EstimatedArrivalAt;
|
||||
dispatch.EtaManualOverride = model.EtaManualOverride;
|
||||
dispatch.CompletedDate = model.CompletedDate;
|
||||
dispatch.Description = model.Description;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public List<int> VendorIds { get; set; } = new();
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? TaskListTemplateId { get; set; }
|
||||
public List<string>? CustomChecklistItems { get; set; }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public string? Status { get; set; }
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,34 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public class Vendor_DTO
|
||||
{
|
||||
public string? CompanyName { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class EditVendor_DTO : Vendor_DTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderVendorUpdate_DTO
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal file
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Filters
|
||||
{
|
||||
public class ArgumentExceptionFilter : IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
if (context.Exception is not ArgumentException ex) return;
|
||||
|
||||
context.Result = new BadRequestObjectResult(new
|
||||
{
|
||||
status = "Error",
|
||||
message = ex.Message,
|
||||
code = 400
|
||||
});
|
||||
context.ExceptionHandled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public enum DocumentScanResult
|
||||
{
|
||||
Passed,
|
||||
Infected,
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public interface IDocumentScanner
|
||||
{
|
||||
Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class ClamAvDocumentScanner : IDocumentScanner
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public ClamAvDocumentScanner(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var host = _configuration["ClamAV:Host"];
|
||||
var port = _configuration.GetValue<int?>("ClamAV:Port") ?? 3310;
|
||||
if (string.IsNullOrWhiteSpace(host) || !File.Exists(path))
|
||||
return DocumentScanResult.Unavailable;
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellationToken);
|
||||
await using var network = client.GetStream();
|
||||
await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken);
|
||||
await using var file = File.OpenRead(path);
|
||||
var buffer = new byte[64 * 1024];
|
||||
while (true)
|
||||
{
|
||||
var count = await file.ReadAsync(buffer, cancellationToken);
|
||||
if (count == 0) break;
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, count);
|
||||
await network.WriteAsync(length, cancellationToken);
|
||||
await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
|
||||
}
|
||||
await network.WriteAsync(new byte[4], cancellationToken);
|
||||
await network.FlushAsync(cancellationToken);
|
||||
|
||||
var responseBuffer = new byte[1024];
|
||||
var responseLength = await network.ReadAsync(responseBuffer, cancellationToken);
|
||||
var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength);
|
||||
if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
return DocumentScanResult.Infected;
|
||||
return response.Contains("OK", StringComparison.OrdinalIgnoreCase)
|
||||
? DocumentScanResult.Passed
|
||||
: DocumentScanResult.Unavailable;
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return DocumentScanResult.Unavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public class VendorDocumentScanWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<VendorDocumentScanWorker> _logger;
|
||||
|
||||
public VendorDocumentScanWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<VendorDocumentScanWorker> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await ScanPendingAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
|
||||
var pending = await db.VendorCompletionDocuments
|
||||
.Where(document => document.ScanStatus == "Pending")
|
||||
.OrderBy(document => document.CreatedDate)
|
||||
.Take(20)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var document in pending)
|
||||
{
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
var result = await scanner.ScanAsync(path, cancellationToken);
|
||||
if (result == DocumentScanResult.Unavailable)
|
||||
{
|
||||
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
|
||||
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
|
||||
document.RejectionReason = result == DocumentScanResult.Infected
|
||||
? "The upload failed malware scanning."
|
||||
: document.RejectionReason;
|
||||
document.ScannedAt = DateTime.UtcNow;
|
||||
document.LastModificationTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (result == DocumentScanResult.Infected && File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class VendorDocumentStorage
|
||||
{
|
||||
public static string Root(string contentRoot) =>
|
||||
Path.Combine(contentRoot, "App_Data", "vendor-documents");
|
||||
|
||||
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
|
||||
Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
|||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
|
||||
}).AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
||||
|
|
@ -43,6 +44,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
|
|||
|
||||
builder.Services.AddScoped<SendMessage>();
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
|
||||
builder.Services.AddHostedService<VendorDocumentScanWorker>();
|
||||
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
||||
{
|
||||
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
// Production must supply a reachable ClamAV Host (environment variable ClamAV__Host).
|
||||
// While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked).
|
||||
"ClamAV": {
|
||||
"Host": "",
|
||||
"Port": 3310
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JWT": {
|
||||
"ValidAudience": "http://console.seahavenind.com",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@
|
|||
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
// Malware scanner for vendor completion document uploads. While Host is empty the
|
||||
// scanner is considered unavailable and uploaded files stay quarantined (download
|
||||
// returns 423 Locked until a scan passes). Production must supply a real Host.
|
||||
"ClamAV": {
|
||||
"Host": "",
|
||||
"Port": 3310
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JWT": {
|
||||
"ValidAudience": "http://localhost:4200",
|
||||
|
|
@ -41,4 +48,3 @@
|
|||
},
|
||||
"FrontendBaseUrl": "https://shoc.seahaven.com"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,22 @@ namespace Data.SeaHavenIndustries
|
|||
builder.Entity<CompletionDocTemplate>()
|
||||
.HasIndex(t => new { t.ServiceKey, t.IsActive });
|
||||
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
|
||||
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.SiteCode)
|
||||
.HasMaxLength(32);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.InternalWONumber)
|
||||
.HasMaxLength(11);
|
||||
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.SiteCode)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
|
@ -70,6 +82,7 @@ namespace Data.SeaHavenIndustries
|
|||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
||||
|
||||
builder.Entity<Dispatch>()
|
||||
.Property(d => d.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
|
@ -79,6 +92,26 @@ namespace Data.SeaHavenIndustries
|
|||
.WithMany()
|
||||
.HasForeignKey(w => w.PrimaryDispatchId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<VendorCompany>()
|
||||
.HasIndex(c => c.NormalizedName)
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<Vendor>()
|
||||
.HasOne(v => v.Company)
|
||||
.WithMany(c => c.Vendors)
|
||||
.HasForeignKey(v => v.CompanyId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<SitePreferredVendor>()
|
||||
.HasIndex(p => new { p.LocationId, p.VendorId, p.Trade })
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NOT NULL");
|
||||
|
||||
builder.Entity<SitePreferredVendor>()
|
||||
.HasIndex(p => new { p.LocationId, p.VendorId })
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NULL");
|
||||
}
|
||||
public DbSet<Category> Categories { get; set; }
|
||||
public DbSet<Locations> Locations { get; set; }
|
||||
|
|
@ -98,7 +131,11 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
|
||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
|
||||
public DbSet<VendorCompletionDocument> VendorCompletionDocuments { get; set; }
|
||||
public DbSet<SitePreferredVendor> SitePreferredVendors { get; set; }
|
||||
public DbSet<Dispatch> Dispatches { get; set; }
|
||||
public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; }
|
||||
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
|
||||
|
|
|
|||
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal file
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared terminal lifecycle statuses for EF-translatable filters and board rules.
|
||||
/// </summary>
|
||||
public static class LifecycleStatusSets
|
||||
{
|
||||
public static readonly LifecycleStatus?[] Terminal =
|
||||
{
|
||||
LifecycleStatus.Completed,
|
||||
LifecycleStatus.Canceled
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
public class AddWorkOrderTypeAndScheduledDateIndex : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL
|
||||
ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
|
||||
END
|
||||
""");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NOT NULL
|
||||
ALTER TABLE workOrders DROP COLUMN WorkOrderType;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1911,7 +1911,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("InternalWONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(11)
|
||||
.HasColumnType("nvarchar(11)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
|
@ -1989,7 +1990,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SiteCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,24 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "SiteCode",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(32)",
|
||||
maxLength: 32,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "InternalWONumber",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(11)",
|
||||
maxLength: 11,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_SiteCode",
|
||||
table: "workOrders",
|
||||
|
|
@ -43,6 +61,24 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "SiteCode",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(32)",
|
||||
oldMaxLength: 32);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "InternalWONumber",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(11)",
|
||||
oldMaxLength: 11);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2996
Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs
generated
Normal file
2996
Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class Phase1_BoardIndexes : Migration
|
||||
public partial class Phase1_BoardIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
|
|
|
|||
|
|
@ -1911,7 +1911,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("InternalWONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(11)
|
||||
.HasColumnType("nvarchar(11)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
|
@ -1989,7 +1990,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SiteCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260720220000_AddVendorDirectoryDetails")]
|
||||
public partial class AddVendorDirectoryDetails : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CompanyPhone",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleMapsUrl",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Notes",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors");
|
||||
migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors");
|
||||
migrationBuilder.DropColumn(name: "Notes", table: "Vendors");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
using System;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260723194013_AddVendorCompanyAndAudit")]
|
||||
public partial class AddVendorCompanyAndAudit : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CompanyId",
|
||||
table: "Vendors",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PreferredContact",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCompanies",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
CompanyPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Address = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
City = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Zip = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
GoogleMapsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCompanies", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorAuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: true),
|
||||
FieldName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
OldValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
NewValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Actor = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorAuditLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorAuditLogs_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
// Backfill: create one VendorCompany per distinct (case-insensitive, trimmed) legacy name.
|
||||
// Keep all company-owned fields from the same deterministic source vendor row.
|
||||
migrationBuilder.Sql(@"
|
||||
WITH RankedVendors AS (
|
||||
SELECT
|
||||
v.[CompanyName] AS [Name],
|
||||
LOWER(LTRIM(RTRIM(v.[CompanyName]))) AS [NormalizedName],
|
||||
v.[CompanyPhone],
|
||||
v.[Email],
|
||||
v.[Address],
|
||||
v.[City],
|
||||
v.[State],
|
||||
v.[Zip],
|
||||
v.[GoogleMapsUrl],
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY LOWER(LTRIM(RTRIM(v.[CompanyName])))
|
||||
ORDER BY v.[Id]
|
||||
) AS [RowNumber]
|
||||
FROM [Vendors] v
|
||||
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> ''
|
||||
)
|
||||
INSERT INTO [VendorCompanies] (
|
||||
[Name], [NormalizedName], [CompanyPhone], [Email], [Address], [City], [State], [Zip], [GoogleMapsUrl], [CreatedDate]
|
||||
)
|
||||
SELECT
|
||||
ranked.[Name],
|
||||
ranked.[NormalizedName],
|
||||
ranked.[CompanyPhone],
|
||||
ranked.[Email],
|
||||
ranked.[Address],
|
||||
ranked.[City],
|
||||
ranked.[State],
|
||||
ranked.[Zip],
|
||||
ranked.[GoogleMapsUrl],
|
||||
GETUTCDATE() AS [CreatedDate]
|
||||
FROM RankedVendors ranked
|
||||
WHERE ranked.[RowNumber] = 1;
|
||||
|
||||
UPDATE v
|
||||
SET v.[CompanyId] = vc.[Id]
|
||||
FROM [Vendors] v
|
||||
INNER JOIN [VendorCompanies] vc
|
||||
ON vc.[NormalizedName] = LOWER(LTRIM(RTRIM(v.[CompanyName])))
|
||||
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> '';
|
||||
");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Vendors_CompanyId",
|
||||
table: "Vendors",
|
||||
column: "CompanyId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorAuditLogs_VendorId",
|
||||
table: "VendorAuditLogs",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompanies_NormalizedName",
|
||||
table: "VendorCompanies",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Vendors_VendorCompanies_CompanyId",
|
||||
table: "Vendors",
|
||||
column: "CompanyId",
|
||||
principalTable: "VendorCompanies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Vendors_VendorCompanies_CompanyId",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Vendors_CompanyId",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorAuditLogs_VendorId",
|
||||
table: "VendorAuditLogs");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCompanies_NormalizedName",
|
||||
table: "VendorCompanies");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorAuditLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCompanies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CompanyId",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PreferredContact",
|
||||
table: "Vendors");
|
||||
}
|
||||
}
|
||||
}
|
||||
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,230 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorRoadmapOperations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unknown");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Not Submitted");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unavailable");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SitePreferredVendors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LocationId = table.Column<int>(type: "int", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SitePreferredVendors", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Locations_LocationId",
|
||||
column: x => x.LocationId,
|
||||
principalTable: "Locations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCompletionDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
DispatchId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Version = table.Column<int>(type: "int", nullable: false),
|
||||
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
|
||||
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Dispatches_DispatchId",
|
||||
column: x => x.DispatchId,
|
||||
principalTable: "Dispatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId",
|
||||
column: x => x.WorkOrderId,
|
||||
principalTable: "workOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId_Trade",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId", "Trade" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_DispatchId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "DispatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_VendorId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_WorkOrderId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "WorkOrderId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SitePreferredVendors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCompletionDocuments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -686,6 +686,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateTime?>("AcknowledgedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CommercialStatusUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CompletedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -710,6 +713,19 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<bool>("EmailSent")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("EstimatedArrivalAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("EtaManualOverride")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("InvoiceNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("InvoiceStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -725,6 +741,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PaymentStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ReplyToAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1210,6 +1230,86 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("EmployeePhones", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AllDay")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("EndTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("EventColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<int?>("RecordID")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RecordName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RecordTargetType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ReminderMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StartTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1589,6 +1689,65 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Regions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("LocationId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NULL");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId", "Trade")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NOT NULL");
|
||||
|
||||
b.ToTable("SitePreferredVendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1790,12 +1949,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Address")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("AvailabilityStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("AvailabilityUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CompanyPhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ContactName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1811,6 +1983,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("GoogleMapsUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -1823,9 +1998,15 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PreferredContact")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1840,6 +2021,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("Vendors", (string)null);
|
||||
});
|
||||
|
||||
|
|
@ -1902,6 +2085,195 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("VendorAccessTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("NewValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("OldValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CompanyPhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("GoogleMapsUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Zip")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("VendorCompanies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("DispatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ReplacesDocumentId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ReviewStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ReviewedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ScanStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ScannedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("VendorCompletionDocuments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1980,7 +2352,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(7)");
|
||||
|
||||
b.Property<string>("InternalWONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(11)
|
||||
.HasColumnType("nvarchar(11)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
|
@ -2074,7 +2447,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SiteCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
@ -2397,6 +2771,35 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -2824,6 +3227,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
|
||||
.WithMany()
|
||||
.HasForeignKey("LocationId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
|
||||
.WithMany("SitePreferences")
|
||||
.HasForeignKey("VendorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Location");
|
||||
|
||||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template")
|
||||
|
|
@ -2866,6 +3288,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("POC");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
|
||||
.WithMany("Vendors")
|
||||
.HasForeignKey("CompanyId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Company");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
|
||||
|
|
@ -2877,6 +3309,44 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
|
||||
.WithMany("AuditLogs")
|
||||
.HasForeignKey("VendorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
|
||||
.WithMany("CompletionDocuments")
|
||||
.HasForeignKey("DispatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
|
||||
.WithMany()
|
||||
.HasForeignKey("VendorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Dispatch");
|
||||
|
||||
b.Navigation("Vendor");
|
||||
|
||||
b.Navigation("WorkOrder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
|
||||
|
|
@ -3078,6 +3548,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.Navigation("Comments");
|
||||
|
||||
b.Navigation("CompletionDocuments");
|
||||
|
||||
b.Navigation("DispatchWorkOrders");
|
||||
|
||||
b.Navigation("Signoffs");
|
||||
|
|
@ -3121,7 +3593,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.Navigation("AuditLogs");
|
||||
|
||||
b.Navigation("Dispatches");
|
||||
|
||||
b.Navigation("SitePreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||
{
|
||||
b.Navigation("Vendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Description { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public string InvoiceStatus { get; set; } = "Not Submitted";
|
||||
public string PaymentStatus { get; set; } = "Unavailable";
|
||||
public string? InvoiceNumber { get; set; }
|
||||
public DateTime? CommercialStatusUpdatedAt { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
|
|
@ -42,5 +48,6 @@ namespace Data.SeaHavenIndustries
|
|||
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
|
||||
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
|
||||
public ICollection<DispatchSignoff>? Signoffs { get; set; }
|
||||
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs
Normal file
15
Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class LinkedWorkOrderInfo
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderTitle { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public LifecycleStatus? LifecycleStatus { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public int? DispatchId { get; set; }
|
||||
}
|
||||
}
|
||||
19
Data.SeaHavenIndustries/Models/SitePreferredVendor.cs
Normal file
19
Data.SeaHavenIndustries/Models/SitePreferredVendor.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class SitePreferredVendor : FullAuditEntity
|
||||
{
|
||||
public int LocationId { get; set; }
|
||||
[ForeignKey(nameof(LocationId))]
|
||||
public Locations? Location { get; set; }
|
||||
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public string? Trade { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class Vendor : FullAuditEntity
|
||||
|
|
@ -6,12 +8,27 @@ namespace Data.SeaHavenIndustries
|
|||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? PreferredContact { get; set; }
|
||||
public string AvailabilityStatus { get; set; } = "Unknown";
|
||||
public DateTime? AvailabilityUpdatedAt { get; set; }
|
||||
|
||||
public int? CompanyId { get; set; }
|
||||
[ForeignKey(nameof(CompanyId))]
|
||||
public virtual VendorCompany? Company { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public int TotalJobs { get; set; }
|
||||
public ICollection<Dispatch>? Dispatches { get; set; }
|
||||
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
|
||||
public ICollection<SitePreferredVendor>? SitePreferences { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
Data.SeaHavenIndustries/Models/VendorAuditLog.cs
Normal file
18
Data.SeaHavenIndustries/Models/VendorAuditLog.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorAuditLog
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public virtual Vendor? Vendor { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? FieldName { get; set; }
|
||||
public string? OldValue { get; set; }
|
||||
public string? NewValue { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
19
Data.SeaHavenIndustries/Models/VendorCompany.cs
Normal file
19
Data.SeaHavenIndustries/Models/VendorCompany.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorCompany : FullAuditEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string NormalizedName { get; set; } = string.Empty;
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
|
||||
public ICollection<Vendor>? Vendors { get; set; }
|
||||
}
|
||||
}
|
||||
31
Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs
Normal file
31
Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorCompletionDocument : FullAuditEntity
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public int DispatchId { get; set; }
|
||||
[ForeignKey(nameof(DispatchId))]
|
||||
public Dispatch? Dispatch { get; set; }
|
||||
|
||||
public int WorkOrderId { get; set; }
|
||||
[ForeignKey(nameof(WorkOrderId))]
|
||||
public WorkOrder? WorkOrder { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = "";
|
||||
public string StoredFileName { get; set; } = "";
|
||||
public string ContentType { get; set; } = "";
|
||||
public long SizeBytes { get; set; }
|
||||
public string ScanStatus { get; set; } = "Pending";
|
||||
public string ReviewStatus { get; set; } = "Processing";
|
||||
public string? RejectionReason { get; set; }
|
||||
public int Version { get; set; } = 1;
|
||||
public int? ReplacesDocumentId { get; set; }
|
||||
public DateTime? ScannedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ namespace Data.SeaHavenIndustries
|
|||
public string? LegacyStatus { get; set; }
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? Service { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
namespace SeaHaven.DataServices.Board
|
||||
{
|
||||
public static class WorkOrderBoardConstants
|
||||
{
|
||||
public const int ClientSideThreshold = 300;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
namespace SeaHaven.DataServices.DTOs
|
||||
{
|
||||
public record WorkOrderBoardQuery(
|
||||
DateOnly ScheduledFrom,
|
||||
DateOnly ScheduledTo,
|
||||
string? Search,
|
||||
IReadOnlyList<string>? AssigneeIds,
|
||||
IReadOnlyList<string>? DbStatuses,
|
||||
int? LocationId);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
namespace SeaHaven.DataServices.DTOs
|
||||
{
|
||||
public record WorkOrderBoardRowData(
|
||||
int Id,
|
||||
string? WoNumber,
|
||||
string? SiteCode,
|
||||
string? LocationLabel,
|
||||
DateTime? ScheduledDate,
|
||||
DateTime? ScheduledStart,
|
||||
DateTime? DueDate,
|
||||
string? WorkOrderType,
|
||||
string? ServiceType,
|
||||
string? AssigneeId,
|
||||
string? AssigneeName,
|
||||
string? VendorCompany,
|
||||
string? VendorTechnician,
|
||||
string? Status,
|
||||
string? PocName,
|
||||
string? PocPhone);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
namespace SeaHaven.DataServices.Exceptions
|
||||
{
|
||||
public class BoardQueryException : Exception
|
||||
{
|
||||
public BoardQueryException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ namespace SeaHaven.DataServices.Helpers
|
|||
Name = c.POC != null
|
||||
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
|
||||
: null,
|
||||
c.POC!.PhoneNumber,
|
||||
PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null,
|
||||
c.Notes
|
||||
})
|
||||
.FirstOrDefault(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ namespace SeaHaven.DataServices.Helpers
|
|||
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
|
||||
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
/// <summary>
|
||||
/// Applies assignee filters. When <paramref name="myWorkOrders"/> is true and
|
||||
/// <paramref name="currentUserId"/> is set, that takes precedence and
|
||||
/// <paramref name="dispatchers"/> is ignored.
|
||||
/// </summary>
|
||||
public static IQueryable<WorkOrder> ApplyDispatcherFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<string>? dispatchers,
|
||||
|
|
@ -47,6 +52,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
|
||||
}
|
||||
|
||||
#region Phase 2 filter helpers (not wired in Phase 1 board API)
|
||||
|
||||
public static IQueryable<WorkOrder> ApplySiteFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<string>? sites)
|
||||
|
|
@ -134,5 +141,8 @@ namespace SeaHaven.DataServices.Helpers
|
|||
&& w.TargetWeek >= dateFrom
|
||||
&& w.TargetWeek <= dateTo));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
|
|
@ -15,13 +17,16 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Vendor?> GetByIdAsync(int id)
|
||||
{
|
||||
return await _context.Vendors.FindAsync(id);
|
||||
return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id);
|
||||
}
|
||||
|
||||
public async Task<Vendor?> GetByIdWithDetailsAsync(int id)
|
||||
{
|
||||
return await _context.Vendors
|
||||
.FirstOrDefaultAsync(v => v.Id == id);
|
||||
return await ProjectWithTotalJobs(
|
||||
_context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.Id == id))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Vendor>> GetAllAsync()
|
||||
|
|
@ -32,24 +37,23 @@ namespace SeaHaven.DataServices.Implementation
|
|||
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null)
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null)
|
||||
{
|
||||
search ??= "";
|
||||
var projectedQuery = BuildDirectoryQuery(
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
var query = _context.Vendors.Where(v => v.IsActive);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.ToLower();
|
||||
query = query.Where(v =>
|
||||
(v.CompanyName ?? "").ToLower().Contains(s) ||
|
||||
(v.ContactName ?? "").ToLower().Contains(s) ||
|
||||
(v.Email ?? "").ToLower().Contains(s) ||
|
||||
(v.TradeSpecialties ?? "").ToLower().Contains(s));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var items = await query
|
||||
var totalCount = await projectedQuery.CountAsync();
|
||||
var items = await projectedQuery
|
||||
.OrderByDescending(v => v.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
|
|
@ -58,6 +62,200 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
internal IQueryable<Vendor> BuildDirectoryQuery(
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null)
|
||||
{
|
||||
var query = _context.Vendors
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var normalizedSearch = search.ToLower();
|
||||
query = query.Where(v =>
|
||||
(v.CompanyName ?? "").ToLower().Contains(normalizedSearch) ||
|
||||
(v.ContactName ?? "").ToLower().Contains(normalizedSearch) ||
|
||||
(v.Email ?? "").ToLower().Contains(normalizedSearch) ||
|
||||
(v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) ||
|
||||
(v.City ?? "").ToLower().Contains(normalizedSearch) ||
|
||||
(v.State ?? "").ToLower().Contains(normalizedSearch) ||
|
||||
(v.Zip ?? "").ToLower().Contains(normalizedSearch));
|
||||
}
|
||||
|
||||
var selectedCompanies = companies?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
if (selectedCompanies.Length > 0)
|
||||
query = query.Where(v =>
|
||||
selectedCompanies.Contains((v.CompanyName ?? "").ToLower()));
|
||||
|
||||
var selectedTrades = trades?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
if (selectedTrades.Length > 0)
|
||||
query = query.Where(BuildTradePredicate(selectedTrades));
|
||||
|
||||
var selectedLocations = locations?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
if (selectedLocations.Length > 0)
|
||||
query = query.Where(BuildLocationPredicate(selectedLocations));
|
||||
|
||||
var result = ProjectWithTotalJobs(query);
|
||||
var selectedJobBuckets = jobBuckets?
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLower())
|
||||
.Distinct()
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
if (selectedJobBuckets.Length == 0)
|
||||
return result;
|
||||
|
||||
var under50 = selectedJobBuckets.Contains("under-50");
|
||||
var from50To99 = selectedJobBuckets.Contains("50-99");
|
||||
var from100To149 = selectedJobBuckets.Contains("100-149");
|
||||
var from150 = selectedJobBuckets.Contains("150-plus");
|
||||
|
||||
return result.Where(v =>
|
||||
(under50 && v.TotalJobs < 50) ||
|
||||
(from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) ||
|
||||
(from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) ||
|
||||
(from150 && v.TotalJobs >= 150));
|
||||
}
|
||||
|
||||
private IQueryable<Vendor> ProjectWithTotalJobs(IQueryable<Vendor> query) =>
|
||||
query.Select(v => new Vendor
|
||||
{
|
||||
Id = v.Id,
|
||||
CompanyName = v.CompanyName,
|
||||
CompanyId = v.CompanyId,
|
||||
ContactName = v.ContactName,
|
||||
Email = v.Email,
|
||||
Phone = v.Phone,
|
||||
CompanyPhone = v.CompanyPhone,
|
||||
PreferredContact = v.PreferredContact,
|
||||
Address = v.Address,
|
||||
City = v.City,
|
||||
State = v.State,
|
||||
Zip = v.Zip,
|
||||
TradeSpecialties = v.TradeSpecialties,
|
||||
GoogleMapsUrl = v.GoogleMapsUrl,
|
||||
Notes = v.Notes,
|
||||
IsActive = v.IsActive,
|
||||
IsDeleted = v.IsDeleted,
|
||||
TotalJobs = _context.Dispatches
|
||||
.Where(dispatch =>
|
||||
dispatch.VendorId == v.Id &&
|
||||
dispatch.WorkOrderId.HasValue)
|
||||
.Select(dispatch => dispatch.WorkOrderId!.Value)
|
||||
.Union(
|
||||
_context.DispatchWorkOrders
|
||||
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id)
|
||||
.Select(link => link.WorkOrderId))
|
||||
.Count()
|
||||
});
|
||||
|
||||
private static readonly MethodInfo StringConcatMethod =
|
||||
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!;
|
||||
|
||||
private static Expression<Func<Vendor, bool>> BuildTradePredicate(
|
||||
IReadOnlyCollection<string> selectedTrades)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
||||
var specialties = Expression.Coalesce(
|
||||
Expression.Property(vendor, nameof(Vendor.TradeSpecialties)),
|
||||
Expression.Constant(string.Empty));
|
||||
var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes);
|
||||
|
||||
var delimiters = new[] { ", ", "," };
|
||||
Expression? body = null;
|
||||
|
||||
foreach (var trade in selectedTrades)
|
||||
{
|
||||
var token = Expression.Constant(trade);
|
||||
|
||||
body = OrElse(body, Expression.Equal(lowered, token));
|
||||
|
||||
foreach (var delimiter in delimiters)
|
||||
{
|
||||
var delim = Expression.Constant(delimiter);
|
||||
var tokenWithTrailing = Concat(token, delim);
|
||||
var leadingToken = Concat(delim, token);
|
||||
var wrappedToken = Concat(leadingToken, delim);
|
||||
|
||||
body = OrElse(body, StartsWith(lowered, tokenWithTrailing));
|
||||
body = OrElse(body, EndsWith(lowered, leadingToken));
|
||||
body = OrElse(body, Contains(lowered, wrappedToken));
|
||||
}
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
}
|
||||
|
||||
private static Expression<Func<Vendor, bool>> BuildLocationPredicate(
|
||||
IReadOnlyCollection<string> selectedLocations)
|
||||
{
|
||||
var vendor = Expression.Parameter(typeof(Vendor), "vendor");
|
||||
var label = BuildLocationLabelBody(vendor);
|
||||
var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes);
|
||||
|
||||
Expression? body = null;
|
||||
foreach (var location in selectedLocations)
|
||||
body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location)));
|
||||
|
||||
return Expression.Lambda<Func<Vendor, bool>>(body ?? Expression.Constant(false), vendor);
|
||||
}
|
||||
|
||||
private static Expression BuildLocationLabelBody(ParameterExpression vendor)
|
||||
{
|
||||
var city = Expression.Property(vendor, nameof(Vendor.City));
|
||||
var state = Expression.Property(vendor, nameof(Vendor.State));
|
||||
var cityBlank = IsBlank(city);
|
||||
var stateBlank = IsBlank(state);
|
||||
|
||||
var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state);
|
||||
|
||||
var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state);
|
||||
var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState);
|
||||
return Expression.Condition(cityBlank, onlyState, cityOrBoth);
|
||||
}
|
||||
|
||||
private static Expression IsBlank(Expression value)
|
||||
{
|
||||
var isNull = Expression.Equal(value, Expression.Constant(null, value.Type));
|
||||
var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes);
|
||||
var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty));
|
||||
return Expression.Condition(isNull, Expression.Constant(true), isEmpty);
|
||||
}
|
||||
|
||||
private static Expression Concat(Expression left, Expression right) =>
|
||||
Expression.Call(StringConcatMethod, left, right);
|
||||
|
||||
private static Expression StartsWith(Expression instance, Expression value) =>
|
||||
Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value);
|
||||
|
||||
private static Expression EndsWith(Expression instance, Expression value) =>
|
||||
Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value);
|
||||
|
||||
private static Expression Contains(Expression instance, Expression value) =>
|
||||
Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value);
|
||||
|
||||
private static Expression OrElse(Expression? accumulated, Expression next) =>
|
||||
accumulated is null ? next : Expression.OrElse(accumulated, next);
|
||||
|
||||
public async Task<Vendor> AddAsync(Vendor vendor)
|
||||
{
|
||||
await _context.Vendors.AddAsync(vendor);
|
||||
|
|
@ -102,5 +300,102 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
return await _context.Vendors.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<VendorCompany?> GetCompanyByIdAsync(int id)
|
||||
{
|
||||
return await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName)
|
||||
{
|
||||
return await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.NormalizedName == normalizedName);
|
||||
}
|
||||
|
||||
public async Task<VendorCompany> AddCompanyAsync(VendorCompany company)
|
||||
{
|
||||
await _context.VendorCompanies.AddAsync(company);
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
return company;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
_context.Entry(company).State = EntityState.Detached;
|
||||
var existing = await _context.VendorCompanies
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateCompanyAsync(VendorCompany company)
|
||||
{
|
||||
_context.VendorCompanies.Update(company);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
internal IQueryable<LinkedWorkOrderInfo> BuildLinkedWorkOrdersQuery(int vendorId)
|
||||
{
|
||||
var directLinks = _context.Dispatches
|
||||
.Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue)
|
||||
.Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value });
|
||||
|
||||
var multiLinks = _context.DispatchWorkOrders
|
||||
.Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId)
|
||||
.Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId });
|
||||
|
||||
var linkedIds = directLinks.Concat(multiLinks);
|
||||
|
||||
return from link in linkedIds
|
||||
join wo in _context.workOrders on link.WorkOrderId equals wo.Id
|
||||
orderby wo.Id, link.DispatchId
|
||||
select new LinkedWorkOrderInfo
|
||||
{
|
||||
WorkOrderId = wo.Id,
|
||||
WorkOrderNumber = wo.WorkerOrderNumber,
|
||||
WorkOrderTitle = wo.WorkerOrderTitle,
|
||||
Status = wo.Status,
|
||||
LifecycleStatus = wo.LifecycleStatus,
|
||||
ScheduledDate = wo.ScheduledDate,
|
||||
DispatchId = link.DispatchId
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId)
|
||||
{
|
||||
var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync();
|
||||
return raw.DistinctBy(x => x.WorkOrderId).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId)
|
||||
{
|
||||
var hasDirect = await _context.Dispatches
|
||||
.AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId);
|
||||
|
||||
if (hasDirect)
|
||||
return true;
|
||||
|
||||
return await _context.DispatchWorkOrders
|
||||
.AnyAsync(link => link.WorkOrderId == workOrderId
|
||||
&& link.Dispatch != null
|
||||
&& link.Dispatch.VendorId == vendorId);
|
||||
}
|
||||
|
||||
public async Task UpdateWithAuditLogsAsync(
|
||||
Vendor vendor,
|
||||
IReadOnlyCollection<VendorAuditLog> auditLogs)
|
||||
{
|
||||
_context.Vendors.Update(vendor);
|
||||
if (auditLogs.Count > 0)
|
||||
await _context.VendorAuditLogs.AddRangeAsync(auditLogs);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
var unscheduledBase = baseQuery.Where(w =>
|
||||
w.ScheduledDate == null
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled);
|
||||
&& !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus));
|
||||
|
||||
var scheduledTotal = await scheduledBase.CountAsync();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<Vendor?> GetByIdAsync(int id);
|
||||
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null);
|
||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
Task<Vendor> AddAsync(Vendor vendor);
|
||||
Task UpdateAsync(Vendor vendor);
|
||||
Task DeleteAsync(int id);
|
||||
|
|
@ -15,5 +23,13 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId);
|
||||
Task<VendorAccessToken?> GetTokenByStringAsync(string token);
|
||||
Task<int> CountAsync();
|
||||
|
||||
Task<VendorCompany?> GetCompanyByIdAsync(int id);
|
||||
Task<VendorCompany?> GetCompanyByNormalizedNameAsync(string normalizedName);
|
||||
Task<VendorCompany> AddCompanyAsync(VendorCompany company);
|
||||
Task UpdateCompanyAsync(VendorCompany company);
|
||||
Task<List<LinkedWorkOrderInfo>> GetLinkedWorkOrdersAsync(int vendorId);
|
||||
Task<bool> IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId);
|
||||
Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection<VendorAuditLog> auditLogs);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public record PrimaryDispatchProjection(
|
||||
int WorkOrderId,
|
||||
int? DispatchId,
|
||||
string? VendorName,
|
||||
DateTime? ApptDate,
|
||||
string? DispatchStatus);
|
||||
}
|
||||
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
namespace SeaHaven.Services.Board
|
||||
{
|
||||
public static class WorkOrderBoardConstants
|
||||
{
|
||||
public const int MaxWindowDays = 90;
|
||||
public const int ClientSideThreshold = 300;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace SeaHaven.Services.Constants
|
|||
public const string LifecycleStatus = nameof(LifecycleStatus);
|
||||
public const string AssignTo = nameof(AssignTo);
|
||||
public const string ScheduledDate = nameof(ScheduledDate);
|
||||
public const string ScheduledStart = nameof(ScheduledStart);
|
||||
public const string ScheduledEnd = nameof(ScheduledEnd);
|
||||
public const string TargetWeek = nameof(TargetWeek);
|
||||
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);
|
||||
|
|
|
|||
|
|
@ -4,33 +4,96 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public int Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zipcode { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int TotalJobs { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class CreateVendorDTO
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zipcode { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class UpdateVendorDTO
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zipcode { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class VendorDeactivationImpactDTO
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public bool CanDeactivate { get; set; }
|
||||
public List<LinkedWorkOrderDTO> OpenWorkOrders { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LinkedWorkOrderDTO
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderTitle { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public int? DispatchId { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderVendorUpdateDTO
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class VendorDeactivationBlockedException : InvalidOperationException
|
||||
{
|
||||
public List<LinkedWorkOrderDTO> OpenWorkOrders { get; }
|
||||
|
||||
public VendorDeactivationBlockedException(string message, List<LinkedWorkOrderDTO> openWorkOrders)
|
||||
: base(message)
|
||||
{
|
||||
OpenWorkOrders = openWorkOrders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,25 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? SortBy { get; set; }
|
||||
public string? SortDir { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Advanced search / filter query for the board.</summary>
|
||||
public class WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 50;
|
||||
public string? Search { get; set; }
|
||||
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
||||
public DateOnly? DateFrom { get; set; }
|
||||
public DateOnly? DateTo { get; set; }
|
||||
public List<string>? Sites { get; set; }
|
||||
public List<WorkOrderType>? Types { get; set; }
|
||||
public List<string>? Dispatchers { get; set; }
|
||||
public List<LifecycleStatus>? Statuses { get; set; }
|
||||
public List<string>? PmTypes { get; set; }
|
||||
public List<int>? VendorIds { get; set; }
|
||||
public List<DocStatus>? DocStatuses { get; set; }
|
||||
public bool MyWorkOrders { get; set; }
|
||||
public string? SortBy { get; set; }
|
||||
public string? SortDir { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
SeaHaven.Services/Helpers/LifecycleStatusSets.cs
Normal file
20
SeaHaven.Services/Helpers/LifecycleStatusSets.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared terminal lifecycle statuses for board filters, read-only checks, and jobs.
|
||||
/// Keep this as the single source of truth (EF expressions should copy these values inline).
|
||||
/// </summary>
|
||||
public static class LifecycleStatusSets
|
||||
{
|
||||
public static readonly LifecycleStatus[] Terminal =
|
||||
{
|
||||
LifecycleStatus.Completed,
|
||||
LifecycleStatus.Canceled
|
||||
};
|
||||
|
||||
public static bool IsTerminal(LifecycleStatus? status)
|
||||
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
|
||||
}
|
||||
}
|
||||
25
SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs
Normal file
25
SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class VendorPhoneNormalizer
|
||||
{
|
||||
public static readonly Regex NorthAmericanPhoneRegex = new(
|
||||
@"^\(\d{3}\) \d{3}-\d{4}$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public static string? NormalizeToCanonical(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var digits = new string(value.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length == 11 && digits.StartsWith('1'))
|
||||
digits = digits[1..];
|
||||
|
||||
return digits.Length == 10
|
||||
? $"({digits[..3]}) {digits[3..6]}-{digits[6..]}"
|
||||
: value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +64,11 @@ namespace SeaHaven.Services.Helpers
|
|||
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<BoardFieldChange>();
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo))
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.AssignTo,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
return changes;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
|
|
|
|||
|
|
@ -4,22 +4,25 @@ namespace SeaHaven.Services.Helpers
|
|||
{
|
||||
public static class WorkOrderBoardMutationRules
|
||||
{
|
||||
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses = new()
|
||||
{
|
||||
LifecycleStatus.Canceled,
|
||||
LifecycleStatus.Completed
|
||||
};
|
||||
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses =
|
||||
LifecycleStatusSets.Terminal.Cast<LifecycleStatus?>().ToHashSet();
|
||||
|
||||
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
|
||||
|
||||
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
|
||||
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
|
||||
|
||||
/// <summary>
|
||||
/// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled.
|
||||
/// Week-only targets do not auto-schedule (same as <see cref="WorkOrderDerivedFields.ApplyAutoScheduleIfEligible"/>).
|
||||
/// </summary>
|
||||
public static bool ShouldAutoSchedule(
|
||||
LifecycleStatus? status,
|
||||
DateTime? scheduledDate,
|
||||
string? assignTo)
|
||||
string? assignTo,
|
||||
bool? scheduleWeekOnly = null)
|
||||
=> status == LifecycleStatus.Incomplete
|
||||
&& scheduleWeekOnly != true
|
||||
&& scheduledDate.HasValue
|
||||
&& !string.IsNullOrWhiteSpace(assignTo);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ namespace SeaHaven.Services.Helpers
|
|||
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Board read-model past-due check. Phase 1 compares UTC calendar dates;
|
||||
/// business/location timezone support is planned for a later phase.
|
||||
/// </summary>
|
||||
public static bool IsPastDue(
|
||||
DateTime? scheduledDate,
|
||||
LifecycleStatus? lifecycleStatus,
|
||||
|
|
@ -66,6 +70,10 @@ namespace SeaHaven.Services.Helpers
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps scheduled date to Mon–Fri board column. Phase 1 uses the stored
|
||||
/// date's day-of-week (UTC); timezone-aware grouping is planned for a later phase.
|
||||
/// </summary>
|
||||
public static string? GetDayGroup(DateTime? scheduledDate)
|
||||
{
|
||||
if (!scheduledDate.HasValue)
|
||||
|
|
|
|||
|
|
@ -16,5 +16,20 @@ namespace SeaHaven.Services.Helpers
|
|||
|
||||
public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
|
||||
=> $"week:{sourceWeekStart:yyyy-MM-dd}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns a validation error message when the week window is invalid; otherwise null.
|
||||
/// </summary>
|
||||
public static string? ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
|
||||
{
|
||||
if (weekEnd < weekStart)
|
||||
return "weekEnd must be on or after weekStart.";
|
||||
|
||||
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
|
||||
if (spanDays > 6)
|
||||
return "Week window cannot exceed 7 days.";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderFieldNames.LifecycleStatus,
|
||||
WorkOrderFieldNames.AssignTo,
|
||||
WorkOrderFieldNames.ScheduledDate,
|
||||
WorkOrderFieldNames.ScheduledStart,
|
||||
WorkOrderFieldNames.ScheduledEnd,
|
||||
WorkOrderFieldNames.TargetWeek,
|
||||
WorkOrderFieldNames.ScheduleWeekOnly,
|
||||
|
|
@ -68,6 +69,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderFieldNames.Status => wo.Status,
|
||||
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledStart => wo.ScheduledStart?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
|
||||
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
|
||||
|
|
@ -105,6 +107,9 @@ namespace SeaHaven.Services.Implementation
|
|||
case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
|
||||
wo.ScheduledDate = scheduledDate;
|
||||
return true;
|
||||
case WorkOrderFieldNames.ScheduledStart when TryParseDateTime(incomingValue, out var scheduledStart):
|
||||
wo.ScheduledStart = scheduledStart;
|
||||
return true;
|
||||
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
|
||||
wo.ScheduledEnd = scheduledEnd;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,35 +1,40 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Vendor service implementation
|
||||
/// Contains business logic, validation, and orchestration for Vendor operations
|
||||
/// </summary>
|
||||
public class VendorService : IVendorService
|
||||
{
|
||||
private static readonly string[] TerminalLegacyStatuses =
|
||||
{ "completed", "cancelled", "canceled" };
|
||||
|
||||
private readonly IVendorDataService _vendorDataService;
|
||||
private readonly ICreateVendorValidation _createValidator;
|
||||
private readonly IUpdateVendorValidation _updateValidator;
|
||||
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
|
||||
|
||||
public VendorService(
|
||||
IVendorDataService vendorDataService,
|
||||
ICreateVendorValidation createValidator,
|
||||
IUpdateVendorValidation updateValidator)
|
||||
IUpdateVendorValidation updateValidator,
|
||||
IWorkOrderVendorUpdateValidation workOrderUpdateValidator)
|
||||
{
|
||||
_vendorDataService = vendorDataService;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
_workOrderUpdateValidator = workOrderUpdateValidator;
|
||||
}
|
||||
|
||||
public async Task<VendorDTO?> GetVendorByIdAsync(int id)
|
||||
{
|
||||
var vendor = await _vendorDataService.GetByIdAsync(id);
|
||||
var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id);
|
||||
return vendor == null ? null : MapToDTO(vendor);
|
||||
}
|
||||
|
||||
|
|
@ -45,9 +50,25 @@ namespace SeaHaven.Services.Implementation
|
|||
return vendors.Select(MapToDTO);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null)
|
||||
public async Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null)
|
||||
{
|
||||
var (items, totalCount) = await _vendorDataService.GetPagedAsync(page, pageSize, search);
|
||||
var (items, totalCount) = await _vendorDataService.GetPagedAsync(
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
isActive,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
return new PagedResult<VendorDTO>
|
||||
{
|
||||
Items = items.Select(MapToDTO).ToList(),
|
||||
|
|
@ -59,31 +80,45 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId)
|
||||
{
|
||||
NormalizePhoneFields(dto);
|
||||
var validationResult = await _createValidator.ValidateAsync(dto);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new ValidationException(validationResult.Errors);
|
||||
}
|
||||
|
||||
var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId);
|
||||
|
||||
var vendor = new Vendor
|
||||
{
|
||||
CompanyName = dto.Name,
|
||||
ContactName = dto.ContactName,
|
||||
Email = dto.Email,
|
||||
Phone = dto.Phone,
|
||||
Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone),
|
||||
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
||||
PreferredContact = dto.PreferredContact,
|
||||
Address = dto.Address,
|
||||
City = dto.City,
|
||||
State = dto.State,
|
||||
Zip = dto.Zipcode,
|
||||
TradeSpecialties = dto.TradeSpecialties,
|
||||
GoogleMapsUrl = dto.GoogleMapsUrl,
|
||||
Notes = dto.Notes,
|
||||
IsActive = dto.IsActive,
|
||||
CompanyId = company?.Id,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
createdby = userId
|
||||
};
|
||||
|
||||
ApplyCompanyFields(vendor, company);
|
||||
|
||||
var created = await _vendorDataService.AddAsync(vendor);
|
||||
return MapToDTO(created);
|
||||
}
|
||||
|
||||
public async Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId)
|
||||
{
|
||||
NormalizePhoneFields(dto);
|
||||
var validationResult = await _updateValidator.ValidateAsync(dto);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
|
|
@ -94,13 +129,28 @@ namespace SeaHaven.Services.Implementation
|
|||
if (vendor == null)
|
||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||
|
||||
if (dto.IsActive.HasValue && !dto.IsActive.Value)
|
||||
await AssertNoOpenLinkedWorkOrdersAsync(id);
|
||||
|
||||
if (dto.Name != null) vendor.CompanyName = dto.Name;
|
||||
if (dto.ContactName != null) vendor.ContactName = dto.ContactName;
|
||||
if (dto.Email != null) vendor.Email = dto.Email;
|
||||
if (dto.Phone != null) vendor.Phone = dto.Phone;
|
||||
if (dto.Address != null) vendor.Address = dto.Address;
|
||||
if (dto.City != null) vendor.City = dto.City;
|
||||
if (dto.State != null) vendor.State = dto.State;
|
||||
if (dto.Zipcode != null) vendor.Zip = dto.Zipcode;
|
||||
if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||||
if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact;
|
||||
if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties;
|
||||
if (dto.Notes != null) vendor.Notes = dto.Notes;
|
||||
if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value;
|
||||
|
||||
if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto))
|
||||
{
|
||||
var company = await ResolveCompanyAsync(
|
||||
dto.CompanyId ?? vendor.CompanyId,
|
||||
dto.Name ?? vendor.CompanyName,
|
||||
dto,
|
||||
userId);
|
||||
vendor.CompanyId = company?.Id;
|
||||
ApplyCompanyFields(vendor, company);
|
||||
}
|
||||
|
||||
vendor.LastModificationTime = DateTime.UtcNow;
|
||||
if (int.TryParse(userId, out int userIdInt))
|
||||
|
|
@ -116,9 +166,12 @@ namespace SeaHaven.Services.Implementation
|
|||
if (vendor == null)
|
||||
throw new InvalidOperationException($"Vendor with ID {id} not found");
|
||||
|
||||
vendor.IsDeleted = true;
|
||||
vendor.DeleterUserId = userId;
|
||||
vendor.DeletionTime = DateTime.UtcNow;
|
||||
await AssertNoOpenLinkedWorkOrdersAsync(id);
|
||||
|
||||
vendor.IsActive = false;
|
||||
vendor.LastModificationTime = DateTime.UtcNow;
|
||||
if (int.TryParse(userId, out int userIdInt))
|
||||
vendor.LastModifierUserId = userIdInt;
|
||||
|
||||
await _vendorDataService.UpdateAsync(vendor);
|
||||
}
|
||||
|
|
@ -133,19 +186,338 @@ namespace SeaHaven.Services.Implementation
|
|||
return await _vendorDataService.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId)
|
||||
{
|
||||
if (!await _vendorDataService.ExistsAsync(vendorId))
|
||||
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
|
||||
|
||||
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
||||
var openWorkOrders = linked
|
||||
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
|
||||
.Select(MapToLinkedWorkOrderDTO)
|
||||
.ToList();
|
||||
|
||||
return new VendorDeactivationImpactDTO
|
||||
{
|
||||
VendorId = vendorId,
|
||||
CanDeactivate = openWorkOrders.Count == 0,
|
||||
OpenWorkOrders = openWorkOrders
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<VendorDTO> UpdateVendorFromWorkOrderAsync(
|
||||
int vendorId,
|
||||
WorkOrderVendorUpdateDTO dto,
|
||||
string userId)
|
||||
{
|
||||
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||||
var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new ValidationException(validationResult.Errors);
|
||||
}
|
||||
|
||||
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
|
||||
if (vendor == null)
|
||||
throw new InvalidOperationException($"Vendor with ID {vendorId} not found");
|
||||
|
||||
var isAssigned = await _vendorDataService.IsVendorAssignedToWorkOrderAsync(vendorId, dto.WorkOrderId);
|
||||
if (!isAssigned)
|
||||
throw new InvalidOperationException(
|
||||
$"Vendor {vendorId} is not actively linked to work order {dto.WorkOrderId}.");
|
||||
|
||||
var changes = new List<VendorAuditLog>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
void RecordChange(string fieldName, string? oldValue, string? newValue)
|
||||
{
|
||||
if (!string.Equals(oldValue, newValue, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
WorkOrderId = dto.WorkOrderId,
|
||||
FieldName = fieldName,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Actor = userId,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.ContactName != null)
|
||||
{
|
||||
RecordChange(nameof(Vendor.ContactName), vendor.ContactName, dto.ContactName);
|
||||
vendor.ContactName = dto.ContactName;
|
||||
}
|
||||
|
||||
if (dto.PreferredContact != null)
|
||||
{
|
||||
RecordChange(nameof(Vendor.PreferredContact), vendor.PreferredContact, dto.PreferredContact);
|
||||
vendor.PreferredContact = dto.PreferredContact;
|
||||
}
|
||||
|
||||
if (dto.Phone != null)
|
||||
{
|
||||
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||||
RecordChange(nameof(Vendor.Phone), vendor.Phone, normalized);
|
||||
vendor.Phone = normalized;
|
||||
}
|
||||
|
||||
if (dto.Email != null)
|
||||
{
|
||||
RecordChange(nameof(Vendor.Email), vendor.Email, dto.Email);
|
||||
vendor.Email = dto.Email;
|
||||
}
|
||||
|
||||
if (dto.Notes != null)
|
||||
{
|
||||
RecordChange(nameof(Vendor.Notes), vendor.Notes, dto.Notes);
|
||||
vendor.Notes = dto.Notes;
|
||||
}
|
||||
|
||||
vendor.LastModificationTime = now;
|
||||
if (int.TryParse(userId, out int userIdInt))
|
||||
vendor.LastModifierUserId = userIdInt;
|
||||
|
||||
await _vendorDataService.UpdateWithAuditLogsAsync(vendor, changes);
|
||||
|
||||
return MapToDTO(vendor);
|
||||
}
|
||||
|
||||
private async Task<VendorCompany?> ResolveCompanyAsync(
|
||||
int? requestedCompanyId,
|
||||
string? companyName,
|
||||
object dto,
|
||||
string userId)
|
||||
{
|
||||
if (requestedCompanyId.HasValue)
|
||||
{
|
||||
var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value);
|
||||
if (byId != null)
|
||||
{
|
||||
ApplyCompanyUpdates(byId, companyName, dto, userId);
|
||||
await _vendorDataService.UpdateCompanyAsync(byId);
|
||||
return byId;
|
||||
}
|
||||
|
||||
throw new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure(
|
||||
nameof(CreateVendorDTO.CompanyId),
|
||||
$"No vendor company exists with ID {requestedCompanyId.Value}.")
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
return null;
|
||||
|
||||
var normalizedName = companyName.Trim().ToLowerInvariant();
|
||||
var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName);
|
||||
if (existing != null)
|
||||
{
|
||||
ApplyCompanyUpdates(existing, companyName, dto, userId);
|
||||
await _vendorDataService.UpdateCompanyAsync(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
string? companyPhone = null;
|
||||
string? email = null;
|
||||
string? address = null;
|
||||
string? city = null;
|
||||
string? state = null;
|
||||
string? zip = null;
|
||||
string? mapsUrl = null;
|
||||
|
||||
if (dto is CreateVendorDTO create)
|
||||
{
|
||||
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(create.CompanyPhone);
|
||||
email = create.Email;
|
||||
address = create.Address;
|
||||
city = create.City;
|
||||
state = create.State;
|
||||
zip = create.Zipcode;
|
||||
mapsUrl = create.GoogleMapsUrl;
|
||||
}
|
||||
else if (dto is UpdateVendorDTO update)
|
||||
{
|
||||
companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(update.CompanyPhone);
|
||||
email = update.Email;
|
||||
address = update.Address;
|
||||
city = update.City;
|
||||
state = update.State;
|
||||
zip = update.Zipcode;
|
||||
mapsUrl = update.GoogleMapsUrl;
|
||||
}
|
||||
|
||||
var company = new VendorCompany
|
||||
{
|
||||
Name = companyName.Trim(),
|
||||
NormalizedName = normalizedName,
|
||||
CompanyPhone = companyPhone,
|
||||
Email = email,
|
||||
Address = address,
|
||||
City = city,
|
||||
State = state,
|
||||
Zip = zip,
|
||||
GoogleMapsUrl = mapsUrl,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
createdby = userId
|
||||
};
|
||||
|
||||
return await _vendorDataService.AddCompanyAsync(company);
|
||||
}
|
||||
|
||||
private static void ApplyCompanyFields(Vendor vendor, VendorCompany? company)
|
||||
{
|
||||
if (company == null)
|
||||
return;
|
||||
|
||||
if (company.CompanyPhone != null) vendor.CompanyPhone = company.CompanyPhone;
|
||||
if (company.Address != null) vendor.Address = company.Address;
|
||||
if (company.City != null) vendor.City = company.City;
|
||||
if (company.State != null) vendor.State = company.State;
|
||||
if (company.Zip != null) vendor.Zip = company.Zip;
|
||||
if (company.GoogleMapsUrl != null) vendor.GoogleMapsUrl = company.GoogleMapsUrl;
|
||||
}
|
||||
|
||||
private static bool HasCompanyFieldUpdate(UpdateVendorDTO dto) =>
|
||||
dto.CompanyPhone != null ||
|
||||
dto.Address != null ||
|
||||
dto.City != null ||
|
||||
dto.State != null ||
|
||||
dto.Zipcode != null ||
|
||||
dto.GoogleMapsUrl != null;
|
||||
|
||||
private static void ApplyCompanyUpdates(
|
||||
VendorCompany company,
|
||||
string? companyName,
|
||||
object dto,
|
||||
string userId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(companyName))
|
||||
{
|
||||
company.Name = companyName.Trim();
|
||||
company.NormalizedName = company.Name.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (dto is CreateVendorDTO create)
|
||||
{
|
||||
if (create.CompanyPhone != null) company.CompanyPhone = create.CompanyPhone;
|
||||
if (create.Address != null) company.Address = create.Address;
|
||||
if (create.City != null) company.City = create.City;
|
||||
if (create.State != null) company.State = create.State;
|
||||
if (create.Zipcode != null) company.Zip = create.Zipcode;
|
||||
if (create.GoogleMapsUrl != null) company.GoogleMapsUrl = create.GoogleMapsUrl;
|
||||
}
|
||||
else if (dto is UpdateVendorDTO update)
|
||||
{
|
||||
if (update.CompanyPhone != null) company.CompanyPhone = update.CompanyPhone;
|
||||
if (update.Address != null) company.Address = update.Address;
|
||||
if (update.City != null) company.City = update.City;
|
||||
if (update.State != null) company.State = update.State;
|
||||
if (update.Zipcode != null) company.Zip = update.Zipcode;
|
||||
if (update.GoogleMapsUrl != null) company.GoogleMapsUrl = update.GoogleMapsUrl;
|
||||
}
|
||||
|
||||
company.LastModificationTime = DateTime.UtcNow;
|
||||
if (int.TryParse(userId, out int userIdInt))
|
||||
company.LastModifierUserId = userIdInt;
|
||||
}
|
||||
|
||||
private static void NormalizePhoneFields(CreateVendorDTO dto)
|
||||
{
|
||||
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||||
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
|
||||
}
|
||||
|
||||
private static void NormalizePhoneFields(UpdateVendorDTO dto)
|
||||
{
|
||||
dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone);
|
||||
dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone);
|
||||
}
|
||||
|
||||
private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId)
|
||||
{
|
||||
var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId);
|
||||
var openWorkOrders = linked
|
||||
.Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status))
|
||||
.Select(MapToLinkedWorkOrderDTO)
|
||||
.ToList();
|
||||
|
||||
if (openWorkOrders.Count > 0)
|
||||
{
|
||||
throw new VendorDeactivationBlockedException(
|
||||
"Vendor cannot be deactivated while linked work orders are open or scheduled.",
|
||||
openWorkOrders);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus)
|
||||
{
|
||||
if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled)
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(legacyStatus) &&
|
||||
TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant()))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static LinkedWorkOrderDTO MapToLinkedWorkOrderDTO(LinkedWorkOrderInfo info) => new()
|
||||
{
|
||||
WorkOrderId = info.WorkOrderId,
|
||||
WorkOrderNumber = info.WorkOrderNumber,
|
||||
WorkOrderTitle = info.WorkOrderTitle,
|
||||
Status = info.Status,
|
||||
ScheduledDate = info.ScheduledDate,
|
||||
DispatchId = info.DispatchId
|
||||
};
|
||||
|
||||
private VendorDTO MapToDTO(Vendor vendor)
|
||||
{
|
||||
return new VendorDTO
|
||||
{
|
||||
Id = vendor.Id,
|
||||
Name = vendor.CompanyName,
|
||||
CompanyId = vendor.CompanyId,
|
||||
ContactName = vendor.ContactName,
|
||||
Email = vendor.Email,
|
||||
Phone = vendor.Phone,
|
||||
CompanyPhone = vendor.CompanyPhone,
|
||||
PreferredContact = vendor.PreferredContact,
|
||||
Address = vendor.Address,
|
||||
City = vendor.City,
|
||||
State = vendor.State,
|
||||
Zipcode = vendor.Zip
|
||||
Zipcode = vendor.Zip,
|
||||
TradeSpecialties = vendor.TradeSpecialties,
|
||||
GoogleMapsUrl = vendor.GoogleMapsUrl,
|
||||
Notes = vendor.Notes,
|
||||
IsActive = vendor.IsActive,
|
||||
TotalJobs = vendor.Dispatches == null
|
||||
? vendor.TotalJobs
|
||||
: CountDistinctWorkOrders(vendor)
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountDistinctWorkOrders(Vendor vendor)
|
||||
{
|
||||
if (vendor.Dispatches == null)
|
||||
return 0;
|
||||
|
||||
return vendor.Dispatches
|
||||
.SelectMany(dispatch =>
|
||||
{
|
||||
var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId)
|
||||
?? Enumerable.Empty<int>();
|
||||
return dispatch.WorkOrderId.HasValue
|
||||
? linked.Append(dispatch.WorkOrderId.Value)
|
||||
: linked;
|
||||
})
|
||||
.Distinct()
|
||||
.Count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,17 +39,17 @@ namespace SeaHaven.Services.Implementation
|
|||
newCarriedOver.ToString(),
|
||||
correlationId: correlationId);
|
||||
|
||||
public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
|
||||
=> StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
|
||||
public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
|
||||
|
||||
public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
|
||||
=> StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
|
||||
public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
|
||||
|
||||
public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId)
|
||||
=> StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
|
||||
public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
|
||||
|
||||
public void StageCreated(int workOrderId, string? actorId, string? woNumber)
|
||||
=> StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
|
||||
public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
|
||||
|
||||
public async Task LogAsync(
|
||||
int workOrderId,
|
||||
|
|
@ -76,7 +76,7 @@ namespace SeaHaven.Services.Implementation
|
|||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private void StageAudit(
|
||||
private async Task StageAuditAsync(
|
||||
int workOrderId,
|
||||
AuditActionType action,
|
||||
string fieldName,
|
||||
|
|
@ -87,7 +87,13 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher,
|
||||
fieldName, oldValue, newValue, actorId, dispatchId);
|
||||
StageFieldLock(workOrderId, fieldName, actorId);
|
||||
|
||||
if (action == AuditActionType.FieldChanged ||
|
||||
action == AuditActionType.StatusChanged ||
|
||||
action == AuditActionType.AssignmentChanged)
|
||||
{
|
||||
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAuditEntry(
|
||||
|
|
@ -117,28 +123,5 @@ namespace SeaHaven.Services.Implementation
|
|||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private void StageFieldLock(int workOrderId, string fieldName, string? userId)
|
||||
{
|
||||
if (_context.WorkOrderFieldLocks.Local.Any(l =>
|
||||
l.WorkOrderId == workOrderId &&
|
||||
string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
|
||||
var existsInDb = _context.WorkOrderFieldLocks.Any(l =>
|
||||
l.WorkOrderId == workOrderId &&
|
||||
l.FieldName == fieldName);
|
||||
|
||||
if (existsInDb)
|
||||
return;
|
||||
|
||||
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
FieldName = fieldName,
|
||||
LockedAt = DateTime.UtcNow,
|
||||
LockedByUserId = userId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ namespace SeaHaven.Services.Implementation
|
|||
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||
workOrder.LegacyStatus = workOrder.Status;
|
||||
|
||||
_auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
||||
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var row = await _boardService.GetBoardRowAsync(workOrderId);
|
||||
|
|
|
|||
|
|
@ -173,29 +173,48 @@ namespace SeaHaven.Services.Implementation
|
|||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes));
|
||||
}
|
||||
|
||||
_context.workOrders.Add(workOrder);
|
||||
await _context.SaveChangesAsync();
|
||||
// Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and
|
||||
// audit/lock/contact rows. A transaction keeps the create atomic across both saves.
|
||||
await using var transaction = _context.Database.IsRelational()
|
||||
? await _context.Database.BeginTransactionAsync()
|
||||
: null;
|
||||
|
||||
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
||||
try
|
||||
{
|
||||
workOrder.PrimaryDispatchId = dispatch.Id;
|
||||
dispatch.WorkOrderId = workOrder.Id;
|
||||
}
|
||||
_context.workOrders.Add(workOrder);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
|
||||
StageChanges(workOrder.Id, changes, actorId, dispatch);
|
||||
|
||||
if (request.PocContactId.HasValue)
|
||||
{
|
||||
_context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
||||
{
|
||||
WorkorderId = workOrder.Id,
|
||||
ContactId = request.PocContactId,
|
||||
Notes = pocNotes
|
||||
});
|
||||
}
|
||||
workOrder.PrimaryDispatchId = dispatch.Id;
|
||||
dispatch.WorkOrderId = workOrder.Id;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber);
|
||||
await StageChangesAsync(workOrder.Id, changes, actorId, dispatch);
|
||||
|
||||
if (request.PocContactId.HasValue)
|
||||
{
|
||||
_context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||
{
|
||||
WorkorderId = workOrder.Id,
|
||||
ContactId = request.PocContactId,
|
||||
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||
|
|
@ -278,7 +297,7 @@ namespace SeaHaven.Services.Implementation
|
|||
return normalized;
|
||||
}
|
||||
|
||||
private void StageChanges(
|
||||
private async Task StageChangesAsync(
|
||||
int workOrderId,
|
||||
IEnumerable<WorkOrderBoardFieldMutations.BoardFieldChange> changes,
|
||||
string? actorId,
|
||||
|
|
@ -290,13 +309,13 @@ namespace SeaHaven.Services.Implementation
|
|||
switch (change.Action)
|
||||
{
|
||||
case AuditActionType.StatusChanged:
|
||||
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
case AuditActionType.AssignmentChanged:
|
||||
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
default:
|
||||
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ namespace SeaHaven.Services.Implementation
|
|||
var weekStart = query.WeekStart;
|
||||
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
|
||||
|
||||
ValidateWeekWindow(weekStart, weekEnd);
|
||||
var validationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, weekEnd);
|
||||
if (validationError != null)
|
||||
throw new ArgumentException(validationError);
|
||||
|
||||
var dataQuery = new WorkOrderBoardQuery(
|
||||
weekStart,
|
||||
|
|
@ -68,18 +70,10 @@ namespace SeaHaven.Services.Implementation
|
|||
return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static void ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
|
||||
{
|
||||
if (weekEnd < weekStart)
|
||||
throw new ArgumentException("weekEnd must be on or after weekStart.");
|
||||
|
||||
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
|
||||
if (spanDays > 6)
|
||||
throw new ArgumentException("Week window cannot exceed 7 days.");
|
||||
}
|
||||
|
||||
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
|
||||
{
|
||||
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
|
||||
// Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime.
|
||||
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
|
||||
var apptEnd = row.ScheduledEnd;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using Data.SeaHavenIndustries;
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
|
@ -31,6 +32,13 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardPatchRequestDto request,
|
||||
string? actorId)
|
||||
{
|
||||
// Intermediate SaveChanges (e.g. new dispatch identity) must stay atomic with the final patch save.
|
||||
await using var transaction = _context.Database.IsRelational()
|
||||
? await _context.Database.BeginTransactionAsync()
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Field))
|
||||
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
|
||||
|
||||
|
|
@ -95,28 +103,33 @@ namespace SeaHaven.Services.Implementation
|
|||
if (changes.Count == 0 && !_context.ChangeTracker.HasChanges())
|
||||
{
|
||||
var unchanged = await LoadBoardRowAsync(workOrderId);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
var persisted = await LoadBoardRowAsync(workOrderId);
|
||||
return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||
switch (change.Action)
|
||||
{
|
||||
case AuditActionType.StatusChanged:
|
||||
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
case AuditActionType.AssignmentChanged:
|
||||
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
default:
|
||||
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId);
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -127,13 +140,29 @@ namespace SeaHaven.Services.Implementation
|
|||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
var currentState = await LoadBoardRowAsync(workOrderId);
|
||||
throw new WorkOrderBoardConcurrencyException(currentState);
|
||||
}
|
||||
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
|
||||
var row = await LoadBoardRowAsync(workOrderId);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
catch (WorkOrderBoardConcurrencyException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<FieldChange>> ApplyFieldMutationAsync(
|
||||
|
|
@ -154,9 +183,9 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
|
||||
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
|
||||
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { ApplyVendorId(dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
|
||||
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
|
||||
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
|
||||
|
|
@ -192,6 +221,8 @@ namespace SeaHaven.Services.Implementation
|
|||
if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0)
|
||||
throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId.");
|
||||
|
||||
await EnsureVendorExistsAsync(vendorIdForCreate.Value);
|
||||
|
||||
var created = new Dispatch
|
||||
{
|
||||
WorkOrderId = workOrder.Id,
|
||||
|
|
@ -205,6 +236,13 @@ namespace SeaHaven.Services.Implementation
|
|||
return created;
|
||||
}
|
||||
|
||||
private async Task EnsureVendorExistsAsync(int vendorId)
|
||||
{
|
||||
var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true);
|
||||
if (!vendorExists)
|
||||
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
||||
}
|
||||
|
||||
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
||||
|
|
@ -477,18 +515,20 @@ namespace SeaHaven.Services.Implementation
|
|||
return FieldChange.ForField(auditField, old, parsed.ToString());
|
||||
}
|
||||
|
||||
private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField)
|
||||
private async Task<FieldChange> ApplyVendorIdAsync(Dispatch dispatch, string? value, string auditField)
|
||||
{
|
||||
var vendorId = ParseVendorIdHint(value);
|
||||
if (!vendorId.HasValue)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer.");
|
||||
|
||||
await EnsureVendorExistsAsync(vendorId.Value);
|
||||
|
||||
var old = dispatch.VendorId.ToString();
|
||||
if (dispatch.VendorId == vendorId.Value)
|
||||
return FieldChange.Unchanged(auditField, dispatch.Id);
|
||||
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
|
||||
|
||||
dispatch.VendorId = vendorId.Value;
|
||||
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id);
|
||||
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch));
|
||||
}
|
||||
|
||||
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
|
||||
|
|
@ -504,13 +544,15 @@ namespace SeaHaven.Services.Implementation
|
|||
var old = FormatDate(dispatch.ScheduledDate);
|
||||
var newVal = FormatDate(parsed);
|
||||
if (old == newVal)
|
||||
return FieldChange.Unchanged(auditField, dispatch.Id);
|
||||
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
|
||||
|
||||
dispatch.ScheduledDate = parsed;
|
||||
return FieldChange.ForField(auditField, old, newVal, dispatch.Id);
|
||||
return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch));
|
||||
}
|
||||
|
||||
private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField)
|
||||
// apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so
|
||||
// SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock).
|
||||
private static List<FieldChange> ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
|
||||
{
|
||||
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
|
||||
|
|
@ -518,19 +560,24 @@ namespace SeaHaven.Services.Implementation
|
|||
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
|
||||
var oldStart = FormatDateTime(workOrder.ScheduledStart);
|
||||
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||
var dispatchId = ResolveDispatchIdForAudit(dispatch);
|
||||
|
||||
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
|
||||
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
|
||||
|
||||
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
||||
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||
var oldCombined = FormatApptRange(oldStart, oldEnd);
|
||||
var newCombined = FormatApptRange(newStart, newEnd);
|
||||
var changes = new List<FieldChange>();
|
||||
|
||||
if (oldCombined == newCombined)
|
||||
return FieldChange.Unchanged(auditField, dispatch.Id);
|
||||
if (oldStart != newStart)
|
||||
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId));
|
||||
if (oldEnd != newEnd)
|
||||
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId));
|
||||
|
||||
return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id);
|
||||
if (changes.Count == 0)
|
||||
changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId));
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
|
||||
|
|
@ -570,7 +617,11 @@ namespace SeaHaven.Services.Implementation
|
|||
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<FieldChange>();
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo))
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.AssignTo,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
return changes;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
|
|
@ -611,6 +662,9 @@ namespace SeaHaven.Services.Implementation
|
|||
return vendorId;
|
||||
}
|
||||
|
||||
private static int? ResolveDispatchIdForAudit(Dispatch dispatch)
|
||||
=> dispatch.Id > 0 ? dispatch.Id : null;
|
||||
|
||||
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
|
||||
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
|
||||
private static string? FormatApptRange(string? start, string? end)
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
workOrder.DocStatus = DocStatus.Yes;
|
||||
|
||||
_auditService.StageFieldChanged(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId);
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId);
|
||||
if (oldDocStatus != DocStatus.Yes.ToString())
|
||||
_auditService.StageFieldChanged(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,21 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<VendorDTO?> GetVendorByIdAsync(int id);
|
||||
Task<VendorDTO?> GetVendorByIdWithDetailsAsync(int id);
|
||||
Task<IEnumerable<VendorDTO>> GetAllVendorsAsync();
|
||||
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(int page, int pageSize, string? search = null);
|
||||
Task<PagedResult<VendorDTO>> GetVendorsPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search = null,
|
||||
bool? isActive = true,
|
||||
IReadOnlyCollection<string>? companies = null,
|
||||
IReadOnlyCollection<string>? trades = null,
|
||||
IReadOnlyCollection<string>? locations = null,
|
||||
IReadOnlyCollection<string>? jobBuckets = null);
|
||||
Task<VendorDTO> CreateVendorAsync(CreateVendorDTO dto, string userId);
|
||||
Task<VendorDTO> UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId);
|
||||
Task DeleteVendorAsync(int id, string userId);
|
||||
Task<bool> VendorExistsAsync(int id);
|
||||
Task<int> GetTotalVendorCountAsync();
|
||||
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);
|
||||
Task<VendorDTO> UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
|
||||
Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId);
|
||||
|
||||
void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
|
||||
void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
|
||||
void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId);
|
||||
void StageCreated(int workOrderId, string? actorId, string? woNumber);
|
||||
Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
|
||||
Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
|
||||
Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
|
||||
Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using FluentValidation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
namespace SeaHaven.Services.Validation
|
||||
{
|
||||
|
|
@ -19,10 +20,29 @@ namespace SeaHaven.Services.Validation
|
|||
.EmailAddress().WithMessage("Invalid email address")
|
||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||
|
||||
RuleFor(x => x.ContactName)
|
||||
.NotEmpty().WithMessage("Technician name is required")
|
||||
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters");
|
||||
|
||||
RuleFor(x => x.PreferredContact)
|
||||
.Must(VendorValidationRules.BeValidPreferredContact)
|
||||
.WithMessage("PreferredContact must be one of: Phone, Email, Text")
|
||||
.When(x => !string.IsNullOrEmpty(x.PreferredContact));
|
||||
|
||||
RuleFor(x => x.TradeSpecialties)
|
||||
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
|
||||
|
||||
RuleFor(x => x.Phone)
|
||||
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
||||
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
|
||||
.WithMessage("Phone must be in North American format: (XXX) XXX-XXXX")
|
||||
.When(x => !string.IsNullOrEmpty(x.Phone));
|
||||
|
||||
RuleFor(x => x.CompanyPhone)
|
||||
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
|
||||
.WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX")
|
||||
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
|
||||
|
||||
RuleFor(x => x.Address)
|
||||
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Address));
|
||||
|
|
@ -38,6 +58,14 @@ namespace SeaHaven.Services.Validation
|
|||
RuleFor(x => x.Zipcode)
|
||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||
|
||||
RuleFor(x => x.GoogleMapsUrl)
|
||||
.Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL")
|
||||
.When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl));
|
||||
|
||||
RuleFor(x => x.Notes)
|
||||
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Notes));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,10 +85,30 @@ namespace SeaHaven.Services.Validation
|
|||
.EmailAddress().WithMessage("Invalid email address")
|
||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||
|
||||
RuleFor(x => x.ContactName)
|
||||
.NotEmpty().WithMessage("Technician name is required")
|
||||
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
|
||||
.When(x => x.ContactName != null);
|
||||
|
||||
RuleFor(x => x.PreferredContact)
|
||||
.Must(VendorValidationRules.BeValidPreferredContact)
|
||||
.WithMessage("PreferredContact must be one of: Phone, Email, Text")
|
||||
.When(x => !string.IsNullOrEmpty(x.PreferredContact));
|
||||
|
||||
RuleFor(x => x.TradeSpecialties)
|
||||
.MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.TradeSpecialties));
|
||||
|
||||
RuleFor(x => x.Phone)
|
||||
.MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters")
|
||||
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
|
||||
.WithMessage("Phone must be in North American format: (XXX) XXX-XXXX")
|
||||
.When(x => !string.IsNullOrEmpty(x.Phone));
|
||||
|
||||
RuleFor(x => x.CompanyPhone)
|
||||
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
|
||||
.WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX")
|
||||
.When(x => !string.IsNullOrEmpty(x.CompanyPhone));
|
||||
|
||||
RuleFor(x => x.Address)
|
||||
.MaximumLength(500).WithMessage("Address cannot exceed 500 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Address));
|
||||
|
|
@ -76,6 +124,64 @@ namespace SeaHaven.Services.Validation
|
|||
RuleFor(x => x.Zipcode)
|
||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||
|
||||
RuleFor(x => x.GoogleMapsUrl)
|
||||
.Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL")
|
||||
.When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl));
|
||||
|
||||
RuleFor(x => x.Notes)
|
||||
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Notes));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public interface IWorkOrderVendorUpdateValidation : IValidator<WorkOrderVendorUpdateDTO>
|
||||
{
|
||||
}
|
||||
|
||||
public class WorkOrderVendorUpdateValidation : AbstractValidator<WorkOrderVendorUpdateDTO>, IWorkOrderVendorUpdateValidation
|
||||
{
|
||||
public WorkOrderVendorUpdateValidation()
|
||||
{
|
||||
RuleFor(x => x.WorkOrderId)
|
||||
.GreaterThan(0).WithMessage("A valid work order id is required");
|
||||
|
||||
RuleFor(x => x.PreferredContact)
|
||||
.Must(VendorValidationRules.BeValidPreferredContact)
|
||||
.WithMessage("PreferredContact must be one of: Phone, Email, Text")
|
||||
.When(x => !string.IsNullOrEmpty(x.PreferredContact));
|
||||
|
||||
RuleFor(x => x.Phone)
|
||||
.Must(VendorValidationRules.BeValidNorthAmericanPhone)
|
||||
.WithMessage("Phone must be in North American format: (XXX) XXX-XXXX")
|
||||
.When(x => !string.IsNullOrEmpty(x.Phone));
|
||||
|
||||
RuleFor(x => x.Email)
|
||||
.EmailAddress().WithMessage("Invalid email address")
|
||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||
|
||||
RuleFor(x => x.Notes)
|
||||
.MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Notes));
|
||||
|
||||
RuleFor(x => x.ContactName)
|
||||
.MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.ContactName));
|
||||
}
|
||||
}
|
||||
|
||||
internal static class VendorValidationRules
|
||||
{
|
||||
private static readonly string[] AllowedPreferredContacts = { "Phone", "Email", "Text" };
|
||||
|
||||
internal static bool BeValidHttpsUrl(string? value) =>
|
||||
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
|
||||
|
||||
internal static bool BeValidPreferredContact(string? value) =>
|
||||
AllowedPreferredContacts.Contains(value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal static bool BeValidNorthAmericanPhone(string? value) =>
|
||||
value is not null && VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
180
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal file
180
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
using System.Reflection;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Migrations;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class Phase4SearchIndexesSqlServerTests
|
||||
{
|
||||
private const string LocalDbMaster =
|
||||
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
|
||||
|
||||
[Fact]
|
||||
public void WorkOrder_IndexedStringColumns_HaveBoundedMaxLengths()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer(LocalDbMaster)
|
||||
.Options;
|
||||
|
||||
using var context = new ApplicationDbContext(options);
|
||||
var entity = context.Model.FindEntityType(typeof(WorkOrder));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
Assert.Equal(32, entity.FindProperty(nameof(WorkOrder.SiteCode))!.GetMaxLength());
|
||||
Assert.Equal(11, entity.FindProperty(nameof(WorkOrder.InternalWONumber))!.GetMaxLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Phase4_SearchIndexes_ApplyOnSqlServer_WhenLocalDbAvailable()
|
||||
{
|
||||
if (!await IsLocalDbAvailableAsync())
|
||||
return;
|
||||
|
||||
var dbName = $"Phase4SearchIndexes_{Guid.NewGuid():N}";
|
||||
var connectionString =
|
||||
$@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True";
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using (var createTable = connection.CreateCommand())
|
||||
{
|
||||
createTable.CommandText =
|
||||
"""
|
||||
CREATE TABLE workOrders (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||
SiteCode nvarchar(max) NULL,
|
||||
InternalWONumber nvarchar(max) NULL,
|
||||
LifecycleStatus int NULL,
|
||||
ScheduledDate datetime2 NULL,
|
||||
istemplate bit NULL
|
||||
);
|
||||
""";
|
||||
await createTable.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await ApplyMigrationUpAsync(context, new Phase4_SearchIndexes());
|
||||
|
||||
await using (var assertCmd = connection.CreateCommand())
|
||||
{
|
||||
assertCmd.CommandText =
|
||||
"""
|
||||
SELECT c.name, t.name AS type_name, c.max_length
|
||||
FROM sys.columns c
|
||||
INNER JOIN sys.types t ON c.user_type_id = t.user_type_id
|
||||
WHERE c.object_id = OBJECT_ID(N'workOrders')
|
||||
AND c.name IN (N'SiteCode', N'InternalWONumber')
|
||||
ORDER BY c.name;
|
||||
""";
|
||||
|
||||
await using var reader = await assertCmd.ExecuteReaderAsync();
|
||||
var columns = new Dictionary<string, (string Type, short MaxLength)>(StringComparer.OrdinalIgnoreCase);
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
columns[reader.GetString(0)] = (reader.GetString(1), reader.GetInt16(2));
|
||||
}
|
||||
|
||||
Assert.Equal("nvarchar", columns["InternalWONumber"].Type);
|
||||
Assert.Equal(22, columns["InternalWONumber"].MaxLength); // nvarchar(11) => 22 bytes
|
||||
Assert.Equal("nvarchar", columns["SiteCode"].Type);
|
||||
Assert.Equal(64, columns["SiteCode"].MaxLength); // nvarchar(32) => 64 bytes
|
||||
}
|
||||
|
||||
await using (var indexCmd = connection.CreateCommand())
|
||||
{
|
||||
indexCmd.CommandText =
|
||||
"""
|
||||
SELECT name
|
||||
FROM sys.indexes
|
||||
WHERE object_id = OBJECT_ID(N'workOrders')
|
||||
AND name IN (
|
||||
N'IX_workOrders_SiteCode',
|
||||
N'IX_workOrders_InternalWONumber',
|
||||
N'IX_workOrders_LifecycleStatus_ScheduledDate'
|
||||
);
|
||||
""";
|
||||
|
||||
await using var reader = await indexCmd.ExecuteReaderAsync();
|
||||
var indexes = new List<string>();
|
||||
while (await reader.ReadAsync())
|
||||
indexes.Add(reader.GetString(0));
|
||||
|
||||
Assert.Contains("IX_workOrders_SiteCode", indexes);
|
||||
Assert.Contains("IX_workOrders_InternalWONumber", indexes);
|
||||
Assert.Contains("IX_workOrders_LifecycleStatus_ScheduledDate", indexes);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DropDatabaseAsync(dbName);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
|
||||
{
|
||||
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Migration.Up not found.");
|
||||
up.Invoke(migration, [builder]);
|
||||
|
||||
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||
var commands = sqlGenerator.Generate(builder.Operations, model: null);
|
||||
foreach (var command in commands)
|
||||
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||
}
|
||||
|
||||
private static async Task<bool> IsLocalDbAvailableAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(LocalDbMaster);
|
||||
await connection.OpenAsync();
|
||||
return true;
|
||||
}
|
||||
catch (SqlException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DropDatabaseAsync(string dbName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(LocalDbMaster);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"""
|
||||
IF DB_ID(N'{dbName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{dbName}];
|
||||
END
|
||||
""";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup for ephemeral LocalDB databases.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,59 @@ public class WorkOrderBoardCreateRelationalTests
|
|||
Assert.DoesNotContain(locks, l => l.WorkOrderId == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenContactPersistenceFails_RollsBackWorkOrder()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new SqliteBoardTestDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
const string actorId = "actor-1";
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = actorId,
|
||||
UserName = "actor-1",
|
||||
NormalizedUserName = "ACTOR-1",
|
||||
Email = "actor@test.local",
|
||||
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var service = new WorkOrderBoardCreateService(
|
||||
context,
|
||||
boardData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Description = "Should roll back",
|
||||
PocContactId = 999_999
|
||||
}, actorId));
|
||||
|
||||
// Fresh context on the same connection so we observe committed state only.
|
||||
await using var verify = new SqliteBoardTestDbContext(options);
|
||||
Assert.Empty(await verify.workOrders.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.WorkOrderFieldLocks.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.WorkOrderContacts.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
|
||||
}
|
||||
|
||||
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@ public class WorkOrderBoardMutationRulesTests
|
|||
"dispatcher-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_FalseWhenScheduleWeekOnly()
|
||||
{
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Incomplete,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1",
|
||||
scheduleWeekOnly: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReschedule_WhenPreviousDateExistsAndChanges()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -503,4 +503,296 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
|
||||
Assert.Equal("#8B5CF6", result.FlagColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
SiteCode = "OLD",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK5",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
var updated = await context.workOrders.FindAsync(1);
|
||||
var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK6",
|
||||
WorkOrderVersion = ToVersion(updated!)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("BK6", secondResult.SiteCode);
|
||||
Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync());
|
||||
Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_NotFound_ThrowsNotFoundCode()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK5",
|
||||
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
ScheduleWeekOnly = true,
|
||||
TargetWeek = new DateOnly(2026, 6, 22),
|
||||
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.AssignTo,
|
||||
Value = "user-a",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
||||
Assert.Equal("user-a", result.DispatcherId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ApptTime_UpdatesScheduledStart()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptTime,
|
||||
Value = "09:00-10:00",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Contains("09:00", result.ApptTime);
|
||||
var reloaded = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded!.ScheduledStart);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptTime,
|
||||
Value = "-30",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1");
|
||||
|
||||
var reloaded = await context.workOrders.FindAsync(1);
|
||||
Assert.NotNull(reloaded!.ScheduledStart);
|
||||
Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd);
|
||||
Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptTime,
|
||||
Value = "09:00-10:00",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1");
|
||||
|
||||
var lockNames = await context.WorkOrderFieldLocks
|
||||
.Where(l => l.WorkOrderId == 1)
|
||||
.Select(l => l.FieldName)
|
||||
.ToListAsync();
|
||||
Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames);
|
||||
Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames);
|
||||
Assert.DoesNotContain("ApptTime", lockNames);
|
||||
|
||||
var locks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, locks);
|
||||
var policy = new SyncFieldMergePolicy(locks);
|
||||
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
{
|
||||
WorkOrder = reloaded,
|
||||
FieldLocks = locks,
|
||||
Audit = audit
|
||||
};
|
||||
|
||||
var appliedStart = await policy.TryApplyAsync(
|
||||
syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00");
|
||||
var appliedEnd = await policy.TryApplyAsync(
|
||||
syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00");
|
||||
|
||||
Assert.False(appliedStart);
|
||||
Assert.False(appliedEnd);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded.ScheduledStart);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptDate,
|
||||
Value = "2026-06-26",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("DispatchVersionRequired", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_VendorIdNotFound_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "999",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("VendorNotFound", ex.Code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
# ADR: Work Orders Board API (Fase 1 read-only)
|
||||
|
||||
**Status:** Proposed — spikes G0, G1, G3 concluídas
|
||||
**Data:** 2026-06-19
|
||||
**Deciders:** Backend + FE (aprovação Fase 1 pending)
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
O Schedule Board (FE) organiza work orders por `ScheduledDate` em visão semanal. O backend atual expõe `GetWorkOrderList` — listagem CRUD paginada sem janela temporal, sem `siteCode`, sem vendor inline, sem `ScheduledDate` na projeção.
|
||||
|
||||
Spikes de referência:
|
||||
|
||||
- [Consumer Audit](../spikes/consumer-audit-getworkorderlist.md)
|
||||
- [WorkOrderType](../spikes/work-order-type.md)
|
||||
- [Search Strategy](../spikes/search-strategy.md)
|
||||
|
||||
---
|
||||
|
||||
## Decisão
|
||||
|
||||
### 1. Endpoint dedicado (sem `projection=`)
|
||||
|
||||
Criar endpoint **separado** para o Schedule Board. **Não alterar** `GetWorkOrderList`.
|
||||
|
||||
```http
|
||||
GET /api/workorders/GetWorkOrdersBoard
|
||||
?scheduledFrom=2026-06-01
|
||||
&scheduledTo=2026-06-07
|
||||
&assignee=guid1&assignee=guid2
|
||||
&search=BK5
|
||||
&status=Open&status=InProgress
|
||||
&locationId=42
|
||||
```
|
||||
|
||||
Rota alias: `GET /api/WorkOrder/GetWorkOrdersBoard` (consistente com controller existente).
|
||||
|
||||
**Rejeitado:**
|
||||
|
||||
- `GET /api/workorders/table`
|
||||
- `GET /GetWorkOrderList?projection=table`
|
||||
|
||||
### 2. Window-based fetch (sem paginação Fase 1)
|
||||
|
||||
| Abordagem | Fase 1 |
|
||||
|-----------|--------|
|
||||
| `scheduledFrom` + `scheduledTo` | **Obrigatório** |
|
||||
| `page` / `pageSize` | **Ausente** |
|
||||
| Limite janela | Max **90 dias** (validação server-side) |
|
||||
|
||||
Response envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"scheduledFrom": "2026-06-01",
|
||||
"scheduledTo": "2026-06-07",
|
||||
"totalCount": 42,
|
||||
"items": [
|
||||
{
|
||||
"row": { /* WorkOrderBoardRowDto */ },
|
||||
"presentation": { /* BoardRowPresentation */ }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. DTO — domínio vs presentation
|
||||
|
||||
**`WorkOrderBoardRowDto`** — dados persistidos / joináveis:
|
||||
|
||||
```csharp
|
||||
public record WorkOrderBoardRowDto(
|
||||
int Id,
|
||||
string? WoNumber,
|
||||
string? SiteCode,
|
||||
string? LocationLabel,
|
||||
DateOnly? ScheduledDate,
|
||||
TimeOnly? ScheduledStart,
|
||||
DateOnly? DueDate,
|
||||
string? WorkOrderType,
|
||||
string? ServiceType,
|
||||
string? AssigneeId,
|
||||
string? AssigneeName,
|
||||
string? VendorCompany,
|
||||
string? VendorTechnician,
|
||||
string Status,
|
||||
string? PocName,
|
||||
string? PocPhone
|
||||
);
|
||||
```
|
||||
|
||||
**`BoardRowPresentation`** — calculado no mapper backend:
|
||||
|
||||
```csharp
|
||||
public record BoardRowPresentation(
|
||||
string StatusDisplay,
|
||||
bool IsPastDue,
|
||||
string? WorkOrderTypeDisplay
|
||||
);
|
||||
```
|
||||
|
||||
Mapper: `IWorkOrderBoardMapper` centraliza Past Due e type display.
|
||||
|
||||
### 4. Query EF (Fase 1)
|
||||
|
||||
- Filtro base: `istemplate != true`
|
||||
- Janela: `ScheduledDate >= from && ScheduledDate < to.AddDays(1)`
|
||||
- Includes: `Locations`, `AssignToUser`, `Dispatches` → `Vendor` (1 vendor por WO — first active dispatch)
|
||||
- POC: `WorkOrderContacts` (primary)
|
||||
- Service type: `Trade` ou category join (mínimo: `Trade`)
|
||||
- **Não incluir** subquery `lastUpdated` (Comments/AuditLog)
|
||||
|
||||
### 5. Search (conforme spike)
|
||||
|
||||
- Search scoped à janela temporal
|
||||
- Servidor: `StartsWith` em `InternalWONumber`, `SiteCode`
|
||||
- Count ≤ **300**: retornar semana inteira; FE filtra title/location/serviceType
|
||||
- Count > 300: exigir termo indexável ou retornar 400
|
||||
- Assignee: `assigneeId[]` multi — **não** filtrar por nome
|
||||
|
||||
Índices (migration separada pós-aprovação):
|
||||
|
||||
- `IX_workOrders_ScheduledDate` (P1)
|
||||
- `IX_workOrders_InternalWONumber`, `IX_workOrders_SiteCode` (P2, após fix tamanho coluna)
|
||||
|
||||
### 6. WorkOrderType
|
||||
|
||||
- Mapear coluna legada `WorkOrderType varchar(50)` na entidade EF
|
||||
- Expor em `WorkOrderBoardRowDto`
|
||||
- `IsPastDue` em presentation — **não** confundir com tipo
|
||||
- Enum C# adiado até amostragem prod
|
||||
|
||||
### 7. Status mapping
|
||||
|
||||
- API retorna status **canônico** (`Open`, `InProgress`, `Completed`, `Cancelled`, `OnHold`)
|
||||
- `StatusDisplay` via `IWorkOrderStatusMapper` (workshop G2 separado)
|
||||
- Sem migration de enum DB
|
||||
|
||||
---
|
||||
|
||||
## Consequências
|
||||
|
||||
### Positivas
|
||||
|
||||
- 1 HTTP call por mudança de semana (FE)
|
||||
- Contrato estável para board sem acoplar CRUD legado
|
||||
- Performance previsível (janela + threshold 300)
|
||||
- Blazor legado inalterado (EF local)
|
||||
|
||||
### Negativas / trade-offs
|
||||
|
||||
- Duplicação parcial de lógica de listagem vs `GetWorkOrderList` (aceitável — bounded contexts distintos)
|
||||
- Vendor technician pode ser null Fase 1 se dispatch spike incompleto
|
||||
- Setup DB local necessário para validação integrada
|
||||
|
||||
---
|
||||
|
||||
## Compliance com spikes
|
||||
|
||||
| Spike | Gate | Resultado |
|
||||
|-------|------|-----------|
|
||||
| Consumer Audit | G0 | `GetWorkOrderList` órfão no repo — endpoint dedicado aprovado |
|
||||
| WorkOrderType | G1 | Mapear coluna EF; Overdue = presentation |
|
||||
| Search Strategy | G3 | Window-first, threshold 300, StartsWith identificadores |
|
||||
|
||||
---
|
||||
|
||||
## Fora de escopo Fase 1
|
||||
|
||||
- PATCH inline edit
|
||||
- Completion doc, media, flags, reorder
|
||||
- Search vendor/tech server-side
|
||||
- Paginação cross-week
|
||||
- Alteração de `GetWorkOrderList`
|
||||
|
||||
---
|
||||
|
||||
## Próximos passos (implementação)
|
||||
|
||||
1. Aprovação explícita **Fase 1 only**
|
||||
2. Migration: `WorkOrderType` + índice `ScheduledDate`
|
||||
3. `GetWorkOrdersBoard` em `WorkOrderController` + `WorkOrderDataService`
|
||||
4. DTOs + `IWorkOrderBoardMapper`
|
||||
5. FE: `useWorkOrdersBoard({ from, to })` substituindo `RAW_ORDERS`
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- Gap analysis v3: `work_orders_api_gap_analysis_ce735946.plan.md`
|
||||
- [`WorkOrderController.cs`](../../Api.SeaHavenIndustries/Controllers/WorkOrderController.cs)
|
||||
- [`WorkOrderDataService.cs`](../../SeaHaven.DataServices/Implementation/WorkOrderDataService.cs)
|
||||
- [`DispatchController.List`](../../Api.SeaHavenIndustries/Controllers/DispatchController.cs) — padrão janela temporal
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
# Auditoria — API Work Orders vs Frontend (WorkOrders.tsx)
|
||||
|
||||
**Data:** 2026-06-19
|
||||
**Fonte da verdade (FE):** `seahaven.desing/src/pages/WorkOrders.tsx`
|
||||
**Backend auditado:** `seaheven.api`
|
||||
|
||||
---
|
||||
|
||||
## Resumo executivo
|
||||
|
||||
| Métrica | Valor |
|
||||
|---------|-------|
|
||||
| Compatibility Score | **24/100** |
|
||||
| Backend Readiness | **Not Ready** |
|
||||
| Confidence | **High** |
|
||||
|
||||
A API atual foi construída para **CRUD legado** (listagem paginada, detalhe rico, dispatch). O frontend `WorkOrders.tsx` define um **Schedule Board operacional** (visão semanal, 14 colunas, edição inline, advanced search, completion doc WO-level). Os contratos são fundamentalmente diferentes.
|
||||
|
||||
**O que funciona hoje:** id, dueDate, location (parcial), CRUD básico, detalhe rico, comments, audit field-based, mídia WO-level, reatribuição, dispatch.
|
||||
|
||||
**O que falta:** listagem window-based, 14 colunas board, PATCH inline, campos derivados, advanced search, completion doc WO-level, jobs Past Due/Carried Over, status 10 labels, WO# 11 dígitos.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints que existem hoje
|
||||
|
||||
| Endpoint | O que entrega |
|
||||
|----------|----------------|
|
||||
| `GET GetWorkOrderList` | Lista **paginada** (default 12): id, número, título, location, priority, status, dueDate, assignedTo, lastUpdated |
|
||||
| `GET Getworkorders` / `GetworkordersDD` | Lista legada para admin/dropdown |
|
||||
| `GET GetFilteredWorkorder` / `2` | Filtros legados (assignee, location, priority, status, due date buckets) — carrega tudo em memória |
|
||||
| `GET GetWorkorderById` / `{id}` | Detalhe: título, descrição, datas, status, problem, trade, POC, attachments, comments, audit, dispatches |
|
||||
| `POST AddWorkorder` | Criação via form: título, descrição, assignee, dueDate, location, priority, mídia, contacts/categories |
|
||||
| `POST/PUT EditWorkorder` | Update parcial via form (subset mapeado ao service) |
|
||||
| `POST ChangeStatus` | Muda status (string livre) + audit |
|
||||
| `POST ChangeAssignment` | Reatribui dispatcher + audit |
|
||||
| `POST AddComment` / `AddCommentJson` | Comentário + upload opcional |
|
||||
| `GET GetComments` / `GetCommentsByWorkorderId` | Lista comentários |
|
||||
| `DELETE DeleteWorkorder` | **Delete físico** (não soft cancel) |
|
||||
| Dispatch endpoints | Vendor, checklist, signoff, verify — **nível dispatch**, não board |
|
||||
|
||||
**Lookups relacionados (outros controllers):** `Common/GetUsers`, `Location/GetLocationList`, `Vendor/GetVendorList` — paginados, contrato diferente do FE.
|
||||
|
||||
**Documentado mas não implementado no código:** `GET GetWorkOrdersBoard`, `IWorkOrderBoardMapper`, `IWorkOrderStatusMapper` (ver `docs/adr/work-orders-board-api.md`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Listagem da tela principal (visão semanal)
|
||||
|
||||
| Frontend espera | API entrega hoje | Gap |
|
||||
|-----------------|------------------|-----|
|
||||
| 1 call por semana (`weekStart` / `weekEnd`) | Paginação `page` / `pageSize` sem janela temporal | Sem fetch window-based |
|
||||
| WOs agrupados por dia (Mon–Fri) | Lista flat paginada | Sem `scheduledDate` na projeção de listagem |
|
||||
| Seção **Unscheduled** sempre visível no topo | Só WOs da página atual | Sem WOs sem data retornados independente da semana |
|
||||
| `targetWeek` (scheduling week-only) | Campo inexistente na entidade/API | Não implementado |
|
||||
| Contador `X of Y` da semana ativa | `totalCount` global paginado | Contagem inadequada para o board |
|
||||
| Skeleton Mon–Fri vazios | N/A (FE-only) | OK — responsabilidade do FE |
|
||||
|
||||
**Evidência BE:** `WorkOrderDataService.GetWorkOrderListPagedAsync` — projeção L163-183, sem `ScheduledDate`, `SiteCode`, vendor, type.
|
||||
|
||||
---
|
||||
|
||||
## 2. Colunas da tabela (`COLS`)
|
||||
|
||||
Definidas no FE em `WorkOrders.tsx` L4234-4249.
|
||||
|
||||
| Coluna FE | Campo(s) FE | API hoje | Gap |
|
||||
|-----------|-------------|----------|-----|
|
||||
| Grip | drag reorder | Ausente | FE local-only; BE não persiste ordem |
|
||||
| Flag | flag pessoal | Ausente | FE session-only |
|
||||
| **SITE** | `site`, `pocName`, `pocPhone`, `pocNotes` | List: `location` (name); Detail: POC via `WorkOrderContacts` | Sem `siteCode` na listagem; sem POC na lista |
|
||||
| **WO** | `woNumber`, `rescheduleCount`, `carriedOver` | `internalWONumber` / `workerOrderNumber` | Formato ≠ 11 dígitos; sem badges ↻ ↷N |
|
||||
| **TYPE OF WO** | `type` (PM / Reactive / Emergency / Add-On / Overdue) | Não exposto (`WorkOrderType` em `db.txt`, não mapeado em EF) | Coluna inoperante |
|
||||
| **ASSIGNED TO** | `dispatcherId` + avatar/cor | `assignedTo` (nome completo); `AssignTo` = GUID Identity | Sem id/initials/color para avatar |
|
||||
| **SCHEDULE ON** | `scheduledOn`, `targetWeek` | Detail: `scheduledDate`; **list: ausente** | Listagem sem data de agendamento |
|
||||
| **DUE DATE** | `dueDate` | `dueDate` | **OK** na listagem |
|
||||
| **SERVICE** | `pm` | Detail: `trade` / `problem`; list: ausente | Coluna vazia na lista |
|
||||
| **VENDOR** | `company`, `tech`, `techPhone` | Detail: `dispatches` → vendor; list: ausente | Coluna vazia na lista |
|
||||
| **APPT TIME** | `apptTime` (ex: "07:00 – 09:00") | Entidade: `ScheduledStart`; sem `ScheduledEnd` | Janela de horário incompleta |
|
||||
| **STATUS** | 10 labels + overlay Past Due | String legada DB + enum 5 valores API | Sem mapping para status operacionais do FE |
|
||||
| **COMP DOC** | `docStatus` (Yes / No / NN) | Ausente no WO | Completion existe só no **dispatch** (`VerifyDispatch`) |
|
||||
| Actions | View / Edit | `GetWorkorderById` separado | Detalhe existe; contrato diferente |
|
||||
|
||||
---
|
||||
|
||||
## 3. Filtros e busca
|
||||
|
||||
### Barra principal (FE L5083-5099)
|
||||
|
||||
| Frontend | API hoje | Gap |
|
||||
|----------|----------|-----|
|
||||
| **Dispatcher** multi-select + `__unassigned__` + default usuário logado | `assignee` single string; `__unassigned` suportado em `GetWorkOrderList` | Multi-select e default "My WOs" não cobertos |
|
||||
| **Semana ativa** | Sem filtro `ScheduledDate` | Filtro semanal inexistente |
|
||||
| **Tipo** (All / PM / Reactive / Emergency / Add-On / Overdue) | Sem filtro por work order type | Inexistente |
|
||||
| **Busca contextual** na semana (site, wo#, dispatcher, location, pm, company, tech, status) | Search em 5 campos (`InternalWONumber`, `WorkerOrderNumber`, `Title`, `Location`), escopo global paginado | Campos e escopo incompatíveis |
|
||||
|
||||
### Advanced Search (FE L4255-4324)
|
||||
|
||||
| Frontend | API hoje | Gap |
|
||||
|----------|----------|-----|
|
||||
| Lista plana cross-week | Ausente | Sem endpoint dedicado |
|
||||
| Date range (this-week, last-week, this-month, last-3-months, next-week, next-month, custom) | `GetFilteredWorkorder` com due date buckets legados | Semântica diferente |
|
||||
| Filtros: sites[], types[], dispatchers[], statuses[], pmTypes[], vendorTechs[], docs[] | Parcial em endpoints legados | Cobertura incompleta |
|
||||
|
||||
---
|
||||
|
||||
## 4. Edição inline (spreadsheet-style)
|
||||
|
||||
| Frontend | API hoje | Gap |
|
||||
|----------|----------|-----|
|
||||
| PATCH por campo (site, wo#, type, dispatcher, schedule, due, service, vendor, appt, status…) | `EditWorkorder` (form multipart) + `ChangeStatus` + `ChangeAssignment` separados | Sem update granular por campo |
|
||||
| Auto-schedule: `Incomplete` → `Scheduled` quando data + dispatcher | Ausente no BE | Regra `maybeAutoSchedule` só no FE |
|
||||
| `rescheduleCount++` ao mudar `scheduledOn` | Campo inexistente | Badge ↻ impossível |
|
||||
| Limpar `isPastDue` ao reagendar para data futura | Campo derivado inexistente | Past Due não limpa via API |
|
||||
| Bloquear mudança de status se `isPastDue` | `ChangeStatus` aceita qualquer string | Sem validação 422 |
|
||||
| WO# único 11 dígitos normalizado | Gerador `WO-{yyyyMMdd}-{random}` ou sync sequencial `10000001` | Formato e unicidade incompatíveis |
|
||||
| WO cancelado read-only | Sem enforcement no BE | Só lógica no FE (`WOSlideOver` L2380) |
|
||||
|
||||
**Evidência BE:** `WorkOrderController.Editworkorder` L182-192 mapeia subset para `UpdateWorkOrderDTO` — ignora `Problem`, `Trade`, `ScheduledDate`, `Source` apesar de existirem em `EditWorkorder_DTO`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Criação de WO
|
||||
|
||||
| Frontend | API hoje | Gap |
|
||||
|----------|----------|-----|
|
||||
| Wizard `NewWOWizard` + inline `InlineRow` | `POST AddWorkorder` | Campos do wizard majoritariamente ausentes |
|
||||
| Campos: site, type, scheduledOn, targetWeek, pm, company, tech, appt, POC | `CreateWorkOrderDTO`: title, description, assignTo, dueDate, location | Contrato incompleto |
|
||||
| Status inicial `Incomplete` | Cria com `WorkOrderStatus.Open` | Status inicial diferente |
|
||||
| `scheduleWeekOnly` / `targetWeek` | Ausente | Week-only scheduling impossível |
|
||||
| Navegação automática para semana do WO criado | Depende de listagem semanal | Listagem incompatível |
|
||||
|
||||
---
|
||||
|
||||
## 6. Cancelamento
|
||||
|
||||
| Frontend | API hoje | Gap |
|
||||
|----------|----------|-----|
|
||||
| Soft cancel → status `Canceled` | `POST ChangeStatus(id, "Canceled")` manual | Sem endpoint `POST cancel` dedicado |
|
||||
| WO cancelado não editável | Sem bloqueio no BE | Enforcement só no FE |
|
||||
| Delete permanente (Admin) | `DELETE DeleteWorkorder` — delete físico | Comportamento diverge do soft cancel FE |
|
||||
|
||||
---
|
||||
|
||||
## 7. Slide-over (`WOSlideOver`)
|
||||
|
||||
| Tab FE | API hoje | Gap |
|
||||
|--------|----------|-----|
|
||||
| **Info** | `GET GetWorkorderById` | Omite `siteCode`; sem campos derivados FE (`isPastDue`, `rescheduleCount`, etc.) |
|
||||
| **Comments** | `AddComment` + `GetCommentsByWorkorderId` | Formato FE `{authorId, text, time}` vs BE `{Commenttext, FirstName, Documents}` |
|
||||
| **Audit Log** | `WorkOrderAuditLog` retornado no detalhe | Schema `{fieldName, oldValue, newValue, action}` vs FE `{type: manual\|system, dispatcherId?, action, time}` |
|
||||
| **Completion Doc** | `SignOffName` / `SignOffAttachment` no WO; `VerifyDispatch` no dispatch | Sem template por service type, PDF, `docStatus` WO-level |
|
||||
| **Extra Docs / Media** | `BeforPhoto`, `AfterPhoto`, `workOrderAttachments` | Sem `MediaFile.category`; sem API extra docs dedicada |
|
||||
|
||||
---
|
||||
|
||||
## 8. Regras de sistema (background jobs)
|
||||
|
||||
| Frontend assume | API hoje | Gap |
|
||||
|-----------------|----------|-----|
|
||||
| Job Past Due: `scheduledOn < hoje` + não terminal → `isPastDue=true` | Ausente (zero `BackgroundService` / Hangfire / Quartz no repo) | Flag nunca setada automaticamente |
|
||||
| Job Carried Over: virada de semana → `carriedOver++` | Ausente | Contador ↷N nunca incrementado |
|
||||
| Type Overdue promotion | Ausente | Filtro/tipo Overdue não automático |
|
||||
| Sync APM | `SyncController` POST manual (DynamoDB → SQL) | On-demand, não integrado à UX do board |
|
||||
|
||||
---
|
||||
|
||||
## 9. Lookups (catálogos)
|
||||
|
||||
| FE usa (mock estático) | API relacionada | Gap |
|
||||
|------------------------|-----------------|-----|
|
||||
| `DISPATCHERS` (id, name, initials, color) | `Common/GetUsers` | Sem initials/color; paginado; ids são GUIDs |
|
||||
| `SITE_OPTIONS` (code → city, state) | `Location/GetLocationList` | Sem lookup por site code; sem mapeamento BK5 → Dallas |
|
||||
| `PM_TYPES` | Ausente | Sem catálogo de service types |
|
||||
| `TECHNICIANS` (name, company, phone) | `Vendor/GetVendorList` | Sem endpoint technicians |
|
||||
| `WEEK_RANGES` (Mon–Fri + flag LIVE) | Ausente | Sem endpoint weeks |
|
||||
|
||||
---
|
||||
|
||||
## Contrato de dados — campo a campo
|
||||
|
||||
| Campo FE (`WorkOrder`) | Campo BE | Status |
|
||||
|------------------------|----------|--------|
|
||||
| `id` | `WorkOrder.Id` | SUPPORTED |
|
||||
| `woNumber` | `InternalWONumber` / `WorkerOrderNumber` | PARTIALLY_SUPPORTED — formatos conflitantes |
|
||||
| `site` | `SiteCode` | PARTIALLY_SUPPORTED — entidade tem; API omite na listagem |
|
||||
| `type` | — (`WorkOrderType` só em SQL) | NOT_SUPPORTED |
|
||||
| `dispatcherId` | `AssignTo` (GUID) | PARTIALLY_SUPPORTED |
|
||||
| `scheduledOn` | `ScheduledDate` | PARTIALLY_SUPPORTED — list omite |
|
||||
| `dayGroup` / `dayLabel` | — | NOT_SUPPORTED (derivado) |
|
||||
| `location` | `Locations.Name` | SUPPORTED (via join) |
|
||||
| `pm` | `Trade` / `Problem` | PARTIALLY_SUPPORTED |
|
||||
| `company` / `tech` | `Dispatches` → `Vendor` | PARTIALLY_SUPPORTED — não inline na lista |
|
||||
| `techPhone` | — | NOT_SUPPORTED |
|
||||
| `apptTime` | `ScheduledStart` (sem end) | PARTIALLY_SUPPORTED |
|
||||
| `status` | `Status` (string livre) | PARTIALLY_SUPPORTED — 10 vs 5+ formatos |
|
||||
| `docStatus` | — | NOT_SUPPORTED |
|
||||
| `pocName` / `pocPhone` / `pocNotes` | `WorkOrderContacts` | PARTIALLY_SUPPORTED — notes ausente |
|
||||
| `dueDate` | `DueDate` | SUPPORTED |
|
||||
| `rescheduleCount` | — | NOT_SUPPORTED |
|
||||
| `carriedOver` | — | NOT_SUPPORTED |
|
||||
| `originalWeek` / `originalDate` | — | NOT_SUPPORTED |
|
||||
| `isPastDue` | — | NOT_SUPPORTED (derivado) |
|
||||
| `targetWeek` | — | NOT_SUPPORTED |
|
||||
|
||||
---
|
||||
|
||||
## Regras de negócio — FE vs BE
|
||||
|
||||
| Regra FE | Evidência FE | BE hoje | Status |
|
||||
|----------|--------------|---------|--------|
|
||||
| Auto-schedule Incomplete → Scheduled | `maybeAutoSchedule` L500-506 | Ausente | NOT_SUPPORTED |
|
||||
| Reschedule incrementa contador | `updateScheduledOn` L4856 | Ausente | NOT_SUPPORTED |
|
||||
| Reschedule limpa Past Due | L4851-4857 | Ausente | NOT_SUPPORTED |
|
||||
| Status bloqueado em Past Due | `StatusCell` L1120-1159 | `ChangeStatus` sem validação | NOT_SUPPORTED |
|
||||
| WO# único 11 dígitos | `findDuplicateWONumber` L4897 | Gerador random; `InternalNumberExistsAsync` sem normalização | NOT_SUPPORTED |
|
||||
| Cancel → Canceled, read-only | `cancelWO` L4946; `WOSlideOver` L2380 | `ChangeStatus` manual | PARTIALLY_SUPPORTED |
|
||||
| Carried over job semanal | Audit derivado L2414-2416 | Sem job | NOT_SUPPORTED |
|
||||
| Completion doc por tipo de serviço | `CompDocDialog` L3902+ | Dispatch verify only | NOT_SUPPORTED (WO-level) |
|
||||
| Emergency/Reactive → media flow | `isMediaWO` L332-334 | Mídia WO genérica | PARTIALLY_SUPPORTED |
|
||||
|
||||
---
|
||||
|
||||
## Diagrama — contrato atual vs esperado
|
||||
|
||||
```
|
||||
HOJE (GetWorkOrderList) FRONTEND (WorkOrders.tsx)
|
||||
───────────────────── ─────────────────────────
|
||||
Paginação 12/page 1 call = semana inteira
|
||||
Sem ScheduledDate Agrupamento Mon–Fri
|
||||
Sem siteCode, type, vendor 14 colunas preenchidas
|
||||
Sem unscheduled semantics Seção Unscheduled fixa
|
||||
5 status legados 10 status + Past Due overlay
|
||||
CRUD admin Schedule Board operacional
|
||||
EditWorkorder (form) PATCH inline por célula
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cobertura por área
|
||||
|
||||
| Área | Cobertura estimada |
|
||||
|------|-------------------|
|
||||
| Listagem board (visão semanal) | ~5% |
|
||||
| Colunas da tabela (14) | ~15% |
|
||||
| Filtros e busca | ~10% |
|
||||
| Edição inline | ~0% |
|
||||
| Criação (wizard/inline) | ~25% |
|
||||
| Slide-over (detalhe) | ~40% |
|
||||
| Completion doc WO-level | ~0% |
|
||||
| Background jobs | ~0% |
|
||||
| Lookups/catálogos | ~20% |
|
||||
|
||||
---
|
||||
|
||||
## Critical blockers
|
||||
|
||||
1. **Sem API window-based** para visão semanal + Unscheduled — tela principal não carrega.
|
||||
2. **Contrato de listagem incompatível** com 14 colunas do board.
|
||||
3. **Campos persistidos ausentes** — `rescheduleCount`, `carriedOver`, `targetWeek`, `docStatus`, `isPastDue`.
|
||||
4. **Sem PATCH inline** — edição spreadsheet-style impossível.
|
||||
5. **Advanced search cross-week inexistente.**
|
||||
6. **Status 10 valores + Past Due** sem mapping layer implementado.
|
||||
7. **Completion doc WO-level inexistente** — coluna COMP DOC inoperante.
|
||||
8. **WO# 11 dígitos único** não suportado.
|
||||
9. **Jobs Past Due / Carried Over ausentes.**
|
||||
|
||||
---
|
||||
|
||||
## Non-critical gaps
|
||||
|
||||
- Reorder intra-dia persistido (FE já local-only)
|
||||
- Flags pessoais (FE session-only)
|
||||
- Paginação 12/24/48/96 (FE não implementou)
|
||||
- Bulk select (FE não implementou)
|
||||
- `[Authorize]` comentado no `WorkOrderController` (risco de segurança)
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- Frontend: `seahaven.desing/src/pages/WorkOrders.tsx`
|
||||
- Controller: `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs`
|
||||
- Data service: `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs`
|
||||
- Entidade: `Data.SeaHavenIndustries/Models/WorkerOrder.cs`
|
||||
- ADR (proposto, não implementado): `docs/adr/work-orders-board-api.md`
|
||||
- Spikes: `docs/spikes/consumer-audit-getworkorderlist.md`, `search-strategy.md`, `status-mapping.md`, `work-order-type.md`
|
||||
|
||||
---
|
||||
|
||||
## Classificação final
|
||||
|
||||
| Métrica | Valor |
|
||||
|---------|-------|
|
||||
| Compatibility Level | **Minimally Compatible** |
|
||||
| Backend Readiness | **Not Ready** |
|
||||
| Confidence | **High** |
|
||||
|
||||
Este documento reporta apenas **findings** — gaps entre o que a API entrega hoje e o que o frontend especifica. Não inclui propostas de implementação.
|
||||
|
|
@ -1,907 +0,0 @@
|
|||
# Roadmap de Implementação — Work Orders API vs Frontend Board
|
||||
|
||||
**Documento:** Relatório consolidado de arquitetura e entrega
|
||||
**Fonte da verdade (frontend):** `seahaven.desing/src/pages/WorkOrders.tsx`
|
||||
**Backend:** `seaheven.api`
|
||||
**Auditoria base:** [auditoria-work-orders-api-vs-frontend.md](auditoria-work-orders-api-vs-frontend.md)
|
||||
**Estado atual:** Compatibility Score **24/100** — backend **Not Ready**
|
||||
**Nota técnica do programa:** **9.0/10** (meta kickoff enterprise **9.5+**)
|
||||
**Escopo:** capacidades de negócio, domínio, governança, fases, riscos e validação — sem design de API, schema ou código
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### 1.1 Readiness geral
|
||||
|
||||
O backend é **maduro para CRUD legado, dispatch, vendor portal, checklist/sign-off e audit**, mas foi construído para **listagem paginada administrativa**, não para o **Schedule Board operacional** que o frontend define. A tela principal do board **não pode funcionar** com os contratos atuais.
|
||||
|
||||
| Dimensão | Avaliação |
|
||||
|----------|-----------|
|
||||
| Tela principal (visão semanal) | ~5% |
|
||||
| 14 colunas do board | ~15% |
|
||||
| Edição inline | ~0% |
|
||||
| Completion doc WO-level | ~0% |
|
||||
| Background jobs | ~0% |
|
||||
| Detalhe / slide-over | ~40% |
|
||||
| **Média ponderada** | **~24%** |
|
||||
|
||||
### 1.2 Problemas arquiteturais principais
|
||||
|
||||
1. **Paradigma de contrato incompatível** — paginação global vs. janela semanal com seção Unscheduled fixa.
|
||||
2. **Projeção de listagem insuficiente** — 9 das 14 colunas do board não são alimentadas pela listagem atual.
|
||||
3. **Ausência de camada de domínio operacional** — campos derivados, tipo de WO e status operacionais ausentes ou não expostos.
|
||||
4. **Database drift** — `WorkOrderType` existe no SQL legado mas não no EF; possíveis outras colunas desalinhadas.
|
||||
5. **Modelo de mutação inadequado** — form multipart vs. edição granular por célula.
|
||||
6. **Sem automação de sistema** — zero infraestrutura de jobs; regras do FE nunca executam no servidor.
|
||||
7. **Cinco consumidores paralelos** — SHOC, Blazor EF, Vendor Portal, Sync/Lambda, e-mails — sem governança de ownership.
|
||||
8. **Duas trilhas paralelas** — Blazor acessa EF direto; API REST é integração do SHOC.
|
||||
|
||||
### 1.3 Maior risco do programa
|
||||
|
||||
**Governança de ownership de dados** — quem pode alterar cada campo entre SHOC, Vendor Portal, Sync, Blazor e Jobs. A arquitetura de domínio resolve fontes da verdade; a governança operacional é o gate final.
|
||||
|
||||
### 1.4 Decisões de produto registradas
|
||||
|
||||
- **Novas funcionalidades do board** → somente via API REST para o frontend React (SHOC).
|
||||
- **Blazor (`SeaHavenIndustries`)** → não recebe board, inline edit, jobs, advanced search nem completion doc WO-level; manutenção mínima até sunset do módulo WO.
|
||||
- **Vendor Portal** → permanece ativo; não-regressão obrigatória.
|
||||
- **DynamoDB/Sync** → ponte temporária; aposentar após SHOC ingerir direto ([TODO.md](../TODO.md)).
|
||||
|
||||
### 1.5 Complexidade e esforço
|
||||
|
||||
| Métrica | Valor |
|
||||
|---------|-------|
|
||||
| Complexidade geral | **Alta** |
|
||||
| Esforço total | **XL** (6–9 meses, 2 squads; ou 4–6 meses, 3 squads) |
|
||||
| Fase 0 — Fundação domínio + governança | L |
|
||||
| Fase 1 — Board semanal | L |
|
||||
| Fase 2 — Edição inline + concorrência | L |
|
||||
| Fase 3 — Criação e cancelamento | M |
|
||||
| Fase 4 — Busca avançada | M |
|
||||
| Fase 5 — Domain events (jobs) | M |
|
||||
| Fase 6 — Completion doc e slide-over | M |
|
||||
| Fase 7 — Rollout e produção | M |
|
||||
|
||||
### 1.6 Gates bloqueantes da Fase 0
|
||||
|
||||
Nenhuma implementação de board inicia sem:
|
||||
|
||||
1. Domain Architecture Review (DAR) aprovado
|
||||
2. **Data Ownership Model** assinado
|
||||
3. **Field Ownership Matrix** assinada
|
||||
4. **Audit Event Contract** aprovado
|
||||
5. Volume Discovery Report (sem premissa de volume)
|
||||
6. Database drift report (SQL vs EF)
|
||||
7. Spike vendor/dispatch
|
||||
8. Data migration dry-run
|
||||
9. RowVersion multi-agregado definido
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability Gap Assessment
|
||||
|
||||
### 2.1 Weekly Scheduling Board — **Critical**
|
||||
- **Atual:** paginação sem janela temporal, sem Unscheduled.
|
||||
- **Desejado:** 1 call/semana, Mon–Fri + Unscheduled, contador `X of Y`.
|
||||
|
||||
### 2.2 Work Order Lifecycle — **High**
|
||||
- **Atual:** form parcial, status `Open`, delete físico.
|
||||
- **Desejado:** wizard/inline completo, `Incomplete`, soft cancel read-only.
|
||||
|
||||
### 2.3 Inline Editing — **Critical**
|
||||
- **Atual:** `EditWorkorder` multipart; endpoints separados.
|
||||
- **Desejado:** update granular por célula com regras embutidas.
|
||||
|
||||
### 2.4 Search & Filtering — **High**
|
||||
- **Atual:** 5 campos, escopo global, assignee single-select.
|
||||
- **Desejado:** busca contextual na semana + advanced search cross-week.
|
||||
|
||||
### 2.5 Status Management — **Critical**
|
||||
- **Atual:** enum 5 valores + string livre; sem Past Due.
|
||||
- **Desejado:** 10 labels + flag Past Due separada; auto-schedule; bloqueios.
|
||||
|
||||
### 2.6 Completion Documentation — **High**
|
||||
- **Atual:** sign-off básico; verify só no dispatch.
|
||||
- **Desejado:** `docStatus` WO-level (Yes/No/NN); templates por serviço.
|
||||
|
||||
### 2.7 Vendor Management (board) — **High**
|
||||
- **Atual:** vendor só no detalhe via dispatch.
|
||||
- **Desejado:** colunas VENDOR/APPT na lista; edição via dispatch primário.
|
||||
|
||||
### 2.8 Scheduling Intelligence — **Critical**
|
||||
- **Atual:** `ScheduledDate` no detalhe; sem `targetWeek`, `ScheduledEnd`.
|
||||
- **Desejado:** agendamento semântico completo; badges ↻ e ↷N.
|
||||
|
||||
### 2.9 Audit & Tracking — **Medium**
|
||||
- **Atual:** audit field-based legado.
|
||||
- **Desejado:** `{type: manual|system, dispatcherId?, action, time}` + eventos de sistema.
|
||||
|
||||
### 2.10 Background Automation — **Critical** (como otimização, não fonte da verdade)
|
||||
- **Atual:** zero jobs.
|
||||
- **Desejado:** WeekRolled, cache opcional PastDue; domínio recalcula on-read.
|
||||
|
||||
### 2.11 Lookup Data — **High**
|
||||
- **Atual:** lookups paginados incompatíveis; sem PM types, technicians.
|
||||
- **Desejado:** dispatchers (initials/color), sites por code, PM types, technicians.
|
||||
|
||||
### 2.12 Reporting / Contadores — **Medium**
|
||||
- **Atual:** `totalCount` global.
|
||||
- **Desejado:** `X of Y` semanal.
|
||||
|
||||
### 2.13 Security — **High**
|
||||
- **Atual:** `[Authorize]` comentado no `WorkOrderController`.
|
||||
- **Desejado:** auth ativa; default "My WOs".
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency Map
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph phase0 [Fase0_Fundacao]
|
||||
DAR[DAR e 3 artefatos]
|
||||
DOM[Agregados dominio]
|
||||
VOL[Volume Discovery]
|
||||
OWN[Data Ownership]
|
||||
end
|
||||
subgraph phase1 [Fase1_Board]
|
||||
B1[Listagem semanal]
|
||||
B2[14 colunas]
|
||||
end
|
||||
subgraph phase2 [Fase2_Mutacao]
|
||||
M1[Inline edit]
|
||||
M2[Concorrencia]
|
||||
end
|
||||
subgraph phase3 [Fase3_Lifecycle]
|
||||
L1[Criacao wizard]
|
||||
L2[Cancelamento]
|
||||
end
|
||||
subgraph phase45 [Fase4_5]
|
||||
S1[Search]
|
||||
E1[Domain events]
|
||||
end
|
||||
subgraph phase6 [Fase6_Completion]
|
||||
C1[DocStatus slideover]
|
||||
end
|
||||
DAR --> DOM
|
||||
DOM --> B1
|
||||
OWN --> M1
|
||||
VOL --> B1
|
||||
B1 --> B2 --> M1
|
||||
M1 --> L1
|
||||
B2 --> S1
|
||||
DOM --> E1
|
||||
B2 --> C1
|
||||
```
|
||||
|
||||
**Ordem obrigatória:** Fase 0 → Board → Inline → Lifecycle → Search/Events → Completion → Rollout.
|
||||
|
||||
---
|
||||
|
||||
## 4. Domain Model
|
||||
|
||||
### 4.1 Agregados e limites (anti God Entity)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph root [Aggregate Root WorkOrder]
|
||||
CORE[Core Lifecycle Assignment]
|
||||
SCH[Scheduling slice]
|
||||
TRK[Tracking slice]
|
||||
COMP[Completion slice]
|
||||
ANA[Analytics slice]
|
||||
end
|
||||
subgraph separate [Agregado separado]
|
||||
DISP[Dispatch fonte vendor]
|
||||
end
|
||||
AUD[WorkOrderAuditLog append-only]
|
||||
CORE --> SCH
|
||||
CORE --> TRK
|
||||
CORE --> ANA
|
||||
CORE --> COMP
|
||||
CORE --> DISP
|
||||
CORE --> AUD
|
||||
```
|
||||
|
||||
| Agregado | Responsabilidade |
|
||||
|----------|------------------|
|
||||
| **WorkOrder (core)** | Id, WoNumber, Type, LifecycleStatus, SiteCode, LocationId, DueDate, AssignTo, Title, RowVersion |
|
||||
| **Scheduling slice** | ScheduledDate, ScheduledStart/End, TargetWeek, ScheduleWeekOnly |
|
||||
| **Tracking slice** | OriginalWeek, OriginalDate (set-once) |
|
||||
| **Analytics slice** | RescheduleCount, CarriedOver (contadores históricos) |
|
||||
| **Completion slice** | DocStatus, refs attachments |
|
||||
| **Dispatch** | Vendor, tech, status portal — **fonte da verdade vendor** |
|
||||
| **Audit** | Rastreabilidade imutável |
|
||||
|
||||
**Proibido:** `VendorId`, `TechName`, `TechPhone` canônicos na WO.
|
||||
|
||||
### 4.2 Scheduling Aggregate Growth Watchlist
|
||||
|
||||
Campos que **não** entram em Scheduling sem ARB review: `MoveReason`, `MoveUser`, `MoveCategory`, `MoveSource`. Metadados de movimentação → Audit. Gate: slice Scheduling > 8 campos operacionais → ARB obrigatório.
|
||||
|
||||
### 4.3 Campos reaproveitados (sem nova coluna)
|
||||
|
||||
`SiteCode`, `ScheduledDate`, `DueDate`, `AssignTo`, `Trade`/`Problem`, `LocationId`, POC via `WorkOrderContacts`.
|
||||
|
||||
### 4.4 Database drift conhecido
|
||||
|
||||
| Coluna SQL | Ação |
|
||||
|------------|------|
|
||||
| `WorkOrderType` | Mapear no Core |
|
||||
| `AvettaTask` | Inventariar; mapear ou deprecar |
|
||||
| `AssignDate` | Inventariar; mapear ou deprecar |
|
||||
|
||||
Gate Fase 0: diff completo SQL vs `ApplicationDbContextModelSnapshot`.
|
||||
|
||||
### 4.5 Entidades relacionadas
|
||||
|
||||
| Entidade | Mudança | Fase |
|
||||
|----------|---------|------|
|
||||
| `WorkOrderContacts` | `Notes` (pocNotes) | 0 |
|
||||
| `WorkOrderAuditLog` | EventType Manual/System/Vendor/Sync | 0 |
|
||||
| `ApplicationUser` | Initials, Color | 0–1 |
|
||||
| `Locations` | SiteCode | 0–1 |
|
||||
| `WorkOrderAttachments` | Category | 2–6 |
|
||||
| `WorkOrderEnums` | LifecycleStatus, WorkOrderType, DocStatus, OperationalFlags | 0 |
|
||||
| `Dispatch` | Proteção regression | contínuo |
|
||||
|
||||
Fora de escopo imediato: `FollowUps`, `Quotes`, `PMSchedules`, `Employee`, `WorkOrderCategories` no board.
|
||||
|
||||
---
|
||||
|
||||
## 5. Domain Architecture Review (DAR)
|
||||
|
||||
### 5.1 Fonte da verdade por conceito
|
||||
|
||||
| Conceito | Fonte da verdade | Proibido |
|
||||
|----------|------------------|----------|
|
||||
| Status | `LifecycleStatus` (10 valores) | String livre; status composto |
|
||||
| Past Due | `OperationalFlags.PastDue` | Misturar no enum lifecycle |
|
||||
| Vendor/Tech | Dispatch primário | Duplicar na WO |
|
||||
| Schedule | Scheduling slice | Duplicar no core |
|
||||
| Completion | Completion.DocStatus | Só inferir de dispatch |
|
||||
| Reschedule/Carried | Analytics + audit | Job blind overwrite |
|
||||
| WO# | Campo canônico único | Dois números sem regra |
|
||||
| POC | WorkOrderContacts | Só no detalhe |
|
||||
|
||||
### 5.2 Matriz Persistido vs Derivado
|
||||
|
||||
| Campo FE | Persistido | Derivado | Fonte / Regra |
|
||||
|----------|------------|----------|---------------|
|
||||
| `lifecycleStatus` | Sim | Não | Core |
|
||||
| `isPastDue` | Não | **Sim** | `ScheduledDate < hoje` AND NOT terminal; job = cache opcional |
|
||||
| `type` Overdue | Não | **Sim** | Regra type + schedule + status |
|
||||
| `carriedOver` | Sim | Não | Métrica histórica; incremento via `WeekRolled` |
|
||||
| `rescheduleCount` | Sim | Parcial | Domain event em mudança de `ScheduledDate` |
|
||||
| `docStatus` | Sim | Não | Completion slice |
|
||||
| `dayGroup`, `apptTime` | Não | Sim | Calculados na leitura |
|
||||
| vendor/tech | Via Dispatch | Projeção | Join dispatch primário |
|
||||
|
||||
**Regra de ouro:** Jobs nunca são a única fonte da verdade. Leitura sempre recalcula derivados.
|
||||
|
||||
### 5.3 Lifecycle Status vs Operational Flags
|
||||
|
||||
```text
|
||||
LifecycleStatus → enum único (10 valores FE)
|
||||
OperationalFlags → PastDue, (futuro: Escalated)
|
||||
```
|
||||
|
||||
Filtro status = LifecycleStatus. Overlay Past Due = flag. Bloqueio de edição = validação sobre flag.
|
||||
|
||||
### 5.4 CarriedOver — justificativa
|
||||
|
||||
| | `isPastDue` | `carriedOver` |
|
||||
|--|-------------|---------------|
|
||||
| Natureza | Estado pontual | **Métrica histórica acumulada** |
|
||||
| Persistir | Não (derivado) | **Sim** (contador) |
|
||||
| Por quê | Calculável da data atual | Não recuperável só do schedule atual |
|
||||
|
||||
Invariante: incremento **somente** via domain event `WeekRolled`.
|
||||
|
||||
### 5.5 Vendor / Dispatch — spike obrigatório (Fase 0)
|
||||
|
||||
1. WO mantém `PrimaryDispatchId`
|
||||
2. Colunas VENDOR = projeção do dispatch primário
|
||||
3. Edição inline vendor = mutação no Dispatch
|
||||
4. WO sem dispatch → edição cria dispatch primário
|
||||
|
||||
Entregável: ADR "Vendor Source of Truth". Critério: zero `Tech*` canônicos na WO.
|
||||
|
||||
### 5.6 Jobs — domínio primeiro
|
||||
|
||||
| Job | Papel |
|
||||
|-----|-------|
|
||||
| Past Due diário | Cache refresh opcional; on-read sempre correto |
|
||||
| Carried Over semanal | Dispara `WeekRolled` → incrementa contador + audit system |
|
||||
| Promoção Overdue | Derivado on-read |
|
||||
| Audit | Síncrono em toda mutação desde Fase 0 |
|
||||
|
||||
### 5.7 Database drift investigation
|
||||
|
||||
Diff SQL vs EF; classificar: mapear, deprecar, mover para agregado satélite. Tabelas: `workOrders`, `Comments`, `Locations`, `Dispatch`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Ownership Model
|
||||
|
||||
### 6.1 Ownership por ator
|
||||
|
||||
| Ator | Pode alterar | Não pode alterar |
|
||||
|------|--------------|------------------|
|
||||
| **SHOC** | Lifecycle, Schedule, Assignment, Completion, vendor via Dispatch, POC, WO# | Checklist/signoff vendor |
|
||||
| **Vendor Portal** | Dispatch status, checklist, signoff, comments | Schedule, lifecycle, assignment, DueDate |
|
||||
| **Sync/Lambda** | Campos ingest (Description, ExternalId, etc.) — só owner Sync | Campos SHOC-owned após 1ª edição manual |
|
||||
| **Blazor** | Campos legados até sunset | Campos novos board |
|
||||
| **Jobs** | Incremento CarriedOver; audit system | Lifecycle, Schedule, Dispatch |
|
||||
|
||||
### 6.2 Field Ownership Matrix
|
||||
|
||||
| Campo | Agregado | Owner escritor | Sync sobrescreve? | Conflito SHOC vs Sync |
|
||||
|-------|----------|----------------|-------------------|----------------------|
|
||||
| LifecycleStatus | Core | SHOC | Não após 1ª edição SHOC | SHOC vence |
|
||||
| AssignTo | Core | SHOC | Não após 1ª edição SHOC | SHOC vence |
|
||||
| ScheduledDate / janela | Scheduling | SHOC | Não | SHOC vence |
|
||||
| TargetWeek | Scheduling | SHOC | Não | SHOC vence |
|
||||
| DueDate | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
|
||||
| Description | Core | SHOC + Sync | Merge / SHOC priority | SHOC se editado |
|
||||
| SiteCode / Location | Core | SHOC + Sync | Sim na criação; não após SHOC | SHOC após flag |
|
||||
| WorkOrderType | Core | SHOC | Sim na criação ingest | SHOC após flag |
|
||||
| WoNumber | Core | SHOC | Não | SHOC only |
|
||||
| Vendor / tech | Dispatch | SHOC + Vendor | Não | Domínios separados |
|
||||
| Checklist/signoff | Dispatch | Vendor | Não | Vendor vence |
|
||||
| DocStatus | Completion | SHOC | Não | SHOC only |
|
||||
| RescheduleCount / CarriedOver | Analytics | Domain events | Não | N/A |
|
||||
| ExternalWorkOrderId | Core | Sync | Sim (idempotente) | Sync only |
|
||||
|
||||
**ManualEditFlag:** primeira edição SHOC marca campo; Sync subsequente skip + audit `SyncRejected`.
|
||||
|
||||
### 6.3 Sync merge — cenários
|
||||
|
||||
| Cenário | Comportamento |
|
||||
|---------|---------------|
|
||||
| Sync DueDate + SHOC ScheduledDate | Merge — campos distintos |
|
||||
| Sync Status + SHOC Status | SHOC vence; sync skip |
|
||||
| Sync AssignTo + SHOC AssignTo | SHOC vence |
|
||||
| Sync Description + SHOC Description | SHOC vence se ManualEditFlag |
|
||||
| Sync cria WO novo | Full ingest |
|
||||
| Sync atualiza por ExternalWorkOrderId | Field-level merge por matriz |
|
||||
|
||||
---
|
||||
|
||||
## 7. Concurrency Strategy
|
||||
|
||||
### 7.1 Atores
|
||||
|
||||
Dispatcher (SHOC), Vendor (Portal), Sync/Lambda, Blazor (congelado), Jobs.
|
||||
|
||||
### 7.2 RowVersion multi-agregado
|
||||
|
||||
| Agregado | RowVersion | Quando muda |
|
||||
|----------|------------|-------------|
|
||||
| **WorkOrder (root)** | No Core | Mutação Core, Scheduling, Completion, Analytics na mesma transação |
|
||||
| **Dispatch** | Próprio | Mutação vendor/tech/checklist/status dispatch |
|
||||
| **Projeção board** | N/A | FE envia `workOrderVersion` + `dispatchVersion` |
|
||||
|
||||
**Propagação:**
|
||||
- SHOC edita ScheduledDate → bump WO.RowVersion
|
||||
- SHOC edita vendor → bump Dispatch.RowVersion
|
||||
- Vendor edita checklist → bump Dispatch.RowVersion only
|
||||
- Sync altera Description → bump WO se merge aplicado
|
||||
- DocStatus → bump WO (Completion na mesma transação)
|
||||
|
||||
Conflito → 409 com `currentState` para refresh.
|
||||
|
||||
### 7.3 Concurrency Ownership Rules
|
||||
|
||||
| Cenário | Resultado |
|
||||
|---------|-----------|
|
||||
| SHOC vs SHOC (mesmo campo) | 409; último RowVersion válido |
|
||||
| SHOC vs Sync (mesmo campo) | Vence owner do campo |
|
||||
| SHOC vs Sync (campos diferentes) | Merge |
|
||||
| Vendor vs Sync | Domínios separados |
|
||||
| SHOC vs Vendor | Merge se campos distintos; impossível mesmo conceito por design |
|
||||
| Job WeekRolled vs SHOC reschedule | Idempotência WO+semana |
|
||||
| Blazor vs SHOC | SHOC vence; Blazor congelado |
|
||||
|
||||
### 7.4 Testes
|
||||
|
||||
Fase 2: conflito paralelo (2 dispatchers, sync durante edição); dual RowVersion; audit count = fields changed.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit Event Contract
|
||||
|
||||
### 8.1 Princípios
|
||||
|
||||
- Toda mutação gera audit **síncrono** desde Fase 0
|
||||
- Edição inline = **1 evento por campo**
|
||||
- Audit para investigação operacional, não só compliance
|
||||
|
||||
### 8.2 Schema por evento
|
||||
|
||||
| Campo | Obrigatório |
|
||||
|-------|-------------|
|
||||
| WorkOrderId | Sim |
|
||||
| EventType | Sim: Manual / System / Sync / Vendor |
|
||||
| Action | Sim: FieldChanged, StatusChanged, WeekRolled, SyncRejected |
|
||||
| FieldName | Sim para inline |
|
||||
| OldValue, NewValue | Sim |
|
||||
| ActorId, ActorType | Sim para Manual |
|
||||
| Timestamp | Sim UTC |
|
||||
| CorrelationId | Recomendado |
|
||||
| DispatchId | Quando evento é no dispatch |
|
||||
|
||||
### 8.3 Mutação → eventos
|
||||
|
||||
| Mutação | Eventos |
|
||||
|---------|---------|
|
||||
| ChangeStatus | 1× StatusChanged |
|
||||
| Inline cell | 1× FieldChanged por campo |
|
||||
| Auto-schedule | StatusChanged + FieldChanged se aplicável |
|
||||
| Reschedule | FieldChanged + RescheduleCount + audit analytics |
|
||||
| WeekRolled | System WeekRolled + CarriedOver old/new |
|
||||
| Sync skip | Sync SyncRejected |
|
||||
| Vendor checklist | Vendor FieldChanged no Dispatch |
|
||||
|
||||
### 8.4 Contrato FE (Audit tab)
|
||||
|
||||
`{ type, dispatcherId?, action, fieldName?, oldValue, newValue, time }`
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Migration Plan
|
||||
|
||||
### 9.1 Status
|
||||
|
||||
| Legado | LifecycleStatus alvo |
|
||||
|--------|------------------------|
|
||||
| Open | Incomplete |
|
||||
| InProgress | In Progress |
|
||||
| Completed | Complete |
|
||||
| Cancelled | Canceled |
|
||||
| OnHold | On Hold |
|
||||
| Desconhecido | Incomplete + NeedsReview |
|
||||
|
||||
Processo: dry-run staging; 100% classificados; `LegacyStatus` read-only 1 release; rollback documentado.
|
||||
|
||||
### 9.2 WO#
|
||||
|
||||
| Formato legado | Estratégia |
|
||||
|----------------|------------|
|
||||
| Sequencial sync `10000001` | Normalizar 11 dígitos |
|
||||
| `WO-{date}-{random}` | Coexistência; novos só 11 dígitos |
|
||||
| Duplicatas | Resolução manual pré-go-live |
|
||||
|
||||
### 9.3 WorkOrderType
|
||||
|
||||
Mapear SQL existente → enum; NULL → default PO; Overdue = derivado.
|
||||
|
||||
### 9.4 Rollback
|
||||
|
||||
Migrations reversíveis; feature flag SHOC; snapshot pré-migration.
|
||||
|
||||
---
|
||||
|
||||
## 10. Volume Discovery e Search Scalability
|
||||
|
||||
### 10.1 Volume Discovery (Fase 0 — gate Fase 1)
|
||||
|
||||
**Sem premissa de volume.** Métricas obrigatórias:
|
||||
|
||||
| Métrica | Gate |
|
||||
|---------|------|
|
||||
| Total WOs produção/staging | Obrigatório |
|
||||
| WOs/semana (p95) | Obrigatório |
|
||||
| WOs Unscheduled | Obrigatório |
|
||||
| Crescimento mensal | Desejável |
|
||||
|
||||
### 10.2 Tiers e impacto
|
||||
|
||||
| Tier | Total WOs | Listagem semanal | Advanced search |
|
||||
|------|-----------|------------------|-----------------|
|
||||
| **S** | < 25k | Índices simples | SQL filtros compostos |
|
||||
| **M** | 25k–250k | Índices covering | Paginação obrigatória |
|
||||
| **L** | 250k–1M | Read model candidato | Full-text |
|
||||
| **XL** | > 1M | Materialized view | Search dedicado (ARB) |
|
||||
|
||||
Fases 1–3: índices conservadores compatíveis com qualquer tier. Fase 4: implementação conforme tier + load test.
|
||||
|
||||
---
|
||||
|
||||
## 11. Coexistência Multi-Consumidor
|
||||
|
||||
### 11.1 Inventário
|
||||
|
||||
| Consumidor | Conexão | Status |
|
||||
|------------|---------|--------|
|
||||
| **SHOC React** (`seahaven.desing`) | REST JWT | Em desenvolvimento — **único alvo novas features** |
|
||||
| **Blazor** (`SeaHavenIndustries`) | EF direto, não REST | Ativo — manutenção mínima; sunset WO |
|
||||
| **Vendor Portal UI** | REST `X-Vendor-Token` | API ativa; UI externa |
|
||||
| **SyncController + Lambda** | DynamoDB → SQL | Ponte temporária |
|
||||
| **E-mails SendGrid** | Deep links SHOC/Portal | Ativo |
|
||||
| **Mobile** | — | Sem evidência — confirmar com PO |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
SHOC[SHOC React] -->|REST JWT| API[Api REST]
|
||||
VPortal[Vendor Portal] -->|X-Vendor-Token| API
|
||||
Lambda[Lambda ingest] --> DynamoDB[(DynamoDB)]
|
||||
DynamoDB --> Sync[SyncController]
|
||||
Sync --> DB[(SQL Server)]
|
||||
API --> DB
|
||||
Blazor[Blazor EF] -->|legado| DB
|
||||
```
|
||||
|
||||
**Nota:** DynamoDB é **ingestão externa**, não banco operacional. SQL Server é fonte para board, Blazor e Portal.
|
||||
|
||||
### 11.2 Matriz de coexistência
|
||||
|
||||
| Consumidor | Novas features board | Schema | Sunset |
|
||||
|------------|---------------------|--------|--------|
|
||||
| SHOC | Recebe tudo | Consome novos campos | Destino final |
|
||||
| Blazor | Não recebe | Colunas nullable; backward compatible | Módulo WO congela |
|
||||
| Vendor Portal | Não usa board | Dispatch intacto | Mantém |
|
||||
| Sync/Lambda | Indireto | Defaults para novos campos | Após cutover API |
|
||||
| E-mails | Links SHOC | FrontendBaseUrl prod | Atualizar templates |
|
||||
|
||||
### 11.3 Trilhas de entrega
|
||||
|
||||
- **Trilha A SHOC:** fases 0–7; feature flags; piloto dispatchers
|
||||
- **Trilha B Blazor:** smoke EF pós-migration; sem board; bugfix crítico only
|
||||
- **Trilha C Vendor Portal:** regression suite cada release que toca WO/Dispatch
|
||||
- **Trilha D Lambda:** manter até SHOC produção; cutover Fase 7; critério: criação WO + auth serviço estáveis
|
||||
|
||||
### 11.4 Endpoints legado vs board
|
||||
|
||||
- Legado (`GetWorkOrderList`, etc.): manter durante coexistência; congelar contrato
|
||||
- Board: novos contratos; não estender legado com hacks
|
||||
- Deprecação: após 100% dispatchers SHOC + sunset Blazor WO
|
||||
|
||||
### 11.5 Ordem de rollout
|
||||
|
||||
1. Fase 0 staging — gates + migrations
|
||||
2. Gate Blazor smoke EF
|
||||
3. Fase 1 staging — SHOC board feature flag
|
||||
4. Gate Vendor Portal regression
|
||||
5. Fases 2–6 incrementais
|
||||
6. Piloto 1–2 dispatchers
|
||||
7. Rollout gradual
|
||||
8. Sunset Blazor WO
|
||||
9. Cutover Lambda → API
|
||||
10. Deprecação legado + SyncController
|
||||
|
||||
### 11.6 Monitoramento coexistência
|
||||
|
||||
Métricas por consumidor; drift DynamoDB vs SQL; adoção SHOC vs Blazor; alertas job failure.
|
||||
|
||||
### 11.7 Checklist pré-go-live PO
|
||||
|
||||
- App mobile externo?
|
||||
- Data sunset Blazor WO
|
||||
- FrontendBaseUrl produção → SHOC
|
||||
- Owner Lambda cutover
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Phases
|
||||
|
||||
### Fase 0 — Domain Foundation + Governança
|
||||
|
||||
**Objetivo:** Kickoff enterprise — domínio, ownership, volume, audit antes de board.
|
||||
|
||||
**Escopo:** DAR; 3 artefatos (Ownership, Field Matrix, Audit Contract); Volume Discovery; drift SQL/EF; spike vendor; agregados Core+Scheduling+Tracking+Analytics+Completion; enums; RowVersion root+Dispatch; migration dry-run; ManualEditFlag design; audit baseline ChangeStatus/ChangeAssignment; smoke Blazor/Sync/Portal.
|
||||
|
||||
**Não inicia:** Board API, inline edit, jobs.
|
||||
|
||||
**Validação:** 9 gates assinados; audit granularidade testada; tier volume definido.
|
||||
|
||||
---
|
||||
|
||||
### Fase 1 — Weekly Board (leitura)
|
||||
|
||||
**Objetivo:** Tela principal carrega semana operacional.
|
||||
|
||||
**Escopo:** Listagem window-based; Unscheduled; 14 colunas como projeção; `X of Y`; filtros (dispatcher multi, `__unassigned__`, My WOs, tipo, semana); `isPastDue` derivado on-read; vendor via dispatch primário; índices conforme tier volume.
|
||||
|
||||
**Validação:** 1 call = semana + Unscheduled; 12/14 colunas mínimo; gate Vendor Portal regression.
|
||||
|
||||
---
|
||||
|
||||
### Fase 2 — Inline Edit + Concorrência
|
||||
|
||||
**Objetivo:** Edição spreadsheet com regras e conflitos tratados.
|
||||
|
||||
**Escopo:** Update granular; dual RowVersion; audit 1 evento/campo; auto-schedule; rescheduleCount++; bloqueio PastDue flag; cancel read-only; WO# 11 dígitos; testes concorrência.
|
||||
|
||||
---
|
||||
|
||||
### Fase 3 — Criação e Cancelamento
|
||||
|
||||
**Escopo:** Wizard/inline completo; Incomplete inicial; targetWeek; soft cancel; ManualEditFlag na criação; delete admin documentado.
|
||||
|
||||
---
|
||||
|
||||
### Fase 4 — Search
|
||||
|
||||
**Escopo:** Busca contextual semana; advanced search cross-week conforme tier; load test; sem implementar advanced sem tier definido.
|
||||
|
||||
---
|
||||
|
||||
### Fase 5 — Scheduled Domain Events
|
||||
|
||||
**Escopo:** WeekRolled → CarriedOver++; cache opcional PastDue; idempotência; monitoramento falha; on-read sempre correto.
|
||||
|
||||
---
|
||||
|
||||
### Fase 6 — Completion Doc e Slide-over
|
||||
|
||||
**Escopo:** docStatus WO-level; templates por serviço; Comments/Audit/Media alinhados ao FE.
|
||||
|
||||
---
|
||||
|
||||
### Fase 7 — Rollout e Produção
|
||||
|
||||
**Escopo:** Piloto; rollout gradual; sunset Blazor WO; cutover Lambda; deprecação legado; UAT; zero P1 duas semanas; dashboards saúde.
|
||||
|
||||
---
|
||||
|
||||
## 13. Critical Path Analysis
|
||||
|
||||
### Must Have
|
||||
1. Listagem window-based + Unscheduled
|
||||
2. Projeção 14 colunas
|
||||
3. Agregados domínio + enums
|
||||
4. Update granular por campo
|
||||
5. Status 10 labels + flag PastDue
|
||||
6. Lookups
|
||||
7. Criação wizard
|
||||
8. WO# 11 dígitos
|
||||
9. DAR + 3 artefatos Fase 0
|
||||
|
||||
### Should Have
|
||||
10. Domain events CarriedOver
|
||||
11. Advanced search
|
||||
12. Regras mutação completas
|
||||
13. Soft cancel enforcement
|
||||
14. docStatus WO-level
|
||||
15. Auth ativa
|
||||
|
||||
### Nice to Have
|
||||
16. Reorder intra-dia persistido (FE local)
|
||||
17. Flags pessoais session-only
|
||||
18. Paginação 12/24/48/96
|
||||
19. Bulk select
|
||||
|
||||
---
|
||||
|
||||
## 14. Frontend Impact Assessment
|
||||
|
||||
| Gap | UX | Severidade | Workaround |
|
||||
|-----|-----|------------|------------|
|
||||
| Sem listagem semanal | Tela inutilizável | Critical | Mock FE |
|
||||
| 14 colunas incompletas | Board ilegível | Critical | Nenhum em prod |
|
||||
| Sem inline edit | Core inoperante | Critical | Modal legado |
|
||||
| Sem advanced search | WOs históricos invisíveis | High | Busca por ID |
|
||||
| Status incompatíveis | Decisões erradas | Critical | Hardcode FE |
|
||||
| Sem jobs (derivados) | Badges stale se só client | High | On-read BE resolve PastDue |
|
||||
| Sem docStatus | COMP DOC vazia | High | Ocultar coluna |
|
||||
| WO# formato errado | Criação bloqueada | Critical | — |
|
||||
| Ownership Sync vs SHOC | Overwrite silencioso | Critical | Field Matrix Fase 0 |
|
||||
| Auth desabilitada | Risco segurança | High | API gateway |
|
||||
|
||||
---
|
||||
|
||||
## 15. Business Rules Assessment
|
||||
|
||||
| Regra | Status | Impacto |
|
||||
|-------|--------|---------|
|
||||
| Auto-schedule Incomplete→Scheduled | Unsupported | Status incorreto |
|
||||
| Reschedule → rescheduleCount++ | Unsupported | Badge ↻ ausente |
|
||||
| Reschedule limpa PastDue (derivado) | Unsupported | Flag stale até re-read |
|
||||
| Status bloqueado se PastDue | Unsupported | Bypass via API |
|
||||
| WO# único 11 dígitos | Unsupported | Duplicatas |
|
||||
| Cancel read-only | Partial | Edição pós-cancel |
|
||||
| WeekRolled → carriedOver | Unsupported | Badge ↷N ausente |
|
||||
| Completion doc por serviço | Unsupported | Workflow manual |
|
||||
| Week-only scheduling | Unsupported | Wizard incompleto |
|
||||
|
||||
---
|
||||
|
||||
## 16. Data Contract Readiness
|
||||
|
||||
| Área | Readiness % | Gap principal |
|
||||
|------|-------------|---------------|
|
||||
| List Views board | 5% | 14 colunas, schedule, vendor, flags |
|
||||
| Detail slide-over | 40% | Derivados, docStatus, schema |
|
||||
| Search | 10% | Cross-week, date ranges |
|
||||
| Lookups | 20% | initials, site code, PM, tech |
|
||||
| Scheduling | 25% | targetWeek, ScheduledEnd |
|
||||
| Status | 30% | 10 labels + flag |
|
||||
| Completion | 0% | docStatus, templates |
|
||||
| Comments | 60% | Schema |
|
||||
| Audit | 50% | type system, granularidade |
|
||||
| Media | 45% | category |
|
||||
|
||||
**Média ponderada: ~28%**
|
||||
|
||||
---
|
||||
|
||||
## 17. Testing Strategy
|
||||
|
||||
### Functional
|
||||
Board semanal, 14 colunas, filtros, inline por campo, regras negócio, criação/cancelamento, docStatus, status+flag, ownership rejection sync.
|
||||
|
||||
### Integration
|
||||
SHOC E2E por fase; Vendor Portal regression; Sync; auth; dual RowVersion.
|
||||
|
||||
### Regression
|
||||
Endpoints legados; Blazor smoke EF; Portal checklist/signoff.
|
||||
|
||||
### UAT
|
||||
Roteiros dispatchers reais; sign-off PO por fase.
|
||||
|
||||
### Data validation
|
||||
WO# migration; status mapping; derivados on-read vs contadores; referencial.
|
||||
|
||||
### Migration validation
|
||||
Dump produção espelho; antes/depois contagens; rollback por fase.
|
||||
|
||||
### Fase-specific
|
||||
- **0:** volume queries; audit granularidade; sync skip
|
||||
- **2:** concurrency suite; 409 handling
|
||||
- **4:** load test tier
|
||||
- **5:** job failure injection; on-read correctness
|
||||
|
||||
---
|
||||
|
||||
## 18. Risk Matrix
|
||||
|
||||
| Risco | Prob. | Impacto | Mitigação |
|
||||
|-------|-------|---------|-----------|
|
||||
| Board inoperante em prod | Alta | Crítico | Fase 1 gate; E2E |
|
||||
| Ownership ambíguo Sync vs SHOC | Alta | Crítico | Field Ownership Matrix |
|
||||
| God Entity creep | Média | Alto | Growth Watchlist; ARB |
|
||||
| Conflito cross-agregado | Média | Alto | RowVersion root + Dispatch |
|
||||
| Job falha estado errado | Alta | Alto | Derivados on-read |
|
||||
| Vendor drift Dispatch vs WO | Alta | Crítico | Spike; projeção only |
|
||||
| Status migration incorreta | Média | Crítico | LegacyStatus; dry-run |
|
||||
| Volume desconhecido → índices errados | Alta | Alto | Volume Discovery Fase 0 |
|
||||
| Audit inútil investigação | Média | Médio | Event Contract |
|
||||
| Regressão Vendor Portal | Média | Crítico | Suite dedicada |
|
||||
| Blazor vs SHOC divergência | Alta | Alto | Congelar Blazor WO |
|
||||
| Auth bloqueia integrações | Média | Alto | Inventário consumidores |
|
||||
| CarriedOver recalculado errado | Baixa | Médio | Só via WeekRolled |
|
||||
| Scheduling aggregate creep | Média | Alto | Watchlist §4.2 |
|
||||
|
||||
---
|
||||
|
||||
## 19. Prioritized Backlog
|
||||
|
||||
Ordenado por sequência de implementação.
|
||||
|
||||
| # | Item | P | Fase | Dep | Complex. | Risco |
|
||||
|---|------|---|------|-----|----------|-------|
|
||||
| 1 | DAR completo + sign-off CTO | P0 | 0 | — | M | Alto |
|
||||
| 2 | Data Ownership Model | P0 | 0 | — | S | Alto |
|
||||
| 3 | Field Ownership Matrix + ManualEditFlag | P0 | 0 | 2 | M | Alto |
|
||||
| 4 | Audit Event Contract + baseline | P0 | 0 | — | M | Médio |
|
||||
| 5 | Volume Discovery Report | P0 | 0 | — | S | Alto |
|
||||
| 6 | Database drift SQL vs EF | P0 | 0 | — | M | Médio |
|
||||
| 7 | Spike vendor/dispatch source of truth | P0 | 0 | — | M | Alto |
|
||||
| 8 | Agregados domínio (anti God Entity) | P0 | 0 | 1 | L | Alto |
|
||||
| 9 | Matriz Persistido vs Derivado | P0 | 0 | 1 | S | Médio |
|
||||
| 10 | RowVersion root + Dispatch | P0 | 0 | 8 | M | Alto |
|
||||
| 11 | Enums Lifecycle + Flags + WorkOrderType | P0 | 0 | 8 | M | Médio |
|
||||
| 12 | Data migration dry-run | P0 | 0 | 8 | M | Alto |
|
||||
| 13 | Inventário consumidores + coexistência | P0 | 0 | — | S | Médio |
|
||||
| 14 | Smoke Blazor EF + Sync + Portal | P0 | 0 | 8 | M | Alto |
|
||||
| 15 | Normalização WO# 11 dígitos | P0 | 0 | 12 | M | Alto |
|
||||
| 16 | Reativar auth endpoints WO | P0 | 0 | — | S | Médio |
|
||||
| 17 | Lookups (dispatchers, sites, PM, tech) | P0 | 0–1 | — | M | Baixo |
|
||||
| 18 | Listagem window-based + Unscheduled | P0 | 1 | 8,11,17 | L | Alto |
|
||||
| 19 | Projeção 14 colunas | P0 | 1 | 18 | L | Alto |
|
||||
| 20 | isPastDue derivado on-read | P0 | 1 | 19 | S | Baixo |
|
||||
| 21 | Vendor via dispatch primário | P0 | 1 | 7,19 | M | Alto |
|
||||
| 22 | Filtros principais + My WOs | P0 | 1 | 18 | M | Médio |
|
||||
| 23 | Contador X of Y | P1 | 1 | 18 | S | Baixo |
|
||||
| 24 | Sync merge por field ownership | P0 | 0–1 | 3 | M | Alto |
|
||||
| 25 | Update granular inline | P0 | 2 | 19 | L | Alto |
|
||||
| 26 | Audit 1 evento por campo | P0 | 2 | 4,25 | M | Médio |
|
||||
| 27 | Concurrency integration tests | P0 | 2 | 10,25 | M | Alto |
|
||||
| 28 | Auto-schedule Incomplete→Scheduled | P0 | 2 | 25 | M | Médio |
|
||||
| 29 | RescheduleCount++ domain event | P0 | 2 | 25 | S | Baixo |
|
||||
| 30 | Bloqueio status se PastDue flag | P0 | 2 | 25 | S | Baixo |
|
||||
| 31 | Cancel read-only enforcement | P1 | 2 | 25 | S | Baixo |
|
||||
| 32 | Criação wizard/inline completa | P0 | 3 | 25 | M | Médio |
|
||||
| 33 | Week-only scheduling targetWeek | P1 | 3 | 32 | M | Médio |
|
||||
| 34 | Soft cancel dedicado | P1 | 3 | 31 | S | Baixo |
|
||||
| 35 | Busca contextual semana | P1 | 4 | 19 | M | Baixo |
|
||||
| 36 | Advanced search cross-week | P1 | 4 | 5,19 | L | Médio |
|
||||
| 37 | Load test search por tier | P0 | 4 | 5 | M | Alto |
|
||||
| 38 | WeekRolled domain event + CarriedOver | P1 | 5 | 8 | M | Médio |
|
||||
| 39 | Cache opcional PastDue | P2 | 5 | 20 | S | Baixo |
|
||||
| 40 | docStatus WO-level | P1 | 6 | 8 | M | Médio |
|
||||
| 41 | Completion templates por serviço | P2 | 6 | 40 | L | Médio |
|
||||
| 42 | Slide-over Comments/Audit/Media | P2 | 6 | 4,40 | M | Baixo |
|
||||
| 43 | Regression suite legado + Portal | P0 | cont. | — | M | Alto |
|
||||
| 44 | UAT dispatchers por fase | P0 | cont. | — | S | Médio |
|
||||
| 45 | Piloto + rollout gradual SHOC | P0 | 7 | 1–42 | M | Alto |
|
||||
| 46 | Sunset Blazor WO | P1 | 7 | 45 | S | Médio |
|
||||
| 47 | Cutover Lambda → API | P1 | 7 | 32 | M | Alto |
|
||||
| 48 | Deprecação endpoints legado + Sync | P2 | 7 | 45 | M | Médio |
|
||||
| 49 | Monitoramento coexistência | P1 | 7 | 45 | S | Baixo |
|
||||
|
||||
---
|
||||
|
||||
## 20. Cronograma indicativo
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Work Orders Board
|
||||
dateFormat YYYY-MM-DD
|
||||
section Fundacao
|
||||
DAR_arteFatos_dominio :2026-07-01, 8w
|
||||
section Board
|
||||
Listagem_14colunas :2026-09-01, 8w
|
||||
section Mutacao
|
||||
Inline_concorrencia :2026-11-01, 8w
|
||||
Criacao_cancelamento :2026-11-15, 6w
|
||||
section Inteligencia
|
||||
Search :2027-01-01, 6w
|
||||
Domain_events :2027-01-15, 4w
|
||||
section Completion
|
||||
DocStatus_slideover :2027-02-15, 6w
|
||||
section Producao
|
||||
Piloto_rollout :2027-04-01, 8w
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 21. Stakeholders
|
||||
|
||||
| Papel | Responsabilidade |
|
||||
|-------|------------------|
|
||||
| CEO / Sponsor | Investimento XL; prioridade SHOC sobre Blazor |
|
||||
| Product Owner | Aceite por fase; semana operacional; regras ambíguas |
|
||||
| CTO / Arquiteto | DAR; 3 artefatos; ownership; cutover Lambda |
|
||||
| Backend | Fases 0–6 domínio e contratos |
|
||||
| Frontend SHOC | Integração; feature flags; remover mocks |
|
||||
| QA | Testes §17; UAT; concurrency |
|
||||
| Documentação | Contratos; guia consumidores legados |
|
||||
| Operações | Jobs; alertas; monitoramento coexistência |
|
||||
|
||||
---
|
||||
|
||||
## 22. Critérios de prontidão para kickoff (9.5+)
|
||||
|
||||
| Área | Critério |
|
||||
|------|----------|
|
||||
| Domain Design | DAR aprovado; agregados; Watchlist |
|
||||
| Source of Truth | Field Ownership Matrix assinada |
|
||||
| Data Governance | Data Ownership Model completo |
|
||||
| Concurrency | RowVersion root + Dispatch; testes Fase 2 |
|
||||
| Sync | Cenários explícitos; ManualEditFlag |
|
||||
| Audit | Event Contract; 1 evento/campo inline |
|
||||
| Migration | Dry-run; LegacyStatus rollback |
|
||||
| Volume | Discovery Fase 0; tier definido |
|
||||
| Jobs | Domain events; on-read correto se job falha |
|
||||
| Coexistência | Inventário; trilhas; smoke Blazor/Portal/Sync |
|
||||
|
||||
---
|
||||
|
||||
## 23. Conclusão
|
||||
|
||||
Programa **viável e de grande porte (XL)**. O caminho crítico é:
|
||||
|
||||
```text
|
||||
Resolver domínio e governança (Fase 0)
|
||||
→ Board semanal (Fase 1)
|
||||
→ Inline edit + concorrência (Fase 2)
|
||||
→ Paridade funcional progressiva (Fases 3–6)
|
||||
→ Rollout seguro (Fase 7)
|
||||
```
|
||||
|
||||
O documento integra: gap assessment da auditoria, modelo de domínio com agregados, governança de ownership, concorrência multi-agregado, migração de dados, coexistência SHOC/Blazor/Portal/Lambda, e backlog executável — pronto para Architecture Review Board e kickoff de implementação enterprise.
|
||||
|
||||
**Próximo passo imediato:** executar Fase 0 — produzir e assinar os três artefatos (Data Ownership Model, Field Ownership Matrix, Audit Event Contract) antes de qualquer migration de schema.
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
# Spike Search Strategy — `GetWorkOrdersBoard`
|
||||
|
||||
**Data:** 2026-06-19
|
||||
**Gate:** G3 (pré-requisito Fase 1)
|
||||
**Objetivo:** Definir busca mínima viável para o Schedule Board sem degradar performance.
|
||||
|
||||
---
|
||||
|
||||
## Estado atual (`GetWorkOrderList`)
|
||||
|
||||
Implementação em `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs` (`GetWorkOrderListPagedAsync`):
|
||||
|
||||
| Aspecto | Comportamento atual |
|
||||
|---------|---------------------|
|
||||
| Paginação | `page` / `pageSize` (default 12), sem cap |
|
||||
| Janela temporal | **Ausente** — sem filtro `ScheduledDate` |
|
||||
| Search | `Contains` + `ToLower()` em 5 campos |
|
||||
| Campos search | `InternalWONumber`, `WorkerOrderNumber`, `WorkerOrderTitle`, `Locations.Title`, `Locations.Name` |
|
||||
| Campos omitidos | `SiteCode`, `Trade`, `Problem`, vendor, dispatcher por ID |
|
||||
| Joins | `Locations`, `AssignToUser` (AspNetUsers) |
|
||||
| Vendor/dispatch | **Não incluídos** |
|
||||
| Projeção extra | Subquery `lastUpdated` via Comments + WorkOrderAuditLogs por linha |
|
||||
|
||||
### Padrão SQL gerado (search)
|
||||
|
||||
```sql
|
||||
WHERE LOWER(InternalWONumber) LIKE '%term%'
|
||||
OR LOWER(WorkerOrderNumber) LIKE '%term%'
|
||||
OR LOWER(WorkerOrderTitle) LIKE '%term%'
|
||||
OR LOWER(Locations.Name) LIKE '%term%'
|
||||
...
|
||||
```
|
||||
|
||||
Leading wildcard → table scan em colunas `nvarchar(max)`.
|
||||
|
||||
---
|
||||
|
||||
## Respostas às perguntas do spike
|
||||
|
||||
| # | Pergunta | Decisão Fase 1 |
|
||||
|---|----------|----------------|
|
||||
| S1 | Volume típico por semana | Assumir **< 200 WOs/semana** até medir em prod; threshold fallback = **300** |
|
||||
| S2 | Campos pesquisáveis mínimos | `internalWONumber`, `siteCode`, `assigneeId[]` (exato), `locationId` |
|
||||
| S3 | LIKE vs full-text | **Sem full-text** Fase 1; `StartsWith` em WO# e siteCode; evitar `Contains` em title/location no servidor |
|
||||
| S4 | Escopo | Search **sempre scoped** a `scheduledFrom` / `scheduledTo` (obrigatório no endpoint) |
|
||||
| S5 | Vendor/tech no search | **Fase 2** — exige join `Dispatches → Vendor` |
|
||||
|
||||
---
|
||||
|
||||
## Estratégia em 2 camadas
|
||||
|
||||
```
|
||||
Request GetWorkOrdersBoard
|
||||
│
|
||||
├─► 1. Filtro janela ScheduledDate (obrigatório)
|
||||
│
|
||||
├─► 2. Filtros exatos: status, assigneeId[], locationId
|
||||
│
|
||||
├─► 3. COUNT no escopo
|
||||
│
|
||||
├─► count <= 300?
|
||||
│ ├─ SIM → retornar semana inteira (sem paginação Fase 1)
|
||||
│ │ search textual extra no FE: title, locationLabel, serviceType
|
||||
│ └─ NÃO → exigir critério indexável no servidor
|
||||
│ (woNumber ou siteCode StartsWith)
|
||||
│
|
||||
└─► Response: { scheduledFrom, scheduledTo, totalCount, items[] }
|
||||
```
|
||||
|
||||
### Campos Fase 1 vs Fase 2
|
||||
|
||||
| Campo | Fase 1 (servidor) | Fase 1 (client fallback) | Fase 2 |
|
||||
|-------|-------------------|--------------------------|--------|
|
||||
| `internalWONumber` / `workerOrderNumber` | `StartsWith` | — | — |
|
||||
| `siteCode` | `StartsWith` ou igualdade | — | — |
|
||||
| `assigneeId` | multi-select exato | — | — |
|
||||
| `locationId` | exato | — | — |
|
||||
| `status` | exato / multi | pills client-side | — |
|
||||
| `workerOrderTitle` | — | client-side se count ≤ 300 | server `Contains` opcional |
|
||||
| `locationLabel` | — | client-side | — |
|
||||
| `serviceType` (trade/category) | — | client-side | server join |
|
||||
| `vendorCompany` / `vendorTechnician` | — | — | join Dispatch→Vendor |
|
||||
| `assigneeName` | — | client-side | evitar join por nome |
|
||||
|
||||
---
|
||||
|
||||
## Query plan esperado (Fase 1)
|
||||
|
||||
### Caminho feliz (semana típica, ≤ 300 rows)
|
||||
|
||||
1. **Index seek** em `IX_workOrders_ScheduledDate` (proposto) com range `[scheduledFrom, scheduledTo+1day)`
|
||||
2. Filtro `istemplate != true`
|
||||
3. Filtros exatos em `AssignTo`, `LocationId`, `Status` (AND)
|
||||
4. **Sem** search textual no servidor se `search` vazio
|
||||
5. Join LEFT `Locations`, `AssignToUser`, LEFT `Dispatches` + `Vendor` (Fase 1 board — vendor no DTO, não no search)
|
||||
6. Projeção flat para `WorkOrderBoardRowDto` — **sem** subquery `lastUpdated`
|
||||
|
||||
### Caminho search servidor (count > 300 ou user digitou termo indexável)
|
||||
|
||||
1. Mesma janela + filtros exatos
|
||||
2. AND (`InternalWONumber LIKE 'term%'` OR `SiteCode LIKE 'term%'`)
|
||||
3. Se count ainda > 300 → HTTP 400 com mensagem "Refine search or narrow date window"
|
||||
|
||||
### Anti-padrões proibidos no board endpoint
|
||||
|
||||
- `pageSize=500` como gambiarra de fetch semanal
|
||||
- `Contains('%x%')` em múltiplas tabelas sem janela temporal
|
||||
- Filtro assignee por nome concatenado (`FirstName + LastName`) — usar `assigneeId`
|
||||
- Subquery correlated Comments/AuditLog por row
|
||||
|
||||
---
|
||||
|
||||
## Índices recomendados
|
||||
|
||||
Migration separada **após aprovação** desta spike (não incluir na Fase 1 code sem review):
|
||||
|
||||
| Índice | Coluna(s) | Prioridade | Nota |
|
||||
|--------|-----------|------------|------|
|
||||
| `IX_workOrders_ScheduledDate` | `ScheduledDate` | **P1** | Filtro de janela do board |
|
||||
| `IX_workOrders_InternalWONumber` | `InternalWONumber` | P2 | Requer alterar coluna de `nvarchar(max)` → `nvarchar(50)` |
|
||||
| `IX_workOrders_SiteCode` | `SiteCode` | P2 | Idem — tamanho fixo ~50 |
|
||||
| `IX_workOrders_AssignTo` | `AssignTo` | existente | Multi-select dispatcher |
|
||||
| `IX_workOrders_LocationId` | `LocationId` | existente | Filtro location |
|
||||
|
||||
**Full-text index:** não recomendado Fase 1 — volume semanal baixo + fallback client-side suficiente.
|
||||
|
||||
---
|
||||
|
||||
## Referência de implementação
|
||||
|
||||
Copiar padrão de janela temporal de `DispatchController.List`:
|
||||
|
||||
```csharp
|
||||
if (dateFrom.HasValue)
|
||||
q = q.Where(d => d.DispatchedAt >= dateFrom.Value);
|
||||
if (dateTo.HasValue)
|
||||
{
|
||||
var end = dateTo.Value.Date.AddDays(1);
|
||||
q = q.Where(d => d.DispatchedAt < end);
|
||||
}
|
||||
```
|
||||
|
||||
Adaptar para `WorkOrder.ScheduledDate` no board endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Parâmetros propostos — `GetWorkOrdersBoard`
|
||||
|
||||
| Parâmetro | Tipo | Obrigatório | Descrição |
|
||||
|-----------|------|-------------|-----------|
|
||||
| `scheduledFrom` | `DateOnly` | **Sim** | Início da semana/janela |
|
||||
| `scheduledTo` | `DateOnly` | **Sim** | Fim da janela (inclusivo) |
|
||||
| `search` | `string` | Não | WO# ou siteCode prefix |
|
||||
| `assignee` | `string[]` | Não | IDs de dispatcher (multi) |
|
||||
| `status` | `string[]` | Não | Status canônico API |
|
||||
| `locationId` | `int?` | Não | Filtro location |
|
||||
|
||||
**Limite hard janela:** max **90 dias** (Fase 2 advanced search cross-week).
|
||||
|
||||
---
|
||||
|
||||
## Threshold e fallback
|
||||
|
||||
| Constante | Valor | Justificativa |
|
||||
|-----------|-------|---------------|
|
||||
| `BoardSearchClientSideThreshold` | **300** | Payload ~300 rows × ~500 bytes ≈ 150 KB — aceitável para 1 call/semana |
|
||||
| Sem paginação Fase 1 | — | Janela semanal é o filtro natural |
|
||||
| Paginação Fase 2 | cursor/offset | Só quando janela > 1 semana com search global |
|
||||
|
||||
---
|
||||
|
||||
## Conclusão
|
||||
|
||||
Fase 1 adota **window-first, search-second**:
|
||||
|
||||
1. Janela `ScheduledDate` obrigatória + índice dedicado
|
||||
2. Search servidor mínimo (`StartsWith` em identificadores)
|
||||
3. Fallback client-side para campos ricos quando count ≤ 300
|
||||
4. Vendor/tech e full-text adiados para Fase 2
|
||||
|
||||
Esta estratégia desbloqueia `GetWorkOrdersBoard` sem replicar os anti-padrões de `GetWorkOrderListPagedAsync`.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# Fase 0 — Work Orders Foundation
|
||||
|
||||
Documentação e gates da Fase 0 (Domain Foundation + Governança).
|
||||
|
||||
## Governança
|
||||
|
||||
- [DAR — Domain Architecture Review](dar-domain-architecture-review.md)
|
||||
- [Matriz Persistido vs Derivado](dar-persisted-vs-derived-matrix.md)
|
||||
- [Data Ownership Model](data-ownership-model.md)
|
||||
- [Field Ownership Matrix](field-ownership-matrix.md)
|
||||
- [ManualEditFlag Design](manual-edit-flag-design.md)
|
||||
- [Audit Event Contract](audit-event-contract.md)
|
||||
|
||||
## Discovery
|
||||
|
||||
- [Volume Discovery Report](volume-discovery-report.md)
|
||||
- [Database Drift Report](database-drift-report.md)
|
||||
- [Consumer Inventory](consumer-inventory.md)
|
||||
- [ADR Vendor Source of Truth](adr-vendor-source-of-truth.md)
|
||||
|
||||
## Validação
|
||||
|
||||
- [RowVersion Design](rowversion-design.md)
|
||||
- [Migration Dry-Run Report](migration-dry-run-report.md)
|
||||
- [Smoke Checklist](smoke-checklist.md)
|
||||
- [Gates Sign-Off](phase-0-gates-signoff.md)
|
||||
|
||||
## Código entregue
|
||||
|
||||
- Migration: `Data.SeaHavenIndustries/Migrations/20260624163145_Phase0_DomainFoundation.cs`
|
||||
- Serviços: `IWorkOrderAuditService`, `IWorkOrderFieldLockService`, `ISyncFieldMergePolicy`
|
||||
- Testes: `SeaHavenIndustries.Tests` (12 testes)
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# ADR — Vendor Source of Truth
|
||||
|
||||
**Status:** Accepted (Fase 0 spike)
|
||||
**Decisores:** Backend + Arquiteto
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
O board SHOC exibe colunas VENDOR e APPT na listagem semanal. Hoje vendor existe apenas no agregado Dispatch; a entidade WorkOrder não possui referência ao dispatch primário.
|
||||
|
||||
---
|
||||
|
||||
## Decisão
|
||||
|
||||
1. **WorkOrder.PrimaryDispatchId** (nullable FK → Dispatches) identifica o dispatch canônico para projeção board.
|
||||
2. Colunas VENDOR/APPT = **projeção read-only** via join no dispatch primário (Vendor.Name, Dispatch.ScheduledDate).
|
||||
3. Edição inline vendor (Fase 2) muta **Dispatch**, não WorkOrder. Bump `Dispatch.RowVersion`.
|
||||
4. WO sem dispatch: primeira edição vendor cria dispatch primário e seta `PrimaryDispatchId`.
|
||||
5. **Proibido** adicionar VendorId, TechName, TechPhone na tabela workOrders.
|
||||
|
||||
---
|
||||
|
||||
## Query de projeção (spike)
|
||||
|
||||
```sql
|
||||
SELECT wo.Id,
|
||||
wo.InternalWONumber,
|
||||
d.Id AS DispatchId,
|
||||
v.Name AS VendorName,
|
||||
d.ScheduledDate AS ApptDate,
|
||||
d.Status AS DispatchStatus
|
||||
FROM workOrders wo
|
||||
LEFT JOIN Dispatches d ON d.Id = wo.PrimaryDispatchId
|
||||
LEFT JOIN Vendors v ON v.Id = d.VendorId
|
||||
WHERE wo.IsDeleted IS NULL OR wo.IsDeleted = 0;
|
||||
```
|
||||
|
||||
Implementação C# em `DispatchDataService.GetPrimaryDispatchProjectionAsync(workOrderId)` (Fase 1).
|
||||
|
||||
---
|
||||
|
||||
## Seleção do dispatch primário
|
||||
|
||||
| Cenário | Regra |
|
||||
|---------|-------|
|
||||
| PrimaryDispatchId setado | Usar esse dispatch |
|
||||
| Null + exatamente 1 dispatch | Auto-set PrimaryDispatchId na migration backfill |
|
||||
| Null + N dispatches | Usar dispatch mais recente por DispatchedAt; log para revisão manual |
|
||||
| Zero dispatches | Vendor columns vazias no board |
|
||||
|
||||
---
|
||||
|
||||
## Consequências
|
||||
|
||||
- **Positivo:** Zero drift vendor WO vs Dispatch; Portal regression isolada.
|
||||
- **Negativo:** Join extra na listagem board — mitigado por índice em PrimaryDispatchId.
|
||||
- **Fase 0:** Coluna + FK + backfill script no dry-run; endpoint board na Fase 1.
|
||||
|
||||
---
|
||||
|
||||
## Critérios de aceite spike
|
||||
|
||||
- [x] ADR documentado
|
||||
- [x] Query prototipada
|
||||
- [ ] PrimaryDispatchId na migration Phase0
|
||||
- [ ] Backfill documentado no dry-run report
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
# Audit Event Contract — Work Orders
|
||||
|
||||
**Fase:** 0
|
||||
**Status:** Aprovado para implementação baseline
|
||||
**Schema EF:** `WorkOrderAuditLog` estendido
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípios
|
||||
|
||||
- Toda mutação gera audit **síncrono** desde Fase 0.
|
||||
- Edição inline (Fase 2) = **1 evento por campo**.
|
||||
- Audit serve investigação operacional, não só compliance.
|
||||
|
||||
---
|
||||
|
||||
## 2. Schema por evento
|
||||
|
||||
| Campo | Tipo | Obrigatório |
|
||||
|-------|------|-------------|
|
||||
| Id | int | Sim (PK) |
|
||||
| WorkOrderId | int | Sim |
|
||||
| EventType | enum string | Sim: Manual, System, Sync, Vendor |
|
||||
| Action | string | Sim: FieldChanged, StatusChanged, WeekRolled, SyncRejected, AssignmentChanged |
|
||||
| FieldName | string | Sim para inline / field change |
|
||||
| OldValue | string | Sim |
|
||||
| NewValue | string | Sim |
|
||||
| UserId / ActorId | string | Sim para Manual |
|
||||
| ActorType | enum string | Sim: Dispatcher, Vendor, System, Sync |
|
||||
| CreatedAt | DateTime UTC | Sim |
|
||||
| CorrelationId | string | Recomendado |
|
||||
| DispatchId | int? | Quando evento é no dispatch |
|
||||
|
||||
**Compatibilidade:** colunas `Action` legada mapeada para novo `Action`; `UserId` = ActorId para Manual.
|
||||
|
||||
---
|
||||
|
||||
## 3. Enums
|
||||
|
||||
```csharp
|
||||
AuditEventType: Manual | System | Sync | Vendor
|
||||
AuditActorType: Dispatcher | Vendor | System | Sync
|
||||
AuditActionType: FieldChanged | StatusChanged | AssignmentChanged | WeekRolled | SyncRejected | Create | Delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mutação → eventos
|
||||
|
||||
| Mutação | Eventos |
|
||||
|---------|---------|
|
||||
| ChangeStatus | 1× StatusChanged (EventType=Manual) |
|
||||
| ChangeAssignment | 1× AssignmentChanged (FieldName=AssignTo) |
|
||||
| Inline cell (Fase 2) | 1× FieldChanged por campo |
|
||||
| Auto-schedule (Fase 2) | StatusChanged + FieldChanged se aplicável |
|
||||
| Reschedule (Fase 2) | FieldChanged + analytics |
|
||||
| WeekRolled (Fase 5) | System WeekRolled + CarriedOver |
|
||||
| Sync skip | Sync SyncRejected |
|
||||
| Vendor checklist | Vendor FieldChanged + DispatchId |
|
||||
|
||||
---
|
||||
|
||||
## 5. Contrato FE (Audit tab)
|
||||
|
||||
```typescript
|
||||
interface AuditEntry {
|
||||
type: 'manual' | 'system' | 'sync' | 'vendor';
|
||||
dispatcherId?: string;
|
||||
action: string;
|
||||
fieldName?: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
time: string; // ISO UTC
|
||||
dispatchId?: number;
|
||||
}
|
||||
```
|
||||
|
||||
Mapeamento API → FE:
|
||||
|
||||
| API EventType | FE type |
|
||||
|---------------|---------|
|
||||
| Manual | manual |
|
||||
| System | system |
|
||||
| Sync | sync |
|
||||
| Vendor | vendor |
|
||||
|
||||
---
|
||||
|
||||
## 6. Baseline Fase 0 (endpoints)
|
||||
|
||||
| Endpoint | Garantia |
|
||||
|----------|----------|
|
||||
| POST ChangeStatus | 1 audit StatusChanged; Old/New preenchidos |
|
||||
| POST ChangeAssignment | 1 audit AssignmentChanged; nomes usuário em Old/New |
|
||||
|
||||
Implementação via `IWorkOrderAuditService`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Critérios de aceite
|
||||
|
||||
- [ ] Migration estende WorkOrderAuditLog
|
||||
- [ ] ChangeStatus/ChangeAssignment usam serviço central
|
||||
- [ ] Testes assertam 1 evento por mutação
|
||||
|
||||
**Assinatura Backend Lead:** _________________ Data: _______
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
# Consumer Inventory — Work Orders
|
||||
|
||||
**Fase:** 0
|
||||
**Referência:** roadmap §11
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventário de consumidores
|
||||
|
||||
| Consumidor | Conexão | Código principal | Campos mutados | Audit hoje? |
|
||||
|------------|---------|------------------|----------------|-------------|
|
||||
| **SHOC React** | REST JWT | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` | Status, AssignTo, CRUD parcial via Service | Parcial (ChangeStatus/Assignment) |
|
||||
| **Blazor** | EF direto | `SeaHavenIndustries/Data/Services/WorkorderService.cs` | CRUD completo, comments, status | **Não** |
|
||||
| **Vendor Portal** | REST token | `Api.SeaHavenIndustries/Controllers/VendorPortalController.cs` | Dispatch checklist, signoff, status | Sim (ad hoc) |
|
||||
| **Sync/Lambda** | DynamoDB → SQL | `Api.SeaHavenIndustries/Controllers/SyncController.cs` | Upsert WO por ExternalWorkOrderId | **Não** |
|
||||
| **Dispatch API** | REST | `DispatchController.cs` | Dispatch create/update/verify | Sim |
|
||||
| **E-mail SendGrid** | Deep links | `Helper/SendMessage` | Nenhum (read-only links) | N/A |
|
||||
|
||||
---
|
||||
|
||||
## 2. Diagrama
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
SHOC[SHOC_React] -->|REST_JWT| API[Api_REST]
|
||||
VPortal[Vendor_Portal] -->|X_Vendor_Token| API
|
||||
Lambda[Lambda_ingest] --> DynamoDB[(DynamoDB)]
|
||||
DynamoDB --> Sync[SyncController]
|
||||
Sync --> DB[(SQL_Server)]
|
||||
API --> DB
|
||||
Blazor[Blazor_EF] -->|legado| DB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Trilhas de entrega (coexistência)
|
||||
|
||||
| Trilha | Escopo Fase 0+ |
|
||||
|--------|----------------|
|
||||
| **A — SHOC** | Fases 0–7; feature flags; piloto dispatchers |
|
||||
| **B — Blazor** | Smoke EF pós-migration; bugfix crítico only |
|
||||
| **C — Vendor Portal** | Regression checklist cada release WO/Dispatch |
|
||||
| **D — Lambda/Sync** | Manter até SHOC produção; merge policy Fase 0 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints legado vs board
|
||||
|
||||
| Tipo | Exemplos | Política |
|
||||
|------|----------|----------|
|
||||
| Legado | GetWorkOrderList, ChangeStatus | Congelar contrato |
|
||||
| Board (Fase 1+) | GetWorkOrdersBoard | Novos contratos separados |
|
||||
|
||||
---
|
||||
|
||||
## 5. Gaps Fase 0 endereçados
|
||||
|
||||
| Gap | Mitigação |
|
||||
|-----|-----------|
|
||||
| Blazor sem audit | Congelar; não expandir |
|
||||
| Sync overwrite | ISyncFieldMergePolicy + ManualEditFlag |
|
||||
| Auth WO desabilitada | Reativar `[Authorize]` WorkOrderController |
|
||||
| WO# inconsistente | Documentar normalização 11 dígitos (Fase 2 execução) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Checklist pré-go-live PO
|
||||
|
||||
- [ ] App mobile externo confirmado?
|
||||
- [ ] Data sunset Blazor WO definida?
|
||||
- [ ] FrontendBaseUrl produção → SHOC?
|
||||
- [ ] Owner Lambda cutover nomeado?
|
||||
|
||||
---
|
||||
|
||||
## 7. Auth — consumidores e tokens
|
||||
|
||||
| Consumidor | Auth |
|
||||
|------------|------|
|
||||
| SHOC | JWT Bearer |
|
||||
| Sync | JWT Bearer (`[Authorize]` no SyncController) |
|
||||
| Vendor Portal | X-Vendor-Token (rotas públicas dispatch) |
|
||||
| Blazor | Cookie Identity (app separado) |
|
||||
|
||||
Reativar auth em WorkOrderController exige SHOC enviar JWT em todas as chamadas WO.
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
# DAR — Domain Architecture Review (Work Orders)
|
||||
|
||||
**Fase:** 0
|
||||
**Status:** Draft para sign-off CTO
|
||||
**Referência:** [roadmap-work-orders-board.md](../../roadmap-work-orders-board.md) §4–5
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
Formalizar o modelo de domínio operacional do Schedule Board antes de qualquer migration de schema board ou contrato de listagem semanal.
|
||||
|
||||
---
|
||||
|
||||
## 2. Agregados e limites (anti God Entity)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph root [AggregateRoot_WorkOrder]
|
||||
CORE[Core_Lifecycle_Assignment]
|
||||
SCH[Scheduling_slice]
|
||||
TRK[Tracking_slice]
|
||||
COMP[Completion_slice]
|
||||
ANA[Analytics_slice]
|
||||
end
|
||||
subgraph separate [Agregado_separado]
|
||||
DISP[Dispatch_fonte_vendor]
|
||||
end
|
||||
AUD[WorkOrderAuditLog_append_only]
|
||||
CORE --> SCH
|
||||
CORE --> TRK
|
||||
CORE --> ANA
|
||||
CORE --> COMP
|
||||
CORE --> DISP
|
||||
CORE --> AUD
|
||||
```
|
||||
|
||||
| Agregado / Slice | Responsabilidade | Persistência Fase 0 |
|
||||
|------------------|------------------|---------------------|
|
||||
| **Core** | Id, WoNumber, Type, LifecycleStatus, SiteCode, LocationId, DueDate, AssignTo, Title, RowVersion, PrimaryDispatchId | Colunas em `workOrders` |
|
||||
| **Scheduling** | ScheduledDate, ScheduledStart/End, TargetWeek, ScheduleWeekOnly | Colunas em `workOrders` |
|
||||
| **Tracking** | OriginalWeek, OriginalDate (set-once) | Colunas em `workOrders` |
|
||||
| **Analytics** | RescheduleCount, CarriedOver | Colunas em `workOrders` |
|
||||
| **Completion** | DocStatus, refs attachments | Coluna DocStatus + attachments existentes |
|
||||
| **Dispatch** | Vendor, tech, status portal | Tabela `Dispatches` + RowVersion |
|
||||
| **Audit** | Rastreabilidade imutável | `WorkOrderAuditLogs` |
|
||||
|
||||
**Proibido:** `VendorId`, `TechName`, `TechPhone` canônicos na entidade WorkOrder. Vendor sempre via Dispatch primário.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fonte da verdade por conceito
|
||||
|
||||
| Conceito | Fonte da verdade | Proibido |
|
||||
|----------|------------------|----------|
|
||||
| Status operacional | `LifecycleStatus` (10 valores FE) | String livre `Status` como canônico |
|
||||
| Past Due | Derivado on-read (`ScheduledDate < hoje` AND NOT terminal) | Misturar no enum lifecycle |
|
||||
| Vendor / Tech | Dispatch primário (`PrimaryDispatchId`) | Duplicar na WO |
|
||||
| Schedule | Scheduling slice | Duplicar no core sem slice lógico |
|
||||
| Completion | `DocStatus` | Inferir só de dispatch signoff |
|
||||
| Reschedule / CarriedOver | Analytics + domain events | Job blind overwrite |
|
||||
| WO# | `InternalWONumber` / `WorkerOrderNumber` com regra 11 dígitos (Fase 2+) | Dois números sem regra |
|
||||
| POC | `WorkOrderContacts` + Notes | Só no detalhe sem contato |
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle Status vs Operational Flags
|
||||
|
||||
```text
|
||||
LifecycleStatus → enum único (10 valores alinhados ao frontend)
|
||||
OperationalFlags → PastDue (derivado on-read; cache opcional Fase 5)
|
||||
LegacyStatus → string original read-only (migration Fase 0)
|
||||
```
|
||||
|
||||
Filtro de status no board = `LifecycleStatus`. Overlay Past Due = flag derivada. Bloqueio de edição = validação sobre flag (Fase 2).
|
||||
|
||||
### Mapeamento legado → LifecycleStatus
|
||||
|
||||
| Legado (`Status`) | LifecycleStatus |
|
||||
|-------------------|-----------------|
|
||||
| Open | Incomplete |
|
||||
| InProgress / In Progress | InProgress |
|
||||
| Completed / Complete | Complete |
|
||||
| Cancelled / Canceled | Canceled |
|
||||
| OnHold / On Hold | OnHold |
|
||||
| Desconhecido | Incomplete + NeedsReview |
|
||||
|
||||
---
|
||||
|
||||
## 5. CarriedOver — justificativa
|
||||
|
||||
| | isPastDue | carriedOver |
|
||||
|--|-----------|-------------|
|
||||
| Natureza | Estado pontual | Métrica histórica acumulada |
|
||||
| Persistir | Não (derivado) | Sim (contador) |
|
||||
| Incremento | N/A | Somente via domain event `WeekRolled` (Fase 5) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Scheduling Aggregate Growth Watchlist
|
||||
|
||||
Campos que **não** entram em Scheduling sem ARB review:
|
||||
|
||||
- `MoveReason`, `MoveUser`, `MoveCategory`, `MoveSource`
|
||||
|
||||
Metadados de movimentação → Audit Event Contract.
|
||||
|
||||
**Gate:** slice Scheduling > 8 campos operacionais → ARB obrigatório.
|
||||
Contagem Fase 0: ScheduledDate, ScheduledStart, ScheduledEnd, TargetWeek, ScheduleWeekOnly, OriginalWeek, OriginalDate (tracking separado) — dentro do limite.
|
||||
|
||||
---
|
||||
|
||||
## 7. Jobs — domínio primeiro
|
||||
|
||||
| Job | Papel |
|
||||
|-----|-------|
|
||||
| Past Due diário | Cache refresh opcional; on-read sempre correto |
|
||||
| Carried Over semanal | Dispara `WeekRolled` → incrementa contador + audit system |
|
||||
| Promoção Overdue type | Derivado on-read |
|
||||
| Audit | Síncrono em toda mutação desde Fase 0 |
|
||||
|
||||
**Regra de ouro:** Jobs nunca são a única fonte da verdade.
|
||||
|
||||
---
|
||||
|
||||
## 8. Critérios de aceite (sign-off CTO)
|
||||
|
||||
- [ ] Zero campos vendor canônicos na WO
|
||||
- [ ] Agregados documentados e refletidos no schema Fase 0
|
||||
- [ ] Persistido vs derivado validado com PO (ver `dar-persisted-vs-derived-matrix.md`)
|
||||
- [ ] Watchlist Scheduling registrada
|
||||
- [ ] Blazor congelado — sem novos campos board via EF direto
|
||||
|
||||
**Assinaturas**
|
||||
|
||||
| Papel | Nome | Data |
|
||||
|-------|------|------|
|
||||
| CTO / Arquiteto | | |
|
||||
| Product Owner | | |
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# DAR — Matriz Persistido vs Derivado
|
||||
|
||||
**Fase:** 0
|
||||
**Status:** Draft para validação PO + CTO
|
||||
|
||||
---
|
||||
|
||||
## Matriz de campos (board + domínio)
|
||||
|
||||
| Campo FE / conceito | Persistido | Derivado | Fonte / Regra |
|
||||
|---------------------|------------|----------|---------------|
|
||||
| lifecycleStatus | Sim | Não | Core — `LifecycleStatus` enum |
|
||||
| isPastDue | Não | **Sim** | `ScheduledDate < UTC hoje` AND NOT status terminal |
|
||||
| type (Overdue) | Parcial | **Sim** | Regra type + schedule + status |
|
||||
| workOrderType | Sim | Não | Core — enum `WorkOrderType` |
|
||||
| carriedOver | Sim | Não | Analytics — incremento via `WeekRolled` only |
|
||||
| rescheduleCount | Sim | Parcial | Analytics — incremento em mudança ScheduledDate |
|
||||
| docStatus | Sim | Não | Completion slice |
|
||||
| dayGroup | Não | Sim | Calculado na leitura a partir de ScheduledDate |
|
||||
| apptTime | Não | Sim | Projeção Dispatch primário ScheduledDate/Start |
|
||||
| vendorName / tech | Não | Sim (projeção) | Join Dispatch primário |
|
||||
| targetWeek | Sim | Não | Scheduling slice |
|
||||
| scheduledEnd | Sim | Não | Scheduling slice |
|
||||
| originalWeek / originalDate | Sim | Não | Tracking — set-once na primeira agenda |
|
||||
| operationalFlags.pastDue | Não* | Sim | *Cache opcional Fase 5; on-read authoritative |
|
||||
| legacyStatus | Sim (read-only) | Não | Valor string original pré-migration |
|
||||
|
||||
---
|
||||
|
||||
## Regras de derivação (on-read)
|
||||
|
||||
### isPastDue
|
||||
|
||||
```text
|
||||
isPastDue = ScheduledDate.HasValue
|
||||
AND ScheduledDate.Value.Date < DateTime.UtcNow.Date
|
||||
AND LifecycleStatus NOT IN (Complete, Canceled)
|
||||
```
|
||||
|
||||
### type Overdue (derivado)
|
||||
|
||||
WO com type != Overdue pode exibir badge Overdue quando isPastDue && regras de negócio PO confirmadas.
|
||||
|
||||
---
|
||||
|
||||
## Validação PO
|
||||
|
||||
| Pergunta | Resposta PO |
|
||||
|----------|-------------|
|
||||
| Past Due separado do lifecycle status? | Sim |
|
||||
| carriedOver persiste mesmo após reschedule? | Sim |
|
||||
| Vendor nunca duplicado na WO? | Sim |
|
||||
|
||||
**Assinatura PO:** _________________ Data: _______
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
# Data Ownership Model — Work Orders
|
||||
|
||||
**Fase:** 0
|
||||
**Status:** Draft para sign-off CTO
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípios
|
||||
|
||||
1. **SHOC (React)** é o único destino de novas features do board.
|
||||
2. **SQL Server** é fonte operacional para board, Blazor e Portal.
|
||||
3. **DynamoDB/Sync** é ponte temporária de ingestão externa.
|
||||
4. Conflito entre atores resolve-se pela Field Ownership Matrix, não por last-write-wins global.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ownership por ator
|
||||
|
||||
| Ator | Conexão | Pode alterar | Não pode alterar |
|
||||
|------|---------|--------------|------------------|
|
||||
| **SHOC** | REST JWT (`api/WorkOrder`) | Lifecycle, Schedule, Assignment, Completion, vendor via Dispatch, POC, WO# | Checklist/signoff vendor |
|
||||
| **Vendor Portal** | REST `X-Vendor-Token` | Dispatch status, checklist, signoff, comments vendor | Schedule, lifecycle, assignment, DueDate |
|
||||
| **Sync/Lambda** | `POST api/Sync/WorkOrders` | Campos ingest (Description, ExternalId, SiteCode na criação, etc.) | Campos SHOC-owned após ManualEditFlag |
|
||||
| **Blazor** | EF direto (`WorkorderService`) | Campos legados existentes até sunset | Campos novos board; **congelado** |
|
||||
| **Jobs** | Domain events (Fase 5+) | Incremento CarriedOver; audit System | Lifecycle, Schedule, Dispatch |
|
||||
|
||||
---
|
||||
|
||||
## 3. Política Blazor (sunset)
|
||||
|
||||
- Manutenção mínima: bugfix crítico only.
|
||||
- Não recebe: board, inline edit, jobs, advanced search, completion doc WO-level.
|
||||
- Schema: colunas novas Fase 0 são **nullable** — Blazor continua funcionando sem conhecer novos campos.
|
||||
- Conflito Blazor vs SHOC: **SHOC vence**; Blazor não deve escrever campos board após go-live Fase 1.
|
||||
- Smoke test EF obrigatório pós-migration (ver checklist smoke).
|
||||
|
||||
---
|
||||
|
||||
## 4. Política Vendor Portal
|
||||
|
||||
- Dispatch intacto; zero regressão checklist/signoff/verify.
|
||||
- Vendor não muta WorkOrder core — apenas Dispatch e comments associados.
|
||||
- Audit EventType = `Vendor` para mutações portal.
|
||||
|
||||
---
|
||||
|
||||
## 5. Política Sync/Lambda
|
||||
|
||||
- Upsert por `ExternalWorkOrderId` (idempotente).
|
||||
- Field-level merge conforme Field Ownership Matrix.
|
||||
- Campo com ManualEditFlag → skip + audit `SyncRejected`.
|
||||
- Cutover Lambda → API direta: Fase 7.
|
||||
|
||||
---
|
||||
|
||||
## 6. Regras de conflito (resumo)
|
||||
|
||||
| Cenário | Vencedor |
|
||||
|---------|----------|
|
||||
| SHOC vs SHOC (mesmo campo) | 409 RowVersion; último commit válido |
|
||||
| SHOC vs Sync (mesmo campo) | SHOC se ManualEditFlag |
|
||||
| SHOC vs Sync (campos distintos) | Merge |
|
||||
| Vendor vs Sync | Domínios separados |
|
||||
| SHOC vs Vendor (mesmo conceito) | Impossível por design (vendor = Dispatch) |
|
||||
| Blazor vs SHOC | SHOC vence; Blazor congelado |
|
||||
|
||||
---
|
||||
|
||||
## 7. Critérios de aceite
|
||||
|
||||
- [ ] Matriz por ator revisada por CTO
|
||||
- [ ] PO confirma política Blazor sunset
|
||||
- [ ] Sync merge referencia Field Ownership Matrix
|
||||
|
||||
**Assinatura CTO:** _________________ Data: _______
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# Database Drift Report — SQL vs EF
|
||||
|
||||
**Fase:** 0
|
||||
**Data:** 2026-06-24
|
||||
**EF Snapshot:** `Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs`
|
||||
**Script legado:** `Api.SeaHavenIndustries/db.txt`
|
||||
|
||||
---
|
||||
|
||||
## 1. Metodologia
|
||||
|
||||
1. Colunas EF extraídas do `ApplicationDbContextModelSnapshot` (entidade `WorkOrder`).
|
||||
2. Colunas SQL legado extraídas de `db.txt` DDL `[WorkOrders]`.
|
||||
3. Classificação: **mapear**, **deprecar**, **satélite**, **OK**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tabela workOrders — drift conhecido
|
||||
|
||||
| Coluna SQL (legado) | No EF? | Classificação | Ação Fase 0 |
|
||||
|---------------------|--------|---------------|-------------|
|
||||
| WorkOrderType | **Não** | mapear | Adicionar `WorkOrderType` int nullable → enum |
|
||||
| AvettaTask | **Não** | inventariar | Adicionar coluna nullable; uso TBD com PO |
|
||||
| AssignDate | **Não** | inventariar | Adicionar coluna nullable; possível alias AssignDate tracking |
|
||||
| Nome tabela WorkOrders vs workOrders | EF usa `workOrders` | OK | Manter EF naming; SQL Server case-insensitive |
|
||||
|
||||
---
|
||||
|
||||
## 3. Colunas EF (workOrders) — baseline
|
||||
|
||||
Presentes no snapshot e mapeadas:
|
||||
|
||||
InternalWONumber, ExternalWorkOrderId, WorkerOrderNumber, WorkerOrderTitle, Description, Customer, SiteCode, Building, Severity, DateReported, ScheduledStart, ScheduledDate, CompletedDate, Source, SourceEmailS3Key, Problem, Trade, SubTrade, VendorNTE, AssignTo, DueDate, Priority, Status, LocationId, PO, TT, Attachments, BeforPhoto*, AfterPhoto*, SignOff*, istemplate, audit fields (CreatedDate, IsDeleted, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Novas colunas Fase 0 (migration)
|
||||
|
||||
| Coluna | Tipo | Slice |
|
||||
|--------|------|-------|
|
||||
| LifecycleStatus | int nullable | Core |
|
||||
| LegacyStatus | nvarchar (cópia Status) | Core |
|
||||
| WorkOrderType | int nullable | Core |
|
||||
| PrimaryDispatchId | int nullable FK | Core |
|
||||
| RowVersion | rowversion | Core |
|
||||
| TargetWeek | date nullable | Scheduling |
|
||||
| ScheduledEnd | datetime2 nullable | Scheduling |
|
||||
| ScheduleWeekOnly | bit nullable | Scheduling |
|
||||
| OriginalWeek | date nullable | Tracking |
|
||||
| OriginalDate | date nullable | Tracking |
|
||||
| RescheduleCount | int default 0 | Analytics |
|
||||
| CarriedOver | int default 0 | Analytics |
|
||||
| DocStatus | int nullable | Completion |
|
||||
| AvettaTask | nvarchar max nullable | Legado SQL |
|
||||
| AssignDate | date nullable | Legado SQL |
|
||||
|
||||
---
|
||||
|
||||
## 5. Dispatch
|
||||
|
||||
| Item | EF | Ação Fase 0 |
|
||||
|------|-----|-------------|
|
||||
| RowVersion | Ausente | Adicionar |
|
||||
| Demais colunas | OK | Manter |
|
||||
|
||||
---
|
||||
|
||||
## 6. WorkOrderAuditLog
|
||||
|
||||
| Coluna | EF atual | Ação Fase 0 |
|
||||
|--------|----------|-------------|
|
||||
| EventType | Ausente | Adicionar |
|
||||
| ActorType | Ausente | Adicionar |
|
||||
| DispatchId | Ausente | Adicionar nullable |
|
||||
| CorrelationId | Ausente | Adicionar nullable |
|
||||
|
||||
---
|
||||
|
||||
## 7. WorkOrderContacts
|
||||
|
||||
| Coluna | EF | Ação Fase 0 |
|
||||
|--------|-----|-------------|
|
||||
| Notes | Ausente | Adicionar nvarchar nullable (pocNotes) |
|
||||
|
||||
---
|
||||
|
||||
## 8. ApplicationUser (AspNetUsers)
|
||||
|
||||
| Coluna | Ação Fase 0 |
|
||||
|--------|-------------|
|
||||
| Initials | nvarchar(8) nullable |
|
||||
| Color | nvarchar(16) nullable |
|
||||
|
||||
---
|
||||
|
||||
## 9. Tabelas revisadas — sem drift crítico adicional
|
||||
|
||||
- `Comments` — OK
|
||||
- `Locations` — OK (SiteCode via WO.SiteCode)
|
||||
- `DispatchWorkOrders` — OK
|
||||
|
||||
---
|
||||
|
||||
## 10. Recomendações
|
||||
|
||||
1. **Eliminar dual-path:** `db.txt` catch-up não deve ser usado após migration EF Phase0; documentar em runbook.
|
||||
2. **WorkOrderType:** mapear valores SQL existentes → enum na migration data script (dry-run).
|
||||
3. **AvettaTask / AssignDate:** manter nullable; PO valida uso antes de exposição board.
|
||||
|
||||
**Gate:** Diff assinado por Backend antes de merge migration.
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
# Field Ownership Matrix — Work Orders
|
||||
|
||||
**Fase:** 0
|
||||
**Depende de:** [data-ownership-model.md](data-ownership-model.md)
|
||||
**Status:** Draft para sign-off CTO + PO
|
||||
|
||||
---
|
||||
|
||||
## Legenda
|
||||
|
||||
- **Owner escritor:** ator autorizado a criar/alterar o valor canônico.
|
||||
- **Sync sobrescreve?:** se ingest DynamoDB pode substituir após WO existir.
|
||||
- **ManualEditFlag:** primeira edição SHOC bloqueia Sync no campo (ver [manual-edit-flag-design.md](manual-edit-flag-design.md)).
|
||||
|
||||
---
|
||||
|
||||
## Matriz completa
|
||||
|
||||
| Campo | Agregado | Owner escritor | Sync sobrescreve? | Conflito SHOC vs Sync |
|
||||
|-------|----------|----------------|-------------------|----------------------|
|
||||
| LifecycleStatus | Core | SHOC | Não após 1ª edição SHOC | SHOC vence |
|
||||
| LegacyStatus | Core | — (read-only) | Não | N/A |
|
||||
| AssignTo | Core | SHOC | Não após ManualEditFlag | SHOC vence |
|
||||
| ScheduledDate | Scheduling | SHOC | Não | SHOC vence |
|
||||
| ScheduledStart | Scheduling | SHOC | Parcial (ingest inicial) | SHOC após flag |
|
||||
| ScheduledEnd | Scheduling | SHOC | Não | SHOC vence |
|
||||
| TargetWeek | Scheduling | SHOC | Não | SHOC vence |
|
||||
| ScheduleWeekOnly | Scheduling | SHOC | Não | SHOC vence |
|
||||
| DueDate | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
|
||||
| Description | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
|
||||
| WorkerOrderTitle | Core | SHOC + Sync | Sim se nunca editado SHOC | SHOC se ManualEditFlag |
|
||||
| SiteCode | Core | SHOC + Sync | Sim na criação; não após SHOC | SHOC após flag |
|
||||
| Building | Core | SHOC + Sync | Sim na criação | SHOC após flag |
|
||||
| LocationId | Core | SHOC + Sync | Sim na criação | SHOC após flag |
|
||||
| WorkOrderType | Core | SHOC + Sync | Sim na criação ingest | SHOC após flag |
|
||||
| InternalWONumber / WoNumber | Core | SHOC (Sync só na criação) | Não | SHOC only |
|
||||
| ExternalWorkOrderId | Core | Sync | Sim (idempotente key) | Sync only |
|
||||
| VendorId / VendorName | Dispatch | SHOC + Vendor | Não | Domínios separados |
|
||||
| Dispatch Status | Dispatch | Vendor + SHOC | Não | Por contexto |
|
||||
| Checklist / Signoff | Dispatch | Vendor | Não | Vendor vence |
|
||||
| DocStatus | Completion | SHOC | Não | SHOC only |
|
||||
| RescheduleCount | Analytics | Domain events | Não | N/A |
|
||||
| CarriedOver | Analytics | Jobs (WeekRolled) | Não | N/A |
|
||||
| OriginalWeek / OriginalDate | Tracking | SHOC (set-once) | Não | SHOC vence |
|
||||
| POC / WorkOrderContacts.Notes | Contacts | SHOC | Não | SHOC vence |
|
||||
| Priority / Severity | Core | SHOC + Sync | Sim se nunca editado | SHOC após flag |
|
||||
| Status (legado string) | Core | — (deprecated write) | Não | Migrar para LifecycleStatus |
|
||||
| Trade / Problem / SubTrade | Core | SHOC | Parcial Sync ingest | SHOC após flag |
|
||||
| Customer / Source | Core | Sync + SHOC | Sim na criação | SHOC após flag |
|
||||
| Attachments / Photos | Core | SHOC + Vendor | Não overwrite mútuo | Por domínio |
|
||||
|
||||
---
|
||||
|
||||
## Campos board (14 colunas) — ownership resumido
|
||||
|
||||
| Coluna board | Fonte | Owner |
|
||||
|--------------|-------|-------|
|
||||
| WO# | Core.InternalWONumber | SHOC |
|
||||
| Type | Core.WorkOrderType | SHOC |
|
||||
| Site | Core.SiteCode | SHOC |
|
||||
| Status | Core.LifecycleStatus | SHOC |
|
||||
| Assignee | Core.AssignTo | SHOC |
|
||||
| Due | Core.DueDate | SHOC + Sync |
|
||||
| Scheduled | Scheduling.ScheduledDate | SHOC |
|
||||
| Vendor | Dispatch (primário) | SHOC via Dispatch |
|
||||
| Appt | Dispatch.ScheduledDate | Dispatch |
|
||||
| Past Due | Derivado | — |
|
||||
| Carried | Analytics.CarriedOver | Jobs |
|
||||
| Reschedule | Analytics.RescheduleCount | Domain |
|
||||
| Doc | Completion.DocStatus | SHOC |
|
||||
| Actions | — | SHOC UI |
|
||||
|
||||
---
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- [ ] PO valida campos board vs matriz
|
||||
- [ ] Sync merge implementado referencia esta matriz
|
||||
- [ ] ManualEditFlag design aprovado
|
||||
|
||||
**Assinaturas**
|
||||
|
||||
| Papel | Data |
|
||||
|-------|------|
|
||||
| CTO | |
|
||||
| PO | |
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
# ManualEditFlag — Design
|
||||
|
||||
**Fase:** 0
|
||||
**Implementação:** tabela satélite `WorkOrderFieldLocks`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema
|
||||
|
||||
Sync/Lambda faz upsert overwrite em campos que dispatchers editam no SHOC. Precisamos saber, por campo, se houve edição manual SHOC para rejeitar ingest conflitante.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solução escolhida: tabela satélite
|
||||
|
||||
```text
|
||||
WorkOrderFieldLocks
|
||||
Id (PK)
|
||||
WorkOrderId (FK)
|
||||
FieldName (string, max 64)
|
||||
LockedAt (UTC)
|
||||
LockedByUserId (nullable — null = system/bootstrap)
|
||||
UNIQUE (WorkOrderId, FieldName)
|
||||
```
|
||||
|
||||
**Alternativa descartada:** bitmask — difícil de auditar e estender.
|
||||
|
||||
---
|
||||
|
||||
## 3. Comportamento
|
||||
|
||||
| Evento | Ação |
|
||||
|--------|------|
|
||||
| SHOC edita campo X pela 1ª vez | INSERT lock (WorkOrderId, FieldName) |
|
||||
| Sync tenta atualizar campo X com lock | Skip update; audit `SyncRejected` |
|
||||
| Sync atualiza campo Y sem lock | Apply merge normal |
|
||||
| WO novo (Sync create) | Sem locks; full ingest |
|
||||
| Blazor edita campo legado | **Não** cria lock Fase 0 (gap conhecido; Blazor congelado) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Campos elegíveis a lock (SHOC writers)
|
||||
|
||||
Definidos em `SyncFieldMergePolicy.ShocOwnedFields`:
|
||||
|
||||
- LifecycleStatus, AssignTo, ScheduledDate, ScheduledEnd, TargetWeek, ScheduleWeekOnly
|
||||
- DueDate, Description, WorkerOrderTitle, SiteCode, Building, LocationId
|
||||
- WorkOrderType, DocStatus, Trade, Problem, Priority, InternalWONumber
|
||||
|
||||
---
|
||||
|
||||
## 5. Audit em rejeição
|
||||
|
||||
```json
|
||||
{
|
||||
"eventType": "Sync",
|
||||
"action": "SyncRejected",
|
||||
"fieldName": "DueDate",
|
||||
"oldValue": "2026-06-01",
|
||||
"newValue": "2026-06-15",
|
||||
"actorType": "Sync"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API interna
|
||||
|
||||
```csharp
|
||||
interface IWorkOrderFieldLockService
|
||||
{
|
||||
Task LockFieldAsync(int workOrderId, string fieldName, string? userId);
|
||||
Task<bool> IsLockedAsync(int workOrderId, string fieldName);
|
||||
}
|
||||
```
|
||||
|
||||
Implementação: `WorkOrderFieldLockService` em `SeaHaven.Services`.
|
||||
|
||||
Lock criado automaticamente por `IWorkOrderAuditService` em eventos `Manual` + `FieldChanged` / `StatusChanged`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Critérios de aceite
|
||||
|
||||
- [ ] Tabela criada na migration Phase0
|
||||
- [ ] Sync consulta locks antes de overwrite
|
||||
- [ ] SyncRejected aparece no audit log
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# Migration Dry-Run Report — Phase 0
|
||||
|
||||
**Migration:** `20260624163145_Phase0_DomainFoundation`
|
||||
**Status:** Pronto para execução em staging
|
||||
|
||||
---
|
||||
|
||||
## 1. Procedimento
|
||||
|
||||
1. Restore backup produção → staging espelho
|
||||
2. Snapshot pré-migration:
|
||||
```sql
|
||||
SELECT Status, COUNT(*) FROM workOrders GROUP BY Status;
|
||||
SELECT COUNT(*) AS Total FROM workOrders;
|
||||
```
|
||||
3. Executar:
|
||||
```powershell
|
||||
dotnet ef database update --project Data.SeaHavenIndustries --startup-project Api.SeaHavenIndustries
|
||||
```
|
||||
4. Validar pós-migration:
|
||||
```sql
|
||||
SELECT COUNT(*) FROM workOrders WHERE LegacyStatus IS NOT NULL;
|
||||
SELECT LifecycleStatus, COUNT(*) FROM workOrders GROUP BY LifecycleStatus;
|
||||
SELECT COUNT(*) FROM workOrders WHERE PrimaryDispatchId IS NOT NULL;
|
||||
```
|
||||
5. Smoke Blazor + API legado + Sync sample
|
||||
6. Rollback (se falha):
|
||||
```powershell
|
||||
dotnet ef database update 20260417195316_AddDispatchVerification --project Data.SeaHavenIndustries --startup-project Api.SeaHavenIndustries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Data scripts incluídos na migration
|
||||
|
||||
- `LegacyStatus` ← cópia de `Status`
|
||||
- `LifecycleStatus` ← mapeamento legado (Open→Incomplete, etc.)
|
||||
- `PrimaryDispatchId` ← dispatch mais recente por WO
|
||||
|
||||
---
|
||||
|
||||
## 3. Resultados (preencher em staging)
|
||||
|
||||
| Métrica | Antes | Depois |
|
||||
|---------|-------|--------|
|
||||
| Total WOs | | |
|
||||
| Com LegacyStatus | | |
|
||||
| Com LifecycleStatus | | |
|
||||
| Com PrimaryDispatchId | | |
|
||||
| Rollback testado | | Sim/Não |
|
||||
|
||||
---
|
||||
|
||||
## 4. Notas
|
||||
|
||||
A migration inclui reconciliação de drift em `Locations`, `Regions`, `Departments` detectada pelo EF — revisar impacto em staging antes de produção.
|
||||
|
||||
**WO# normalização 11 dígitos:** documentada para Fase 2; não executada nesta migration.
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# Phase 0 — Gates Sign-Off Checklist
|
||||
|
||||
**Programa:** Work Orders Board
|
||||
**Fase:** 0 — Domain Foundation + Governança
|
||||
|
||||
---
|
||||
|
||||
## 9 Gates bloqueantes
|
||||
|
||||
| # | Gate | Artefato | Status |
|
||||
|---|------|----------|--------|
|
||||
| 1 | DAR aprovado | [dar-domain-architecture-review.md](dar-domain-architecture-review.md) | Documentado |
|
||||
| 2 | Data Ownership Model | [data-ownership-model.md](data-ownership-model.md) | Documentado |
|
||||
| 3 | Field Ownership Matrix | [field-ownership-matrix.md](field-ownership-matrix.md) | Documentado |
|
||||
| 4 | Audit Event Contract | [audit-event-contract.md](audit-event-contract.md) | Implementado baseline |
|
||||
| 5 | Volume Discovery | [volume-discovery-report.md](volume-discovery-report.md) | Queries prontas; tier S provisório |
|
||||
| 6 | Database drift | [database-drift-report.md](database-drift-report.md) | Documentado |
|
||||
| 7 | Spike vendor/dispatch | [adr-vendor-source-of-truth.md](adr-vendor-source-of-truth.md) | ADR + query |
|
||||
| 8 | Migration dry-run | [migration-dry-run-report.md](migration-dry-run-report.md) | Procedimento pronto |
|
||||
| 9 | RowVersion multi-agregado | WO + Dispatch + ConcurrencyExceptionFilter | Implementado |
|
||||
|
||||
---
|
||||
|
||||
## Implementação código (Fase 0)
|
||||
|
||||
- Migration `Phase0_DomainFoundation`
|
||||
- Enums: LifecycleStatus, WorkOrderType, DocStatus, AuditEventType, etc.
|
||||
- `IWorkOrderAuditService`, `IWorkOrderFieldLockService`, `ISyncFieldMergePolicy`
|
||||
- `[Authorize]` reativado em WorkOrderController
|
||||
- Projeto `SeaHavenIndustries.Tests`
|
||||
|
||||
---
|
||||
|
||||
## GO Fase 1 — pendente
|
||||
|
||||
- [ ] Sign-off CTO/PO nos artefatos
|
||||
- [ ] Dry-run staging executado
|
||||
- [ ] Volume tier confirmado em staging/prod
|
||||
- [ ] Smoke checklist verde
|
||||
|
||||
**Assinatura GO Fase 1**
|
||||
|
||||
| Papel | Nome | Data |
|
||||
|-------|------|------|
|
||||
| CTO | | |
|
||||
| PO | | |
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# RowVersion Multi-Agregado — Design
|
||||
|
||||
**Fase:** 0 — Gate #9
|
||||
|
||||
---
|
||||
|
||||
## Agregados
|
||||
|
||||
| Agregado | Coluna | Comportamento |
|
||||
|----------|--------|---------------|
|
||||
| WorkOrder | `RowVersion` rowversion | Bump em mutação WO (schedule, status, assignment) |
|
||||
| Dispatch | `RowVersion` rowversion | Bump em mutação vendor/checklist/signoff |
|
||||
|
||||
---
|
||||
|
||||
## HTTP 409
|
||||
|
||||
`ConcurrencyExceptionFilter` captura `DbUpdateConcurrencyException` e retorna:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "Conflict",
|
||||
"message": "The record was modified by another user. Refresh and retry.",
|
||||
"code": 409
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regras (Fase 2 testes completos)
|
||||
|
||||
- SHOC edita ScheduledDate → bump WO.RowVersion
|
||||
- SHOC edita vendor → bump Dispatch.RowVersion
|
||||
- Vendor edita checklist → bump Dispatch.RowVersion only
|
||||
|
||||
Testes de concorrência paralela: Fase 2 (`Concurrency integration tests` backlog #27).
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# Smoke Test Checklist — Multi-Consumidor (Fase 0)
|
||||
|
||||
**Ambiente:** Docker local (`docker compose up -d` + `scripts/setup-local-db.ps1`)
|
||||
|
||||
---
|
||||
|
||||
## Blazor EF
|
||||
|
||||
| # | Teste | Pass |
|
||||
|---|-------|------|
|
||||
| 1 | `/workorderlist` carrega sem exception | [ ] |
|
||||
| 2 | Abrir detalhe WO existente | [ ] |
|
||||
| 3 | Campos novos nullable não quebram binding | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## API REST (legado)
|
||||
|
||||
| # | Teste | Pass |
|
||||
|---|-------|------|
|
||||
| 1 | POST login → JWT | [ ] |
|
||||
| 2 | GET work order list com Bearer token | [ ] |
|
||||
| 3 | POST ChangeStatus com audit no log | [ ] |
|
||||
| 4 | POST ChangeAssignment.tex Assignment com audit | [ ] |
|
||||
| 5 | 401 sem token (auth reativada) | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Sync
|
||||
|
||||
| # | Teste | Pass |
|
||||
|---|-------|------|
|
||||
| 1 | POST `api/Sync/WorkOrders` com JWT (mock/staging DynamoDB) | [ ] |
|
||||
| 2 | Upsert idempotente por ExternalWorkOrderId | [ ] |
|
||||
| 3 | Campo locked → SyncRejected no audit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Vendor Portal
|
||||
|
||||
| # | Teste | Pass |
|
||||
|---|-------|------|
|
||||
| 1 | Checklist update flow | [ ] |
|
||||
| 2 | Signoff flow | [ ] |
|
||||
| 3 | Dispatch RowVersion presente (schema) | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Automação local
|
||||
|
||||
```powershell
|
||||
dotnet test SeaHavenIndustries.Tests
|
||||
dotnet build
|
||||
./scripts/setup-local-db.ps1
|
||||
```
|
||||
|
||||
Testes unitários cobrem: LifecycleStatusMapper, audit baseline, sync merge rejection.
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
# Volume Discovery Report — Work Orders
|
||||
|
||||
**Fase:** 0 — Gate Fase 1
|
||||
**Data:** 2026-06-24
|
||||
**Ambiente:** Local dev (Docker) + queries para staging/prod
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
Definir tier S/M/L/XL antes de índices e estratégia de search (Fase 4). **Sem premissa de volume.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Queries obrigatórias
|
||||
|
||||
Executar em staging ou produção read-only:
|
||||
|
||||
```sql
|
||||
-- Total WOs
|
||||
SELECT COUNT(*) AS TotalWorkOrders
|
||||
FROM workOrders
|
||||
WHERE IsDeleted IS NULL OR IsDeleted = 0;
|
||||
|
||||
-- WOs por semana (ISO) — p95
|
||||
WITH WeeklyCounts AS (
|
||||
SELECT DATEPART(iso_week, ScheduledDate) AS IsoWeek,
|
||||
YEAR(ScheduledDate) AS IsoYear,
|
||||
COUNT(*) AS Cnt
|
||||
FROM workOrders
|
||||
WHERE ScheduledDate IS NOT NULL
|
||||
AND (IsDeleted IS NULL OR IsDeleted = 0)
|
||||
GROUP BY DATEPART(iso_week, ScheduledDate), YEAR(ScheduledDate)
|
||||
)
|
||||
SELECT MAX(Cnt) AS P95ProxyMaxPerWeek,
|
||||
AVG(Cnt * 1.0) AS AvgPerWeek
|
||||
FROM WeeklyCounts;
|
||||
|
||||
-- Unscheduled (não terminal)
|
||||
SELECT COUNT(*) AS UnscheduledCount
|
||||
FROM workOrders
|
||||
WHERE ScheduledDate IS NULL
|
||||
AND (IsDeleted IS NULL OR IsDeleted = 0)
|
||||
AND Status NOT IN ('Completed', 'Complete', 'Cancelled', 'Canceled');
|
||||
|
||||
-- Crescimento mensal
|
||||
SELECT YEAR(CreatedDate) AS Y, MONTH(CreatedDate) AS M, COUNT(*) AS Cnt
|
||||
FROM workOrders
|
||||
WHERE CreatedDate IS NOT NULL
|
||||
GROUP BY YEAR(CreatedDate), MONTH(CreatedDate)
|
||||
ORDER BY Y DESC, M DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Resultados
|
||||
|
||||
| Métrica | Local Docker (dev) | Staging | Produção |
|
||||
|---------|-------------------|---------|----------|
|
||||
| Total WOs | *executar após seed* | _pendente acesso_ | _pendente acesso_ |
|
||||
| WOs/semana p95 | — | _pendente_ | _pendente_ |
|
||||
| Unscheduled | — | _pendente_ | _pendente_ |
|
||||
| Crescimento mensal | — | _pendente_ | _pendente_ |
|
||||
|
||||
**Nota:** Ambiente local inicia vazio. Tier provisório para desenvolvimento: **S** (< 25k). Confirmar com query em staging antes do GO Fase 1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Classificação tier (§10.2 roadmap)
|
||||
|
||||
| Tier | Total WOs | Listagem semanal | Advanced search |
|
||||
|------|-----------|------------------|-----------------|
|
||||
| **S** | < 25k | Índices simples | SQL filtros compostos |
|
||||
| **M** | 25k–250k | Índices covering | Paginação obrigatória |
|
||||
| **L** | 250k–1M | Read model candidato | Full-text |
|
||||
| **XL** | > 1M | Materialized view | Search dedicado (ARB) |
|
||||
|
||||
**Tier definido para Fase 1:** **S (provisório dev)** — revisar ao obter métricas staging.
|
||||
|
||||
---
|
||||
|
||||
## 5. Impacto Fase 0–1
|
||||
|
||||
- Fases 1–3: índices conservadores compatíveis com qualquer tier.
|
||||
- Fase 4: implementação search conforme tier confirmado + load test.
|
||||
|
||||
---
|
||||
|
||||
## 6. Script local
|
||||
|
||||
```powershell
|
||||
./scripts/setup-local-db.ps1
|
||||
# Executar queries acima via sqlcmd ou DBCode contra localhost:1433
|
||||
```
|
||||
|
||||
**Gate:** Preencher coluna Staging/Produção antes do GO Fase 1.
|
||||
|
|
@ -17,10 +17,10 @@ Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada.
|
|||
|
||||
| Param | Obrigatório | Descrição |
|
||||
|-------|-------------|-----------|
|
||||
| `weekStart` | Sim | Segunda-feira da semana (`YYYY-MM-DD`) |
|
||||
| `weekEnd` | Não | Sexta-feira (default: `weekStart + 4 dias`) |
|
||||
| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos |
|
||||
| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` |
|
||||
| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. |
|
||||
| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. |
|
||||
| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. |
|
||||
| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). |
|
||||
| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) |
|
||||
| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) |
|
||||
|
||||
|
|
@ -45,10 +45,14 @@ curl -H "Authorization: Bearer <token>" \
|
|||
|
||||
- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search)
|
||||
- `counts.returned` — WOs agendadas após aplicar search
|
||||
- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo)
|
||||
- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior)
|
||||
|
||||
### `GET /api/workorders/lookups/dispatchers`
|
||||
|
||||
Lista dispatchers para filtros do board.
|
||||
Lista opções para o filtro/dropdown de assignee do board.
|
||||
|
||||
**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito.
|
||||
|
||||
```json
|
||||
[
|
||||
|
|
@ -101,7 +105,7 @@ WO entra em **scheduled** se:
|
|||
- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou**
|
||||
- `ScheduleWeekOnly == true` e `TargetWeek == weekStart`
|
||||
|
||||
**Unscheduled:** `ScheduledDate == null` e status não terminal.
|
||||
**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -114,7 +118,7 @@ WO entra em **scheduled** se:
|
|||
| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` |
|
||||
| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` |
|
||||
| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) |
|
||||
| Migration | `Data.SeaHavenIndustries/Migrations/20260624180343_Phase1_BoardIndexes.cs` |
|
||||
| Migration | `Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs` |
|
||||
| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
# Fase 2 — Inline Edit + Concorrência
|
||||
|
||||
**Programa:** Work Orders Board
|
||||
**Objetivo:** Edição spreadsheet-style célula a célula no board SHOC, com dual RowVersion, audit granular e regras de domínio no backend.
|
||||
|
||||
**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md)
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `PATCH /api/workorders/{id}/board`
|
||||
|
||||
Atualiza um único campo do board com validação otimista de concorrência.
|
||||
|
||||
**Autenticação:** Bearer JWT (`[Authorize]`)
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"field": "scheduledDate",
|
||||
"value": "2026-06-25",
|
||||
"workOrderVersion": "<base64 RowVersion>",
|
||||
"dispatchVersion": "<base64 RowVersion | null para campos WO-only>",
|
||||
"primaryDispatchId": 123
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Obrigatório | Descrição |
|
||||
|-------|-------------|-----------|
|
||||
| `field` | Sim | Nome canônico do campo (case-insensitive) |
|
||||
| `value` | Sim* | Valor serializado como string (*pode ser vazio para limpar datas) |
|
||||
| `workOrderVersion` | Sim | `RowVersion` atual da WO (Base64) |
|
||||
| `dispatchVersion` | Condicional | Obrigatório para campos Dispatch quando dispatch já existe |
|
||||
| `primaryDispatchId` | Não | Valida que o dispatch pertence à WO |
|
||||
|
||||
**Responses:**
|
||||
|
||||
| HTTP | Body | Quando |
|
||||
|------|------|--------|
|
||||
| 200 | `WorkOrderBoardRowDto` | Sucesso — row completo com versões atualizadas |
|
||||
| 409 | `WorkOrderBoardConflictDto` + `currentState` | Conflito de versão |
|
||||
| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio violada |
|
||||
| 400 | `Response` | Erro genérico / argumento inválido |
|
||||
|
||||
**Exemplo:**
|
||||
|
||||
```bash
|
||||
curl -X PATCH -H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"field":"siteCode","value":"BK5","workOrderVersion":"AQAAAAAAAAA="}' \
|
||||
"https://localhost:5001/api/workorders/1/board"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Campos editáveis
|
||||
|
||||
| field (API) | Agregado | Coluna board | Audit FieldName |
|
||||
|-------------|----------|--------------|-----------------|
|
||||
| `woNumber` | WorkOrder | WO# | InternalWONumber |
|
||||
| `workOrderType` | WorkOrder | Type | WorkOrderType |
|
||||
| `siteCode` | WorkOrder | Site | SiteCode |
|
||||
| `lifecycleStatus` | WorkOrder | Status | LifecycleStatus |
|
||||
| `assignTo` | WorkOrder | Assignee | AssignTo |
|
||||
| `dueDate` | WorkOrder | Due | DueDate |
|
||||
| `scheduledDate` | WorkOrder | Scheduled | ScheduledDate |
|
||||
| `targetWeek` | WorkOrder | Scheduled (week-only) | TargetWeek |
|
||||
| `scheduleWeekOnly` | WorkOrder | Scheduled | ScheduleWeekOnly |
|
||||
| `vendorId` | Dispatch | Vendor | VendorId |
|
||||
| `apptDate` | Dispatch | Appt | ApptDate |
|
||||
| `apptTime` | Dispatch/WO | Appt | ApptTime |
|
||||
| `docStatus` | WorkOrder | Doc | DocStatus |
|
||||
|
||||
**Read-only:** `isPastDue`, `carriedOver`, `rescheduleCount` (badge derivado; count incrementado por regra).
|
||||
|
||||
---
|
||||
|
||||
## Dual RowVersion
|
||||
|
||||
| Agregado | Quando exigir versão |
|
||||
|----------|---------------------|
|
||||
| WorkOrder | Sempre (`workOrderVersion`) |
|
||||
| Dispatch | Campos vendor/appt quando dispatch primário já existe (`dispatchVersion`) |
|
||||
|
||||
O GET board retorna `rowVersion`, `dispatchRowVersion` e `primaryDispatchId` em cada row.
|
||||
|
||||
**409 currentState:** inclui row recarregado do DB para refresh imediato no FE.
|
||||
|
||||
---
|
||||
|
||||
## Regras de domínio
|
||||
|
||||
| Regra | Comportamento |
|
||||
|-------|---------------|
|
||||
| Auto-schedule | `Incomplete` + `scheduledDate` + `assignTo` → `Scheduled` |
|
||||
| Reschedule | Mudança de `scheduledDate` com data anterior → `rescheduleCount++` |
|
||||
| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` |
|
||||
| Past Due block | Não permite mudar `lifecycleStatus` quando `isPastDue` |
|
||||
| Cancel read-only | Status terminal (`Canceled`, `Closed`, `Complete`) bloqueia PATCH |
|
||||
| WO# 11 dígitos | Normalização numérica + unicidade |
|
||||
| Vendor ADR | Mutação em `Dispatch`; cria dispatch primário se ausente |
|
||||
|
||||
---
|
||||
|
||||
## Audit
|
||||
|
||||
- 1 evento por campo alterado (`FieldChanged`, `StatusChanged`, ou `AssignmentChanged`)
|
||||
- Side effects (auto-schedule, rescheduleCount) geram eventos adicionais
|
||||
- Field lock (`WorkOrderFieldLocks`) criado automaticamente em edições manuais
|
||||
- Eventos Dispatch incluem `dispatchId`
|
||||
|
||||
---
|
||||
|
||||
## Código entregue
|
||||
|
||||
| Camada | Arquivo |
|
||||
|--------|---------|
|
||||
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
|
||||
| Helpers | `WorkOrderBoardMutationRules`, `WorkOrderNumberNormalizer`, `WorkOrderBoardFieldNames`, `WorkOrderBoardApptTimeParser` |
|
||||
| Update service | `SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs` |
|
||||
| Audit | `IWorkOrderAuditService.StageFieldChanged`, `LogFieldChangedAsync` |
|
||||
| API | `WorkOrderController` — `PATCH {id}/board` |
|
||||
| Testes | `WorkOrderBoardMutationRulesTests`, `WorkOrderNumberNormalizerTests`, `WorkOrderBoardUpdateServiceTests`, `WorkOrderBoardConcurrencyTests` |
|
||||
|
||||
---
|
||||
|
||||
## Gates de aceite
|
||||
|
||||
### Desenvolvimento
|
||||
|
||||
- [x] `PATCH /api/workorders/{id}/board` para todos os campos da matriz
|
||||
- [x] Dual RowVersion validado; 409 com `currentState`
|
||||
- [x] Auto-schedule Incomplete→Scheduled
|
||||
- [x] RescheduleCount++ em reagendamento
|
||||
- [x] Bloqueio status quando PastDue; reagendar limpa flag on-read
|
||||
- [x] WO cancelado/closed rejeita edição (422)
|
||||
- [x] WO# normalizado 11 dígitos + unicidade
|
||||
- [x] Vendor/appt muta Dispatch; cria primário se ausente
|
||||
- [x] 1 audit event por campo; field lock criado
|
||||
- [x] 51 testes unitários total (25 Fase 2 + 26 Fases 0–1)
|
||||
|
||||
### Staging (pendente)
|
||||
|
||||
- [ ] Smoke Sync durante edição SHOC
|
||||
- [ ] UAT dispatcher: editar células reais no board
|
||||
- [ ] Latência PATCH aceitável (<200ms p95 Tier S)
|
||||
|
||||
---
|
||||
|
||||
## Próximo passo
|
||||
|
||||
**Fase 3** — Criação wizard/inline, soft cancel dedicado, ManualEditFlag na criação.
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
# Fase 3 — Criação e Cancelamento
|
||||
|
||||
**Programa:** Work Orders Board
|
||||
**Objetivo:** Criação wizard/inline via contrato do board SHOC, cancelamento soft dedicado, ManualEditFlag na criação, e delete físico restrito a Admin.
|
||||
|
||||
**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md)
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `POST /api/workorders/board`
|
||||
|
||||
Cria uma WO com contrato alinhado ao board. Suporta wizard (payload completo) e inline row (subset mínimo).
|
||||
|
||||
**Autenticação:** Bearer JWT (`[Authorize]`)
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"woNumber": "12345",
|
||||
"workOrderType": "PM",
|
||||
"siteCode": "BK5",
|
||||
"assignTo": "dispatcher-guid",
|
||||
"dueDate": "2026-07-01",
|
||||
"scheduledDate": "2026-06-25",
|
||||
"targetWeek": "2026-06-22",
|
||||
"scheduleWeekOnly": false,
|
||||
"vendorId": 5,
|
||||
"apptDate": "2026-06-26",
|
||||
"apptTime": "09:00 – 11:00",
|
||||
"docStatus": "No",
|
||||
"description": "Leak in break room",
|
||||
"trade": "HVAC PM",
|
||||
"locationId": 12,
|
||||
"pocContactId": 3,
|
||||
"pocNotes": "Call before arrival"
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Obrigatório | Descrição |
|
||||
|-------|-------------|-----------|
|
||||
| `workOrderType` | Sim | Enum `WorkOrderType` |
|
||||
| `siteCode` | Sim | Código do site |
|
||||
| `woNumber` | Não | Se omitido, auto-gera sequencial normalizado 11 dígitos |
|
||||
| `scheduleWeekOnly` | Não | Se `true`, `targetWeek` é obrigatório |
|
||||
| `vendorId` | Condicional | Obrigatório quando `apptDate` ou `apptTime` informados |
|
||||
|
||||
**Responses:**
|
||||
|
||||
| HTTP | Body | Quando |
|
||||
|------|------|--------|
|
||||
| 200 | `WorkOrderBoardRowDto` | Sucesso — row pronto para inserir no board |
|
||||
| 400 | `Response` | Validação FluentValidation |
|
||||
| 409 | `WorkOrderBoardValidationErrorDto` | WO# duplicado |
|
||||
| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio |
|
||||
|
||||
**Exemplo (inline mínimo):**
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"workOrderType":"PM","siteCode":"BK5"}' \
|
||||
"https://localhost:5001/api/workorders/board"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/workorders/{id}/cancel`
|
||||
|
||||
Soft cancel — transição para `LifecycleStatus.Canceled` com WO read-only.
|
||||
|
||||
**Autenticação:** Bearer JWT
|
||||
|
||||
**Responses:**
|
||||
|
||||
| HTTP | Body | Quando |
|
||||
|------|------|--------|
|
||||
| 200 | `WorkOrderBoardRowDto` | Cancelado (ou já estava cancelado — idempotente) |
|
||||
| 422 | `WorkOrderBoardValidationErrorDto` | WO `Complete` ou `Closed` |
|
||||
|
||||
**Exemplo:**
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer <token>" \
|
||||
"https://localhost:5001/api/workorders/42/cancel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regras de domínio na criação
|
||||
|
||||
| Regra | Comportamento |
|
||||
|-------|---------------|
|
||||
| Status inicial | `Incomplete` |
|
||||
| Auto-schedule | `scheduledDate` + `assignTo` → `Scheduled` |
|
||||
| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` |
|
||||
| Week-only | `scheduleWeekOnly=true` + `targetWeek` → aparece na semana no GET board |
|
||||
| WO# | Manual normalizado 11 dígitos; auto-gerado se omitido |
|
||||
| Vendor | Cria dispatch primário quando `vendorId` informado |
|
||||
| ManualEditFlag | Field locks criados para cada campo SHOC preenchido |
|
||||
|
||||
---
|
||||
|
||||
## Soft cancel vs hard delete
|
||||
|
||||
| Operação | Endpoint | Quem | Efeito |
|
||||
|----------|----------|------|--------|
|
||||
| Soft cancel | `POST /{id}/cancel` | Dispatcher | Status `Canceled`, WO permanece, PATCH bloqueado (422) |
|
||||
| Hard delete | `DELETE DeleteWorkorder` | **Admin only** | Remove registro, anexos e contatos |
|
||||
|
||||
O legado `POST AddWorkorder` (form/Blazor) permanece inalterado.
|
||||
|
||||
---
|
||||
|
||||
## ManualEditFlag na criação
|
||||
|
||||
Campos preenchidos na criação SHOC recebem lock em `WorkOrderFieldLocks` via audit `FieldChanged`. Sync subsequente em campo lockado gera `SyncRejected` (ver [manual-edit-flag-design.md](../phase-0/manual-edit-flag-design.md)).
|
||||
|
||||
WO criada via Sync/Lambda continua sem locks até edição SHOC.
|
||||
|
||||
---
|
||||
|
||||
## Audit
|
||||
|
||||
| Evento | Action |
|
||||
|--------|--------|
|
||||
| Criação | 1× `Create` + `FieldChanged` por campo preenchido |
|
||||
| Auto-schedule na criação | `StatusChanged` adicional |
|
||||
| Cancel | `StatusChanged` → `Canceled` |
|
||||
| Hard delete Admin | `Delete` |
|
||||
|
||||
---
|
||||
|
||||
## Código entregue
|
||||
|
||||
| Camada | Arquivo |
|
||||
|--------|---------|
|
||||
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
|
||||
| Validação | `SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs` |
|
||||
| Scheduling compartilhado | `SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs` |
|
||||
| Create service | `SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs` |
|
||||
| Cancel service | `SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs` |
|
||||
| Audit | `IWorkOrderAuditService.StageCreated`, `LogCreatedAsync` |
|
||||
| API | `WorkOrderController` — `POST board`, `POST {id}/cancel`, `DELETE` Admin |
|
||||
| Testes | `WorkOrderBoardCreateServiceTests`, `WorkOrderBoardCancelServiceTests`, `WorkOrderBoardCreateSyncLockTests` |
|
||||
|
||||
---
|
||||
|
||||
## Gates de aceite
|
||||
|
||||
### Desenvolvimento
|
||||
|
||||
- [x] `POST /api/workorders/board` — Incomplete / auto-schedule / week-only
|
||||
- [x] WO# auto ou manual 11 dígitos + unicidade (409)
|
||||
- [x] Vendor/dispatch primário na criação
|
||||
- [x] Field locks + SyncRejected pós-create SHOC
|
||||
- [x] `POST /{id}/cancel` idempotente; PATCH bloqueado após cancel
|
||||
- [x] `DELETE DeleteWorkorder` restrito a Admin
|
||||
- [x] Legado `AddWorkorder` inalterado
|
||||
- [x] 68 testes unitários total (17 Fase 3 + 51 Fases 0–2)
|
||||
|
||||
### Staging (pendente)
|
||||
|
||||
- [ ] UAT dispatcher: wizard + inline row
|
||||
- [ ] Smoke Sync durante criação SHOC
|
||||
- [ ] Confirmar navegação FE para semana do WO criado
|
||||
|
||||
---
|
||||
|
||||
## Próximo passo
|
||||
|
||||
**Fase 4** — Busca contextual na semana + advanced search cross-week conforme tier volume.
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
# Fase 4 — Search contextual + Advanced Search
|
||||
|
||||
**Programa:** Work Orders Board
|
||||
**Objetivo:** Hardening da busca contextual no board semanal, endpoint de advanced search cross-week (Tier S), índices de suporte e harness de load test.
|
||||
|
||||
**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md), [Fase 3](../phase-3/README.md)
|
||||
|
||||
**Tier vigente (dev):** **S (provisório)** — ver [search-tier-decision.md](./search-tier-decision.md)
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /api/workorders/board?search=` (hardening)
|
||||
|
||||
Busca contextual na janela semanal. Campos cobertos:
|
||||
|
||||
| Campo | Origem |
|
||||
|-------|--------|
|
||||
| Site | `SiteCode` |
|
||||
| WO# | `InternalWONumber`, `WorkerOrderNumber` |
|
||||
| Location | `Locations.Name` |
|
||||
| Dispatcher | `AssignToUser` nome |
|
||||
| PM | `Trade`, `Problem` |
|
||||
| Vendor | `PrimaryDispatch.Vendor.CompanyName` |
|
||||
| Tech | `PrimaryDispatch.Vendor.ContactName` |
|
||||
| Status legado | `Status` |
|
||||
| Lifecycle | label enum (`Scheduled`, `In Progress`, etc.) |
|
||||
|
||||
**Regras:**
|
||||
|
||||
- `search` com trim; ignorado se vazio ou < 2 caracteres
|
||||
- `counts.total` — agendadas na semana **sem** search
|
||||
- `counts.returned` — agendadas **com** search
|
||||
|
||||
### `GET /api/workorders/board/search`
|
||||
|
||||
Advanced search cross-week paginado.
|
||||
|
||||
**Autenticação:** Bearer JWT
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Tipo | Descrição |
|
||||
|-------|------|-----------|
|
||||
| `search` | string | Texto livre (mesmos campos do contextual) |
|
||||
| `datePreset` | enum | `thisWeek`, `lastWeek`, `thisMonth`, `last3Months`, `nextWeek`, `nextMonth`, `custom` |
|
||||
| `dateFrom` / `dateTo` | date | Obrigatórios se `datePreset=custom` |
|
||||
| `sites` | string[] | `SiteCode` |
|
||||
| `types` | WorkOrderType[] | Tipo WO |
|
||||
| `dispatchers` | string[] | GUIDs + `__unassigned__` |
|
||||
| `statuses` | LifecycleStatus[] | Status operacional |
|
||||
| `pmTypes` | string[] | Match em `Trade`/`Problem` (contains, case-insensitive) |
|
||||
| `vendorIds` | int[] | Via `PrimaryDispatch.VendorId` |
|
||||
| `docStatuses` | DocStatus[] | Enum `DocStatus` |
|
||||
| `myWorkOrders` | bool | Filtro usuário logado |
|
||||
| `page` | int | Default 1 |
|
||||
| `pageSize` | int | Default 50; max 100 (Tier S/M) |
|
||||
| `sortBy` | string | `scheduledDate` (default), `woNumber`, `dueDate` |
|
||||
| `sortDir` | string | `asc` / `desc` |
|
||||
|
||||
**Response:** `PagedResult<WorkOrderBoardRowDto>`
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [ /* WorkOrderBoardRowDto */ ],
|
||||
"totalCount": 120,
|
||||
"page": 1,
|
||||
"pageSize": 50,
|
||||
"totalPages": 3,
|
||||
"hasNext": true,
|
||||
"hasPrevious": false
|
||||
}
|
||||
```
|
||||
|
||||
**Exemplo:**
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"https://localhost:5001/api/workorders/board/search?datePreset=thisMonth&sites=BK5&search=HVAC&page=1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Matriz preset → intervalo de datas
|
||||
|
||||
Base: **segunda-feira ISO** (`WorkOrderSearchDateRangeResolver`).
|
||||
|
||||
| Preset | Intervalo |
|
||||
|--------|-----------|
|
||||
| `thisWeek` | Seg–Dom da semana ISO corrente |
|
||||
| `lastWeek` | Seg–Dom da semana ISO anterior |
|
||||
| `nextWeek` | Seg–Dom da próxima semana ISO |
|
||||
| `thisMonth` | 1º–último dia do mês corrente |
|
||||
| `nextMonth` | 1º–último dia do mês seguinte |
|
||||
| `last3Months` | Hoje − 3 meses → hoje |
|
||||
| `custom` | `dateFrom` / `dateTo` (400 se ausentes) |
|
||||
|
||||
**Filtro de data:** `ScheduledDate` no intervalo **OU** `ScheduleWeekOnly && TargetWeek` intersectando o intervalo. Exclui templates e `IsDeleted`.
|
||||
|
||||
---
|
||||
|
||||
## Código entregue
|
||||
|
||||
| Camada | Arquivo |
|
||||
|--------|---------|
|
||||
| Search filter | `SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs` |
|
||||
| Query filters | `SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs` |
|
||||
| Projeção | `SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs` |
|
||||
| Date presets | `SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs` |
|
||||
| Advanced data | `SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs` |
|
||||
| Advanced service | `SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs` |
|
||||
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
|
||||
| API | `WorkOrderController` — `GET board/search` |
|
||||
| Migration | `20260624200000_Phase4_SearchIndexes.cs` |
|
||||
| Testes | `SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs` |
|
||||
| Load test | `scripts/load-test/work-order-search.k6.js` |
|
||||
| Seed | `scripts/seed-work-orders-search.ps1` |
|
||||
|
||||
---
|
||||
|
||||
## Gates de aceite
|
||||
|
||||
### Dev (implementação)
|
||||
|
||||
- [x] Helper de search compartilhado + testes por campo
|
||||
- [x] `GET /board/search` paginado com filtros FE
|
||||
- [x] Date presets alinhados ao SHOC (ISO Monday)
|
||||
- [x] Migration índices Phase 4
|
||||
- [x] Harness k6 + seed sintético (Tier S local)
|
||||
- [x] Endpoints legados inalterados
|
||||
- [x] ~12 testes unitários novos (total ~80)
|
||||
|
||||
### Staging/prod (GO — pendente)
|
||||
|
||||
- [ ] Volume Discovery preenchido — [volume-discovery-report.md](../phase-0/volume-discovery-report.md)
|
||||
- [ ] Tier assinado — [search-tier-decision.md](./search-tier-decision.md)
|
||||
- [ ] Load test em staging com volume real
|
||||
- [ ] Latência dentro do SLO do tier confirmado
|
||||
- [ ] Smoke Portal/Blazor inalterados
|
||||
|
||||
---
|
||||
|
||||
## SLOs Tier S (load test local)
|
||||
|
||||
| Cenário | Endpoint | p95 |
|
||||
|---------|----------|-----|
|
||||
| Board sem search | `GET /board?weekStart=...` | < 800ms |
|
||||
| Board com search | `GET /board?search=BK5` | < 1000ms |
|
||||
| Advanced preset | `GET /board/search?datePreset=thisMonth` | < 1200ms |
|
||||
| Advanced multi-filter | sites + types + search | < 1500ms |
|
||||
|
||||
Ver [scripts/load-test/README.md](../../scripts/load-test/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Full-text search (Tier L)
|
||||
- Search externo dedicado (Tier XL)
|
||||
- Lookups PM catalog (`PM_TYPES`) — backlog #17
|
||||
- Alterações em endpoints legados
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# Search Tier Decision — Fase 4
|
||||
|
||||
**Data:** 2026-06-24
|
||||
**Status:** Provisório (dev) — aguardando Volume Discovery staging/prod
|
||||
|
||||
---
|
||||
|
||||
## Tier confirmado
|
||||
|
||||
| Ambiente | Tier | Total WOs | Decisão |
|
||||
|----------|------|-----------|---------|
|
||||
| Dev local | **S (provisório)** | < 25k sintético | Implementar SQL filtros + paginação opcional |
|
||||
| Staging | _pendente_ | _pendente acesso_ | Executar [volume-discovery-report.md](../phase-0/volume-discovery-report.md) |
|
||||
| Produção | _pendente_ | _pendente acesso_ | Executar queries read-only |
|
||||
|
||||
---
|
||||
|
||||
## Estratégia por tier
|
||||
|
||||
| Tier | Volume | Estratégia advanced search |
|
||||
|------|--------|----------------------------|
|
||||
| **S** | < 25k | SQL filtros compostos + paginação default 50, max 100 |
|
||||
| **M** | 25k–250k | Paginação **obrigatória** + índices covering |
|
||||
| **L** | 250k–1M | Spike full-text SQL Server — **não implementar sem spike** |
|
||||
| **XL** | > 1M | Documentar ARB; search externo — **não implementar** |
|
||||
|
||||
---
|
||||
|
||||
## Implementação atual (Tier S)
|
||||
|
||||
- `WorkOrderBoardSearchFilter` — filtro textual compartilhado (board + advanced)
|
||||
- `WorkOrderAdvancedSearchDataService` — query composta com `CountAsync` + `Skip/Take`
|
||||
- Índices Phase 4: `SiteCode`, `InternalWONumber`, `LifecycleStatus+ScheduledDate`
|
||||
- Min 2 caracteres em `search` para evitar full-table scan
|
||||
|
||||
---
|
||||
|
||||
## Próximos passos (gate staging)
|
||||
|
||||
1. Executar `scripts/volume-discovery.ps1` contra staging read-only
|
||||
2. Preencher tabela §3 do volume report
|
||||
3. Atualizar este documento com tier real
|
||||
4. Repetir load test k6 em staging; ajustar `pageSize` max se tier = M
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- [volume-discovery-report.md](../phase-0/volume-discovery-report.md)
|
||||
- [phase-4/README.md](./README.md)
|
||||
- Roadmap §10, §12 Fase 4
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue