Compare commits

...

18 commits

Author SHA1 Message Date
Alexandre Brandizzi
a9dee0bb84 merge: integrate origin/dev into work-orders-phase-4
Merge origin/dev (vendor roadmap PRs #28/#29, phases 1-3 PRs #13-15)
into work-orders-phase-4. Conflict in WorkOrderController.cs resolved by
keeping both the Phase 4 board/search endpoint and the dev-side XML doc
comment on GetDispatcherLookups (both additions at the same site).

Migration timestamps remain monotonic and non-overlapping. Phase 4 global
advanced search, search index migrations, and the cross-platform LocalDB
test guard are preserved alongside the merged vendor roadmap and phases 1-3.

Validation (Docker .NET 8 SDK): build 0 errors; 191 tests pass across
all three test projects (64 + 18 + 109).
2026-07-24 13:38:20 -03:00
Alexandre Brandizzi
8192da7790
Merge pull request #15 from Sea-Haven-Industries/feat/work-orders-phase-3
Feat/work orders Phase 3 board create and soft cancel
2026-07-24 13:34:39 -03:00
Alexandre Brandizzi
df828cf48c
Merge pull request #14 from Sea-Haven-Industries/feat/work-orders-phase-2
Feat/work orders phase 2
2026-07-24 13:32:25 -03:00
Alexandre Brandizzi
3bd0268129
Merge pull request #13 from Sea-Haven-Industries/feat/work-orders-phase-1-board
Feat/work orders phase 1 board
2026-07-24 13:29:09 -03:00
Alexandre Brandizzi
782d0e8ae9
Merge pull request #29 from Sea-Haven-Industries/codex/vendor-roadmap-completion-backend
Complete vendor operations roadmap backend
2026-07-24 13:24:22 -03:00
Alexandre Brandizzi
af724ff625 fix(test): guard LocalDB migration check by platform 2026-07-24 13:10:50 -03:00
Alexandre Brandizzi
b1421de0b5 fix(vendors): address roadmap review findings 2026-07-24 11:49:57 -03:00
Alexandre Brandizzi
4b4ab04615
Merge pull request #28 from Sea-Haven-Industries/codex/vendor-core-completion-backend
Complete core vendor workflows
2026-07-23 19:46:01 -03:00
Alexandre Brandizzi
39829f7274 Merge branch 'codex/vendor-core-completion-backend' into codex/vendor-roadmap-completion-backend 2026-07-23 19:18:19 -03:00
Alexandre Brandizzi
4863d1fdaf feat(vendors): complete operations roadmap backend 2026-07-23 19:18:06 -03:00
Alexandre Brandizzi
5b4b538c16 fix(vendors): preserve company and phone integrity 2026-07-23 19:17:14 -03:00
Alexandre Brandizzi
8cf49afc2c feat(vendors): complete core vendor workflows 2026-07-23 17:02:40 -03:00
Alexandre Brandizzi
1162c68596
feat(vendors): add directory filters and details API (#25)
* feat(vendors): add directory filters and details API

* fix(vendors): preserve omitted status

* fix(vendors): align facet filtering

* fix(vendors): address directory review findings
2026-07-23 15:55:47 +00:00
Alexandre Brandizzi
1ff29c234e
Merge branch 'dev' into feat/work-orders-phase-1-board 2026-07-21 14:01:48 -03:00
Alexandre Brandizzi
32b9113057
Merge pull request #10 from Sea-Haven-Industries/feat/work-orders-domain-foundation
feat(work-orders): Phase 0 domain foundation with FE alignment
2026-07-21 13:58:18 -03:00
Arthur Bassi
75969a1e3d docs(work-orders): clarify board filter precedence and dispatcher lookup
Document myWorkOrders overriding dispatchers, weekend dayGroup null contract, and that dispatcher lookups currently return all users. Map service ArgumentException to 400 for consistency.
2026-07-21 09:33:24 -03:00
Arthur Bassi
f3d2a5cfe7 fix(work-orders): address PR13 phase-1 review feedback 2026-07-17 14:51:56 -03:00
Arthur Bassi
cc8825ace0 fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery 2026-07-10 11:20:14 -03:00
42 changed files with 8430 additions and 71 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

@ -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)
{
@ -174,6 +327,8 @@ namespace Api.SeaHavenIndustries.Controllers
{
v.Id,
v.CompanyName,
v.ContactName,
v.PreferredContact,
v.TradeSpecialties,
v.Address,
v.City,
@ -191,6 +346,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
@ -212,6 +369,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

@ -95,6 +95,12 @@ namespace Api.SeaHavenIndustries.Controllers
_advancedSearchService = advancedSearchService;
}
/// <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, BindRequired] DateOnly weekStart,
@ -109,19 +115,26 @@ namespace Api.SeaHavenIndustries.Controllers
if (weekValidationError != null)
return BadRequest(weekValidationError);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var query = new WorkOrderBoardQueryDto
try
{
WeekStart = weekStart,
WeekEnd = weekEnd,
Dispatchers = dispatchers,
MyWorkOrders = myWorkOrders,
Types = types,
Search = search
};
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);
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("board/search")]
@ -139,6 +152,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()
{
@ -670,6 +687,8 @@ namespace Api.SeaHavenIndustries.Controllers
d.Description,
d.Status,
d.ScheduledDate,
d.EstimatedArrivalAt,
d.EtaManualOverride,
d.CompletedDate,
d.DispatchedAt,
d.EmailSent,
@ -1039,7 +1058,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++;
@ -1058,6 +1080,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()
@ -1137,6 +1161,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 });
@ -1163,6 +1200,8 @@ namespace Api.SeaHavenIndustries.Controllers
d.Description,
d.Status,
d.ScheduledDate,
d.EstimatedArrivalAt,
d.EtaManualOverride,
d.CompletedDate,
d.DispatchedAt,
d.EmailSent,
@ -1189,6 +1228,8 @@ namespace Api.SeaHavenIndustries.Controllers
x.Description,
x.Status,
x.ScheduledDate,
x.EstimatedArrivalAt,
x.EtaManualOverride,
x.CompletedDate,
x.DispatchedAt,
x.EmailSent,
@ -1277,6 +1318,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,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

@ -40,6 +40,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",

View file

@ -73,6 +73,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; }
@ -90,7 +110,11 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { 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,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

@ -631,6 +631,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CommercialStatusUpdatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -655,6 +658,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");
@ -670,6 +686,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)");
@ -1147,6 +1167,86 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("EmployeePhones");
});
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")
@ -1526,6 +1626,65 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Regions");
});
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")
@ -1727,12 +1886,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)");
@ -1748,6 +1920,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");
@ -1760,9 +1935,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)");
@ -1777,6 +1958,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("Vendors");
});
@ -1839,6 +2022,195 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("VendorAccessTokens");
});
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")
@ -1970,13 +2342,13 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("ScheduledStart")
.HasColumnType("datetime2");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
b.Property<string>("Service")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
b.Property<string>("SignOffAttachment")
.HasColumnType("nvarchar(max)");
@ -2694,6 +3066,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")
@ -2736,6 +3127,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")
@ -2747,6 +3148,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")
@ -2937,6 +3376,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Comments");
b.Navigation("CompletionDocuments");
b.Navigation("DispatchWorkOrders");
b.Navigation("Signoffs");
@ -2980,7 +3421,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; }
@ -35,5 +41,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

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

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

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

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

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

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

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

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

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

@ -136,6 +136,9 @@ public class Phase4SearchIndexesSqlServerTests
private static async Task<bool> IsLocalDbAvailableAsync()
{
if (!OperatingSystem.IsWindows())
return false;
try
{
await using var connection = new SqlConnection(LocalDbMaster);

View file

@ -0,0 +1,159 @@
# Fase 1 — Weekly Board (leitura)
**Programa:** Work Orders Board
**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais.
---
## Endpoints
### `GET /api/workorders/board`
Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada.
**Autenticação:** Bearer JWT (`[Authorize]`)
**Query params:**
| Param | Obrigatório | Descrição |
|-------|-------------|-----------|
| `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) |
**Exemplo:**
```bash
curl -H "Authorization: Bearer <token>" \
"https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true"
```
**Response:**
```json
{
"weekStart": "2026-06-22",
"weekEnd": "2026-06-26",
"counts": { "returned": 42, "total": 58 },
"unscheduled": [ /* WorkOrderBoardRowDto[] */ ],
"scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ]
}
```
- `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 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
[
{ "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" }
]
```
---
## Matriz coluna → campo API
| Coluna board | Campos response |
|--------------|-----------------|
| WO# | `woNumber`, `rescheduleCount`, `carriedOver` |
| Type | `workOrderType`, `isPastDue` |
| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` |
| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` |
| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` |
| Due | `dueDate` |
| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` |
| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` |
| Appt | `apptDate`, `apptTime` |
| Past Due | `isPastDue` (derivado on-read) |
| Carried | `carriedOver` |
| Reschedule | `rescheduleCount` |
| Doc | `docStatus` |
| Actions | FE only |
---
## Regras de derivação
### isPastDue
```
ScheduledDate < UTC hoje
AND LifecycleStatus NOT IN (Complete, Canceled, Closed)
```
Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs`
### Vendor / Appt
Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)).
### Janela semanal
WO entra em **scheduled** se:
- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou**
- `ScheduleWeekOnly == true` e `TargetWeek == weekStart`
**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`).
---
## Código entregue
| Camada | Arquivo |
|--------|---------|
| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` |
| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` |
| 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/20260701120000_Phase1_BoardIndexes.cs` |
| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` |
---
## Gates de aceite
### Desenvolvimento (concluído)
- [x] Endpoint `GET board` — semana + Unscheduled
- [x] Projeção 12/14 colunas de dados
- [x] `isPastDue` derivado on-read
- [x] Vendor via dispatch primário
- [x] Filtros dispatcher / My WOs / tipo / search
- [x] Contador X of Y
- [x] Índices Tier S
- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total)
### Staging / produção (pendente)
- [ ] Sign-off CTO/PO (herda gates Fase 0)
- [ ] Dry-run migration Phase0 + Phase1 em staging
- [ ] Tier volume confirmado em staging/prod
- [ ] Smoke checklist Blazor / Sync / Portal
- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados)
- [ ] Latência aceitável com volume real (Tier S: &lt; 25k WOs)
- [ ] Feature flag SHOC board em staging
---
## Coexistência
Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas.
---
## Próximo passo
**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular).