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:
Alexandre Brandizzi 2026-07-24 14:20:16 -03:00
commit e5cf4cec09
107 changed files with 12199 additions and 4151 deletions

View 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");
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}
}

View 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);
}
}

View file

@ -65,6 +65,7 @@ public class WorkOrderControllerSearchTests
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext

View file

@ -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)
{

View 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; }
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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; }

View file

@ -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; }
}

View file

@ -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; }
}
}

View 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;
}
}
}

View 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;
}
}
}
}

View 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);
}
}

View file

@ -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();

View file

@ -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",

View file

@ -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"
}

View file

@ -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; }

View 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
};
}
}

View file

@ -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;
""");
}
}
}

View file

@ -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)");

View file

@ -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);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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)");

View file

@ -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");
}
}
}

View file

@ -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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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 =>

View file

@ -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; }
}
}

View 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; }
}
}

View 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; }
}
}

View file

@ -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; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View file

@ -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))]

View file

@ -1,7 +0,0 @@
namespace SeaHaven.DataServices.Board
{
public static class WorkOrderBoardConstants
{
public const int ClientSideThreshold = 300;
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -1,9 +0,0 @@
namespace SeaHaven.DataServices.Exceptions
{
public class BoardQueryException : Exception
{
public BoardQueryException(string message) : base(message)
{
}
}
}

View file

@ -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(),

View file

@ -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
}
}

View file

@ -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();
}
}
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -1,9 +0,0 @@
namespace SeaHaven.DataServices.Interfaces
{
public record PrimaryDispatchProjection(
int WorkOrderId,
int? DispatchId,
string? VendorName,
DateTime? ApptDate,
string? DispatchStatus);
}

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]

View file

@ -1,8 +0,0 @@
namespace SeaHaven.Services.Board
{
public static class WorkOrderBoardConstants
{
public const int MaxWindowDays = 90;
public const int ClientSideThreshold = 300;
}
}

View file

@ -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);

View file

@ -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;
}
}
}

View file

@ -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; }
}
}

View 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;
}
}

View 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;
}
}
}

View file

@ -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();

View file

@ -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);

View file

@ -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)

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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();
}
}
}

View file

@ -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
});
}
}
}

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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)

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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.
}
}
}

View file

@ -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)

View file

@ -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()
{

View file

@ -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);
}
}

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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`.

View file

@ -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)

View file

@ -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

View file

@ -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: _______

View file

@ -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.

View file

@ -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 | | |

View file

@ -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: _______

View file

@ -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: _______

View file

@ -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.

View file

@ -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 | |

View file

@ -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

View file

@ -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.

View file

@ -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 | | |

View file

@ -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).

View file

@ -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.

View file

@ -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.

View file

@ -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` |
---

View file

@ -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.

View file

@ -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.

View file

@ -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 &lt; 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=...` | &lt; 800ms |
| Board com search | `GET /board?search=BK5` | &lt; 1000ms |
| Advanced preset | `GET /board/search?datePreset=thisMonth` | &lt; 1200ms |
| Advanced multi-filter | sites + types + search | &lt; 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

View file

@ -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)** | &lt; 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** | &lt; 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** | &gt; 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