mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
merge: integrate origin/dev into work-orders-phase-5
Brings in merged PR #16 and vendor/Phase 1-4 work from origin/dev into the Phase 5 (WeekRolled) head. Only conflict was ApplicationDbContextModelSnapshot.cs (EF model snapshot touched by both sides); resolved by taking dev's latest snapshot (matches migration 20260723220614) and grafting the WorkOrderWeekRolledLedger entity + relationship blocks, matching the Phase5 migration Designer exactly. Migration ordering unchanged: Phase5 (20260709) runs before vendor migrations (20260720-20260723).
This commit is contained in:
commit
5fa9b4e344
41 changed files with 8461 additions and 105 deletions
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal file
169
Api.SeaHavenIndustries.Tests/VendorControllerTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorControllerTests
|
||||
{
|
||||
private static VendorController NewController(Mock<IVendorService> service)
|
||||
{
|
||||
var controller = new VendorController(
|
||||
service.Object,
|
||||
db: null!,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "42")
|
||||
}, "Test"))
|
||||
}
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new VendorDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var model = new EditVendor_DTO
|
||||
{
|
||||
Id = 8,
|
||||
CompanyName = "Updated Name"
|
||||
};
|
||||
|
||||
var result = await controller.Update(8, model);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
service.Verify(x => x.UpdateVendorAsync(
|
||||
8,
|
||||
It.Is<UpdateVendorDTO>(dto => dto.IsActive == null),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new VendorDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var model = new EditVendor_DTO
|
||||
{
|
||||
Id = 8,
|
||||
CompanyName = "Updated Name",
|
||||
IsActive = false
|
||||
};
|
||||
|
||||
await controller.Update(8, model);
|
||||
|
||||
service.Verify(x => x.UpdateVendorAsync(
|
||||
8,
|
||||
It.Is<UpdateVendorDTO>(dto => dto.IsActive == false),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutIsActive_DefaultsToActive()
|
||||
{
|
||||
var service = new Mock<IVendorService>();
|
||||
service.Setup(x => x.CreateVendorAsync(It.IsAny<CreateVendorDTO>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new VendorDTO());
|
||||
|
||||
var controller = NewController(service);
|
||||
|
||||
var model = new Vendor_DTO
|
||||
{
|
||||
CompanyName = "New Co"
|
||||
};
|
||||
|
||||
await controller.Create(model);
|
||||
|
||||
service.Verify(x => x.CreateVendorAsync(
|
||||
It.Is<CreateVendorDTO>(dto => dto.IsActive == true),
|
||||
"42"), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "Active Co", "Inactive Co")]
|
||||
[InlineData(false, "Inactive Co", "Active Co")]
|
||||
public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors(
|
||||
bool isActive,
|
||||
string expected,
|
||||
string excluded)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = new VendorController(
|
||||
Mock.Of<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
var result = await controller.GetFacets(isActive);
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
|
||||
payload.Should().Contain(expected);
|
||||
payload.Should().NotContain(excluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = new VendorController(
|
||||
Mock.Of<IVendorService>(),
|
||||
context,
|
||||
zipDistance: null!,
|
||||
vendorTokens: null!,
|
||||
config: null!);
|
||||
|
||||
var result = await controller.GetFacets();
|
||||
var payload = JsonSerializer.Serialize(
|
||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
||||
|
||||
payload.Should().Contain("Active Co");
|
||||
payload.Should().Contain("Inactive Co");
|
||||
}
|
||||
}
|
||||
324
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
324
Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorDataServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext NewSqlServerContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True")
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static Vendor VendorWithJobs(
|
||||
string company,
|
||||
string city,
|
||||
string state,
|
||||
string trades,
|
||||
int jobCount,
|
||||
bool isActive = true)
|
||||
{
|
||||
return new Vendor
|
||||
{
|
||||
CompanyName = company,
|
||||
ContactName = $"{company} Technician",
|
||||
City = city,
|
||||
State = state,
|
||||
TradeSpecialties = trades,
|
||||
IsActive = isActive,
|
||||
IsDeleted = false,
|
||||
Dispatches = Enumerable.Range(1, jobCount)
|
||||
.Select(workOrderId => new Dispatch { WorkOrderId = workOrderId })
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task SeedBoundaryVendors(ApplicationDbContext context)
|
||||
{
|
||||
context.Vendors.AddRange(
|
||||
VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49),
|
||||
VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50),
|
||||
VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100),
|
||||
VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("under-50", "Under Co")]
|
||||
[InlineData("50-99", "Fifty Co")]
|
||||
[InlineData("100-149", "Hundred Co")]
|
||||
[InlineData("150-plus", "One Fifty Co")]
|
||||
public async Task GetPagedAsync_AppliesExactJobBucketBoundaries(
|
||||
string bucket,
|
||||
string expectedCompany)
|
||||
{
|
||||
await using var context = NewContext();
|
||||
await SeedBoundaryVendors(context);
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var (items, totalCount) = await service.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
isActive: true,
|
||||
jobBuckets: new[] { bucket });
|
||||
|
||||
totalCount.Should().Be(1);
|
||||
items.Single().CompanyName.Should().Be(expectedCompany);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
await SeedBoundaryVendors(context);
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var (items, totalCount) = await service.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
isActive: true,
|
||||
companies: new[] { "Under Co", "Hundred Co" },
|
||||
trades: new[] { "HVAC", "Plumbing" },
|
||||
locations: new[] { "Chicago, IL" },
|
||||
jobBuckets: new[] { "under-50", "100-149" });
|
||||
|
||||
totalCount.Should().Be(1);
|
||||
items.Single().CompanyName.Should().Be("Under Co");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1),
|
||||
VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false));
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var active = await service.GetPagedAsync(1, 1, isActive: true);
|
||||
var inactive = await service.GetPagedAsync(1, 1, isActive: false);
|
||||
|
||||
active.TotalCount.Should().Be(1);
|
||||
inactive.TotalCount.Should().Be(1);
|
||||
active.Items.Single().CompanyName.Should().Be("Active Co");
|
||||
inactive.Items.Single().CompanyName.Should().Be("Inactive Co");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" });
|
||||
cityOnly.TotalCount.Should().Be(1);
|
||||
cityOnly.Items.Single().CompanyName.Should().Be("CityOnly");
|
||||
|
||||
var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" });
|
||||
stateOnly.TotalCount.Should().Be(1);
|
||||
stateOnly.Items.Single().CompanyName.Should().Be("StateOnly");
|
||||
|
||||
var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" });
|
||||
full.TotalCount.Should().Be(1);
|
||||
full.Items.Single().CompanyName.Should().Be("FullLoc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" });
|
||||
hvac.TotalCount.Should().Be(1);
|
||||
hvac.Items.Single().CompanyName.Should().Be("HvacOnly");
|
||||
|
||||
var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" });
|
||||
hvacControls.TotalCount.Should().Be(1);
|
||||
hvacControls.Items.Single().CompanyName.Should().Be("HvacControls");
|
||||
|
||||
var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" });
|
||||
plumb.TotalCount.Should().Be(0);
|
||||
plumb.Items.Should().BeEmpty();
|
||||
|
||||
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
|
||||
plumbing.TotalCount.Should().Be(1);
|
||||
plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false },
|
||||
new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
|
||||
plumbing.TotalCount.Should().Be(2);
|
||||
plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle");
|
||||
|
||||
var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" });
|
||||
roofing.TotalCount.Should().Be(1);
|
||||
roofing.Items.Single().CompanyName.Should().Be("Middle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var vendor = new Vendor
|
||||
{
|
||||
CompanyName = "History Co",
|
||||
IsActive = true,
|
||||
IsDeleted = false,
|
||||
Dispatches = new List<Dispatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 100,
|
||||
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||
{
|
||||
new() { WorkOrderId = 100 },
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
},
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
};
|
||||
context.Vendors.Add(vendor);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var result = await service.GetPagedAsync(1, 12, isActive: true);
|
||||
|
||||
result.Items.Single().TotalJobs.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2);
|
||||
context.Vendors.Add(vendor);
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var result = await service.GetByIdAsync(vendor.Id);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
context.Entry(result!).State.Should().Be(EntityState.Unchanged);
|
||||
context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse();
|
||||
result!.Dispatches.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("trade")]
|
||||
[InlineData("location")]
|
||||
[InlineData("job-bucket")]
|
||||
public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter)
|
||||
{
|
||||
using var context = NewSqlServerContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var query = filter switch
|
||||
{
|
||||
"trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }),
|
||||
"location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }),
|
||||
_ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" })
|
||||
};
|
||||
|
||||
var sql = query.ToQueryString();
|
||||
|
||||
sql.Should().Contain("SELECT");
|
||||
if (filter == "trade")
|
||||
sql.Should().Contain("TradeSpecialties");
|
||||
if (filter == "location")
|
||||
sql.Should().Contain("City");
|
||||
if (filter == "job-bucket")
|
||||
sql.Should().Contain("UNION");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation()
|
||||
{
|
||||
using var context = NewSqlServerContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString();
|
||||
|
||||
sql.Should().Contain("SELECT");
|
||||
sql.Should().Contain("Dispatches");
|
||||
sql.Should().Contain("DispatchWorkOrders");
|
||||
sql.Should().Contain("workOrders");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true };
|
||||
context.Vendors.Add(vendor);
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 701,
|
||||
WorkerOrderNumber = "WO-701",
|
||||
WorkerOrderTitle = "Direct And Multi",
|
||||
Status = "Scheduled",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 702,
|
||||
WorkerOrderNumber = "WO-702",
|
||||
WorkerOrderTitle = "Multi Only",
|
||||
Status = "Scheduled",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
|
||||
var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor };
|
||||
var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor };
|
||||
context.Dispatches.AddRange(directDispatch, multiDispatch);
|
||||
context.DispatchWorkOrders.AddRange(
|
||||
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 },
|
||||
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorDataService(context);
|
||||
var result = await service.GetLinkedWorkOrdersAsync(60);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
|
||||
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
}
|
||||
313
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
313
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
using System.Collections;
|
||||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorOperationsControllerTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static VendorOperationsController NewController(ApplicationDbContext context)
|
||||
{
|
||||
var controller = new VendorOperationsController(
|
||||
context,
|
||||
new DispatchDataService(context),
|
||||
TimeProvider.System);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static object Prop(object source, string name) =>
|
||||
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||
|
||||
[Fact]
|
||||
public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-48",
|
||||
WorkerOrderTitle = "Needs vendor",
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(47)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetNotifications();
|
||||
|
||||
var value = ((OkObjectResult)result).Value!;
|
||||
var items = ((IEnumerable)Prop(value, "Items")).Cast<object>().ToList();
|
||||
items.Should().ContainSingle();
|
||||
Prop(items[0], "Type").Should().Be("NoVendor");
|
||||
Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-ASSIGNED",
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(24)
|
||||
};
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = workOrder.ScheduledDate
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetNotifications();
|
||||
|
||||
var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast<object>();
|
||||
items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var site = new Locations { Name = "St Louis" };
|
||||
var preferred = new Vendor
|
||||
{
|
||||
CompanyName = "Preferred",
|
||||
ContactName = "Alex",
|
||||
IsActive = true,
|
||||
TradeSpecialties = "HVAC",
|
||||
AvailabilityStatus = "Available",
|
||||
AvailabilityUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-BUSY",
|
||||
ScheduledStart = DateTime.UtcNow.AddHours(1),
|
||||
ScheduledEnd = DateTime.UtcNow.AddHours(3)
|
||||
};
|
||||
context.AddRange(site, preferred, busy, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.SitePreferredVendors.Add(new SitePreferredVendor
|
||||
{
|
||||
LocationId = site.Id,
|
||||
VendorId = preferred.Id,
|
||||
Trade = "HVAC",
|
||||
SortOrder = 0
|
||||
});
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = busy.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = workOrder.ScheduledStart
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetAvailability(
|
||||
DateTime.UtcNow.AddHours(2),
|
||||
DateTime.UtcNow.AddHours(4),
|
||||
site.Id,
|
||||
"HVAC");
|
||||
|
||||
var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast<object>().ToList();
|
||||
Prop(rows[0], "CompanyName").Should().Be("Preferred");
|
||||
Prop(rows[0], "IsPreferred").Should().Be(true);
|
||||
var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy");
|
||||
Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceSitePreferences_RejectsInactiveVendor()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var site = new Locations { Name = "St Louis" };
|
||||
var inactive = new Vendor { CompanyName = "Inactive", IsActive = false };
|
||||
context.AddRange(site, inactive);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).ReplaceSitePreferences(site.Id,
|
||||
new List<VendorOperationsController.SitePreferenceRequest>
|
||||
{
|
||||
new() { VendorId = inactive.Id }
|
||||
});
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.SitePreferredVendors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsightsExports_ReturnStableCsvAndPdfFormats()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false };
|
||||
var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Completed",
|
||||
DispatchedAt = DateTime.UtcNow.AddHours(-4),
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(-2),
|
||||
AcknowledgedAt = DateTime.UtcNow.AddHours(-3),
|
||||
CompletedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var controller = NewController(context);
|
||||
|
||||
var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null);
|
||||
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null);
|
||||
|
||||
csv.ContentType.Should().Be("text/csv");
|
||||
var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents);
|
||||
csvText.Should().Contain("AverageCycleHours");
|
||||
csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\"");
|
||||
pdf.ContentType.Should().Be("application/pdf");
|
||||
System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_RejectsInactiveVendor()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Inactive", IsActive = false };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
workOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id });
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.Dispatches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var start = DateTime.UtcNow.AddHours(2);
|
||||
var vendor = new Vendor
|
||||
{
|
||||
CompanyName = "Gateway",
|
||||
IsActive = true,
|
||||
AvailabilityStatus = "Available",
|
||||
AvailabilityUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var existingWorkOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-EXISTING",
|
||||
ScheduledStart = start,
|
||||
ScheduledEnd = start.AddHours(2)
|
||||
};
|
||||
var assignedWorkOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-NEW",
|
||||
ScheduledStart = start.AddMinutes(30),
|
||||
ScheduledEnd = start.AddHours(1)
|
||||
};
|
||||
context.AddRange(vendor, existingWorkOrder, assignedWorkOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = existingWorkOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = existingWorkOrder.ScheduledStart
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var eta = start.AddMinutes(45);
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
assignedWorkOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
ScheduledStart = assignedWorkOrder.ScheduledStart,
|
||||
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
|
||||
EstimatedArrivalAt = eta,
|
||||
EtaManualOverride = true
|
||||
});
|
||||
|
||||
var value = ((OkObjectResult)result).Value!;
|
||||
Prop(value, "AvailabilityStatus").Should().Be("Unavailable");
|
||||
((IEnumerable)Prop(value, "Conflicts")).Cast<object>().Should().ContainSingle();
|
||||
var dispatch = await context.Dispatches.SingleAsync(item =>
|
||||
item.WorkOrderId == assignedWorkOrder.Id);
|
||||
dispatch.EstimatedArrivalAt.Should().Be(eta);
|
||||
dispatch.EtaManualOverride.Should().BeTrue();
|
||||
dispatch.DispatchNumber.Should().StartWith("DISP-");
|
||||
context.WorkOrderAuditLogs.Should().ContainSingle(log =>
|
||||
log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var originalVendor = new Vendor { CompanyName = "Original", IsActive = true };
|
||||
var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-LINKED",
|
||||
ScheduledStart = DateTime.UtcNow.AddHours(2),
|
||||
ScheduledEnd = DateTime.UtcNow.AddHours(4)
|
||||
};
|
||||
context.AddRange(originalVendor, replacementVendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = originalVendor.Id,
|
||||
Status = "Sent",
|
||||
DispatchNumber = "DISP-LINKED"
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
workOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest
|
||||
{
|
||||
VendorId = replacementVendor.Id,
|
||||
ScheduledStart = workOrder.ScheduledStart,
|
||||
ScheduledEnd = workOrder.ScheduledEnd
|
||||
});
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.Dispatches.Should().ContainSingle();
|
||||
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
|
||||
}
|
||||
}
|
||||
164
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
164
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class VendorPortalDocumentTests : IDisposable
|
||||
{
|
||||
private const string Token = "vendor-document-test-token";
|
||||
private readonly string _contentRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"seahaven-vendor-documents-{Guid.NewGuid():N}");
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private VendorPortalController NewController(ApplicationDbContext context)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["VendorPortal:TokenLifetimeDays"] = "365",
|
||||
["SendGrid:ApiKey"] = "not-used"
|
||||
})
|
||||
.Build();
|
||||
var environment = new Mock<IWebHostEnvironment>();
|
||||
environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
|
||||
var controller = new VendorPortalController(
|
||||
context,
|
||||
new VendorPortalTokenService(context, configuration),
|
||||
configuration,
|
||||
new SendMessage(configuration),
|
||||
environment.Object);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
};
|
||||
controller.Request.Headers["X-Vendor-Token"] = Token;
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch(
|
||||
ApplicationDbContext context)
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Completed"
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.VendorAccessTokens.Add(new VendorAccessToken
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
Token = Token,
|
||||
IssuedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(1)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return (vendor, dispatch);
|
||||
}
|
||||
|
||||
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
|
||||
{
|
||||
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = contentType
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf"));
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var controller = NewController(context);
|
||||
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
|
||||
|
||||
var upload = await controller.UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(pdf, "completion.pdf", "application/pdf"));
|
||||
|
||||
upload.Should().BeOfType<OkObjectResult>();
|
||||
var document = await context.VendorCompletionDocuments.SingleAsync();
|
||||
document.ScanStatus.Should().Be("Pending");
|
||||
document.ReviewStatus.Should().Be("Processing");
|
||||
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
|
||||
|
||||
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
|
||||
|
||||
var locked = download.Should().BeOfType<ObjectResult>().Subject;
|
||||
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(pdf, "completion.pdf", "Application/PDF"));
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 };
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(png, "photo.png", "Image/PNG"));
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_contentRoot))
|
||||
{
|
||||
Directory.Delete(_contentRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
481
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
481
Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorServiceTests
|
||||
{
|
||||
private static VendorService NewService(Mock<IVendorDataService> data) =>
|
||||
new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation());
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_PersistsCompleteProfile()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; });
|
||||
|
||||
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||
{
|
||||
Name = "Gateway Plumbing",
|
||||
ContactName = "Jordan Lee",
|
||||
Email = "dispatch@gateway.example",
|
||||
Phone = "(555) 555-0100",
|
||||
CompanyPhone = "(555) 555-0199",
|
||||
Address = "10 Lake St",
|
||||
City = "Chicago",
|
||||
State = "IL",
|
||||
Zipcode = "60601",
|
||||
TradeSpecialties = "Plumbing, Backflow",
|
||||
GoogleMapsUrl = "https://maps.google.com/example",
|
||||
Notes = "After-hours dispatch available",
|
||||
IsActive = true
|
||||
}, "42");
|
||||
|
||||
saved.Should().NotBeNull();
|
||||
saved!.ContactName.Should().Be("Jordan Lee");
|
||||
saved.TradeSpecialties.Should().Be("Plumbing, Backflow");
|
||||
saved.CompanyPhone.Should().Be("(555) 555-0199");
|
||||
saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example");
|
||||
saved.Notes.Should().Be("After-hours dispatch available");
|
||||
saved.City.Should().Be("Chicago");
|
||||
result.IsActive.Should().BeTrue();
|
||||
result.ContactName.Should().Be("Jordan Lee");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_UpdatesProfileAndActiveState()
|
||||
{
|
||||
var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO
|
||||
{
|
||||
Name = "New",
|
||||
ContactName = "Casey",
|
||||
TradeSpecialties = "HVAC",
|
||||
IsActive = false
|
||||
}, "42");
|
||||
|
||||
existing.CompanyName.Should().Be("New");
|
||||
existing.ContactName.Should().Be("Casey");
|
||||
existing.TradeSpecialties.Should().Be("HVAC");
|
||||
existing.IsActive.Should().BeFalse();
|
||||
existing.LastModifierUserId.Should().Be(42);
|
||||
result.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteVendor_DeactivatesWithoutDeletingHistory()
|
||||
{
|
||||
var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).DeleteVendorAsync(9, "42");
|
||||
|
||||
existing.IsActive.Should().BeFalse();
|
||||
existing.IsDeleted.Should().NotBeTrue();
|
||||
existing.DeletionTime.Should().BeNull();
|
||||
existing.LastModificationTime.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
"chicago",
|
||||
false,
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>(),
|
||||
It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.ReturnsAsync((new[]
|
||||
{
|
||||
new Vendor
|
||||
{
|
||||
Id = 10,
|
||||
CompanyName = "Inactive Vendor",
|
||||
ContactName = "Taylor",
|
||||
City = "Chicago",
|
||||
State = "IL",
|
||||
TradeSpecialties = "Electrical",
|
||||
IsActive = false
|
||||
}
|
||||
}.AsEnumerable(), 1));
|
||||
|
||||
var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false);
|
||||
|
||||
result.TotalCount.Should().Be(1);
|
||||
result.Items.Single().Should().BeEquivalentTo(new
|
||||
{
|
||||
Id = 10,
|
||||
Name = "Inactive Vendor",
|
||||
ContactName = "Taylor",
|
||||
City = "Chicago",
|
||||
State = "IL",
|
||||
TradeSpecialties = "Electrical",
|
||||
IsActive = false
|
||||
}, options => options.ExcludingMissingMembers());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
|
||||
{
|
||||
var vendor = new Vendor
|
||||
{
|
||||
Id = 11,
|
||||
CompanyName = "History Vendor",
|
||||
Dispatches = new List<Dispatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 100,
|
||||
DispatchWorkOrders = new List<DispatchWorkOrder>
|
||||
{
|
||||
new() { WorkOrderId = 100 },
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
},
|
||||
new() { WorkOrderId = 101 }
|
||||
}
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor);
|
||||
|
||||
var result = await NewService(data).GetVendorByIdAsync(11);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.TotalJobs.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVendorsPaged_ForwardsFacetFilters()
|
||||
{
|
||||
var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" };
|
||||
var trades = new[] { "Backflow Preventers" };
|
||||
var locations = new[] { "Dallas, TX" };
|
||||
var jobBuckets = new[] { "100-149" };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetPagedAsync(
|
||||
1,
|
||||
25,
|
||||
"gateway",
|
||||
true,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets))
|
||||
.ReturnsAsync((Enumerable.Empty<Vendor>(), 0));
|
||||
|
||||
await NewService(data).GetVendorsPagedAsync(
|
||||
1,
|
||||
25,
|
||||
"gateway",
|
||||
true,
|
||||
companies,
|
||||
trades,
|
||||
locations,
|
||||
jobBuckets);
|
||||
|
||||
data.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
|
||||
{
|
||||
var validator = new CreateVendorValidation();
|
||||
|
||||
var result = validator.Validate(new CreateVendorDTO
|
||||
{
|
||||
Name = "Gateway Plumbing",
|
||||
ContactName = "",
|
||||
GoogleMapsUrl = "http://maps.google.com/gateway"
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
|
||||
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
|
||||
{
|
||||
var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetLinkedWorkOrdersAsync(20))
|
||||
.ReturnsAsync(new List<LinkedWorkOrderInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
WorkOrderId = 500,
|
||||
WorkOrderNumber = "WO-500",
|
||||
Status = "Scheduled",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
}
|
||||
});
|
||||
|
||||
var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42");
|
||||
|
||||
await act.Should().ThrowAsync<VendorDeactivationBlockedException>();
|
||||
existing.IsActive.Should().BeTrue();
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName()
|
||||
{
|
||||
var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null);
|
||||
|
||||
var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO
|
||||
{
|
||||
CompanyId = 999,
|
||||
Name = "Fallback Try"
|
||||
}, "42");
|
||||
|
||||
await act.Should().ThrowAsync<ValidationException>();
|
||||
data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny<string>()), Times.Never);
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" };
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; });
|
||||
|
||||
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||
{
|
||||
Name = "Acme",
|
||||
CompanyId = 77,
|
||||
ContactName = "Riley"
|
||||
}, "42");
|
||||
|
||||
saved.Should().NotBeNull();
|
||||
saved!.CompanyId.Should().Be(77);
|
||||
result.CompanyId.Should().Be(77);
|
||||
data.Verify(x => x.AddCompanyAsync(It.IsAny<VendorCompany>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact()
|
||||
{
|
||||
var validator = new CreateVendorValidation();
|
||||
|
||||
var result = validator.Validate(new CreateVendorDTO
|
||||
{
|
||||
Name = "Acme",
|
||||
ContactName = "Riley",
|
||||
Phone = "555-1234",
|
||||
PreferredContact = "Fax"
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone));
|
||||
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
Vendor? saved = null;
|
||||
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
|
||||
.ReturnsAsync((Vendor vendor) =>
|
||||
{
|
||||
saved = vendor;
|
||||
vendor.Id = 41;
|
||||
return vendor;
|
||||
});
|
||||
|
||||
await NewService(data).CreateVendorAsync(new CreateVendorDTO
|
||||
{
|
||||
Name = "Acme",
|
||||
ContactName = "Riley",
|
||||
Phone = "+1 312 555 0100"
|
||||
}, "42");
|
||||
|
||||
saved!.Phone.Should().Be("(312) 555-0100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 42,
|
||||
CompanyId = 77,
|
||||
CompanyName = "Acme",
|
||||
Address = "Old address",
|
||||
IsActive = true
|
||||
};
|
||||
var company = new VendorCompany
|
||||
{
|
||||
Id = 77,
|
||||
Name = "Acme",
|
||||
NormalizedName = "acme",
|
||||
Address = "Old address"
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
|
||||
data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask);
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO
|
||||
{
|
||||
Name = "Acme Services",
|
||||
Address = "200 New Street"
|
||||
}, "42");
|
||||
|
||||
company.Name.Should().Be("Acme Services");
|
||||
company.NormalizedName.Should().Be("acme services");
|
||||
company.Address.Should().Be("200 New Street");
|
||||
existing.Address.Should().Be("200 New Street");
|
||||
data.Verify(x => x.UpdateCompanyAsync(company), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 43,
|
||||
CompanyId = 77,
|
||||
CompanyName = "Acme",
|
||||
Address = "Keep this address",
|
||||
CompanyPhone = "(312) 555-0199",
|
||||
IsActive = true
|
||||
};
|
||||
var sparseCompany = new VendorCompany
|
||||
{
|
||||
Id = 77,
|
||||
Name = "Acme",
|
||||
NormalizedName = "acme"
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany);
|
||||
data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask);
|
||||
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO
|
||||
{
|
||||
Name = "Acme"
|
||||
}, "42");
|
||||
|
||||
existing.Address.Should().Be("Keep this address");
|
||||
existing.CompanyPhone.Should().Be("(312) 555-0199");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact()
|
||||
{
|
||||
var validator = new WorkOrderVendorUpdateValidation();
|
||||
|
||||
var result = validator.Validate(new WorkOrderVendorUpdateDTO
|
||||
{
|
||||
WorkOrderId = 5,
|
||||
Phone = "(312) 555-0100",
|
||||
PreferredContact = "Text"
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder()
|
||||
{
|
||||
var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true };
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false);
|
||||
|
||||
var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
|
||||
{
|
||||
WorkOrderId = 777,
|
||||
ContactName = "Should Not Apply"
|
||||
}, "42");
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
existing.ContactName.Should().Be("Original");
|
||||
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues()
|
||||
{
|
||||
var existing = new Vendor
|
||||
{
|
||||
Id = 50,
|
||||
CompanyName = "Assigned",
|
||||
ContactName = "Old Name",
|
||||
Phone = "(312) 555-0000",
|
||||
PreferredContact = "Phone",
|
||||
IsActive = true
|
||||
};
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
|
||||
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true);
|
||||
var auditLogs = new List<VendorAuditLog>();
|
||||
data.Setup(x => x.UpdateWithAuditLogsAsync(
|
||||
existing,
|
||||
It.IsAny<IReadOnlyCollection<VendorAuditLog>>()))
|
||||
.Callback<Vendor, IReadOnlyCollection<VendorAuditLog>>((_, logs) => auditLogs.AddRange(logs))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
|
||||
{
|
||||
WorkOrderId = 777,
|
||||
ContactName = "New Name",
|
||||
Phone = "(312) 555-0199",
|
||||
PreferredContact = "Email"
|
||||
}, "42");
|
||||
|
||||
existing.ContactName.Should().Be("New Name");
|
||||
existing.Phone.Should().Be("(312) 555-0199");
|
||||
existing.PreferredContact.Should().Be("Email");
|
||||
auditLogs.Should().NotBeEmpty();
|
||||
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name");
|
||||
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199");
|
||||
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDeactivationImpact_RejectsUnknownVendor()
|
||||
{
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false);
|
||||
|
||||
var act = () => NewService(data).GetDeactivationImpactAsync(404);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*404*not found");
|
||||
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/vendor-operations")]
|
||||
public class VendorOperationsController : ControllerBase
|
||||
{
|
||||
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IDispatchDataService _dispatchDataService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VendorOperationsController(
|
||||
ApplicationDbContext db,
|
||||
IDispatchDataService dispatchDataService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_db = db;
|
||||
_dispatchDataService = dispatchDataService;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
[HttpGet("notifications")]
|
||||
public async Task<IActionResult> GetNotifications()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var cutoff = now.AddHours(48);
|
||||
|
||||
var directlyAssignedIds = await _db.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
|
||||
.Select(d => d.WorkOrderId!.Value)
|
||||
.ToListAsync();
|
||||
var multiplyAssignedIds = await _db.DispatchWorkOrders
|
||||
.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||
.Select(link => link.WorkOrderId)
|
||||
.ToListAsync();
|
||||
var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
|
||||
|
||||
var noVendor = await _db.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(wo => wo.ScheduledDate.HasValue
|
||||
&& wo.ScheduledDate.Value <= cutoff
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& !assignedWorkOrderIds.Contains(wo.Id))
|
||||
.OrderBy(wo => wo.ScheduledDate)
|
||||
.Select(wo => new
|
||||
{
|
||||
Id = $"no-vendor-{wo.Id}",
|
||||
Type = "NoVendor",
|
||||
Severity = "Medium",
|
||||
Group = "No Vendor",
|
||||
Title = "No vendor assigned — 48h to service",
|
||||
WorkOrderId = wo.Id,
|
||||
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
|
||||
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
|
||||
ScheduledAt = wo.ScheduledDate,
|
||||
Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var conflicts = BuildConflictPairs(dispatches)
|
||||
.Select(pair => new
|
||||
{
|
||||
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
||||
Type = "VendorConflict",
|
||||
Severity = "Medium",
|
||||
Group = "Vendor Conflict",
|
||||
Title = "Vendor has overlapping appointments",
|
||||
VendorId = pair.Left.VendorId,
|
||||
VendorName = pair.Left.VendorName,
|
||||
WorkOrders = new[]
|
||||
{
|
||||
new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End },
|
||||
new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End }
|
||||
},
|
||||
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
||||
Actions = new[]
|
||||
{
|
||||
new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
|
||||
new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = now,
|
||||
Items = noVendor.Cast<object>().Concat(conflicts).OrderBy(item =>
|
||||
item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("availability")]
|
||||
public async Task<IActionResult> GetAvailability(
|
||||
[FromQuery] DateTime? start,
|
||||
[FromQuery] DateTime? end,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var requestedStart = start ?? now;
|
||||
var requestedEnd = end ?? requestedStart.AddHours(2);
|
||||
if (requestedEnd <= requestedStart)
|
||||
return BadRequest(new Response { Status = "Error", Message = "end must be after start" });
|
||||
|
||||
var vendors = await _db.Vendors.AsNoTracking()
|
||||
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
|
||||
.ToListAsync();
|
||||
|
||||
var preferences = locationId.HasValue
|
||||
? await _db.SitePreferredVendors.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId.Value)
|
||||
.ToListAsync()
|
||||
: new List<SitePreferredVendor>();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var result = vendors.Select(vendor =>
|
||||
{
|
||||
var matchingDispatches = dispatches
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id
|
||||
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
|
||||
.ToList();
|
||||
var matchingPreference = preferences
|
||||
.Where(preference => preference.VendorId == vendor.Id
|
||||
&& (string.IsNullOrWhiteSpace(preference.Trade)
|
||||
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(preference => preference.SortOrder)
|
||||
.FirstOrDefault();
|
||||
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
|
||||
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
|
||||
var status = matchingDispatches.Count > 0
|
||||
? "Unavailable"
|
||||
: signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus)
|
||||
? vendor.AvailabilityStatus
|
||||
: "Unknown";
|
||||
|
||||
return new
|
||||
{
|
||||
vendor.Id,
|
||||
vendor.CompanyName,
|
||||
vendor.ContactName,
|
||||
vendor.TradeSpecialties,
|
||||
AvailabilityStatus = status,
|
||||
vendor.AvailabilityUpdatedAt,
|
||||
IsPreferred = matchingPreference != null,
|
||||
PreferenceRank = matchingPreference?.SortOrder,
|
||||
Conflicts = matchingDispatches.Select(dispatch => new
|
||||
{
|
||||
dispatch.WorkOrderId,
|
||||
dispatch.WorkOrderNumber,
|
||||
dispatch.Start,
|
||||
dispatch.End
|
||||
})
|
||||
};
|
||||
})
|
||||
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
|
||||
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
|
||||
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
|
||||
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
|
||||
.ThenBy(vendor => vendor.CompanyName)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result });
|
||||
}
|
||||
|
||||
[HttpPut("vendors/{vendorId:int}/availability")]
|
||||
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body)
|
||||
{
|
||||
var normalized = body.Status?.Trim();
|
||||
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" });
|
||||
|
||||
var vendor = await _db.Vendors.FindAsync(vendorId);
|
||||
if (vendor == null) return NotFound();
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var previous = vendor.AvailabilityStatus;
|
||||
vendor.AvailabilityStatus = normalized;
|
||||
vendor.AvailabilityUpdatedAt = now;
|
||||
vendor.LastModificationTime = now;
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = nameof(Vendor.AvailabilityStatus),
|
||||
OldValue = previous,
|
||||
NewValue = normalized,
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt });
|
||||
}
|
||||
|
||||
[HttpGet("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> GetSitePreferences(int locationId)
|
||||
{
|
||||
var preferences = await _db.SitePreferredVendors
|
||||
.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId)
|
||||
.Include(p => p.Vendor)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.LocationId,
|
||||
p.VendorId,
|
||||
VendorName = p.Vendor!.CompanyName,
|
||||
p.Trade,
|
||||
p.SortOrder,
|
||||
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
|
||||
p.LastModificationTime,
|
||||
p.UpdatedBy
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(preferences);
|
||||
}
|
||||
|
||||
[HttpPut("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body)
|
||||
{
|
||||
if (!await _db.Locations.AnyAsync(location => location.Id == locationId))
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" });
|
||||
|
||||
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
|
||||
var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id);
|
||||
foreach (var item in body)
|
||||
{
|
||||
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" });
|
||||
if (!string.IsNullOrWhiteSpace(item.Trade)
|
||||
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" });
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync();
|
||||
_db.SitePreferredVendors.RemoveRange(existing);
|
||||
_db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor
|
||||
{
|
||||
LocationId = locationId,
|
||||
VendorId = item.VendorId,
|
||||
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
|
||||
SortOrder = item.SortOrder ?? index,
|
||||
CreatedDate = now,
|
||||
LastModificationTime = now,
|
||||
createdby = actor,
|
||||
UpdatedBy = actor
|
||||
}));
|
||||
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
|
||||
{
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = $"SitePreference:{locationId}",
|
||||
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetSitePreferences(locationId);
|
||||
}
|
||||
|
||||
[HttpPut("documents/{documentId:int}/review")]
|
||||
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body)
|
||||
{
|
||||
if (body.ReviewStatus is not ("Approved" or "Rejected"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" });
|
||||
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
|
||||
return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" });
|
||||
|
||||
var document = await _db.VendorCompletionDocuments.FindAsync(documentId);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" });
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previous = document.ReviewStatus;
|
||||
document.ReviewStatus = body.ReviewStatus;
|
||||
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
|
||||
document.ReviewedAt = now;
|
||||
document.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Completion Document {document.OriginalFileName}",
|
||||
OldValue = previous,
|
||||
NewValue = document.ReviewStatus,
|
||||
Action = "completion_document_reviewed",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt });
|
||||
}
|
||||
|
||||
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
|
||||
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body)
|
||||
{
|
||||
var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" };
|
||||
var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
|
||||
if (!invoiceStatuses.Contains(body.InvoiceStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" });
|
||||
if (!paymentStatuses.Contains(body.PaymentStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" });
|
||||
|
||||
var dispatch = await _db.Dispatches.FindAsync(dispatchId);
|
||||
if (dispatch == null) return NotFound();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
|
||||
dispatch.InvoiceStatus = body.InvoiceStatus;
|
||||
dispatch.PaymentStatus = body.PaymentStatus;
|
||||
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
|
||||
dispatch.CommercialStatusUpdatedAt = now;
|
||||
dispatch.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
|
||||
OldValue = oldValue,
|
||||
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
|
||||
Action = "commercial_status_updated",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("work-orders/{workOrderId:int}/assignment")]
|
||||
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body)
|
||||
{
|
||||
var workOrder = await _db.workOrders.FindAsync(workOrderId);
|
||||
if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" });
|
||||
var vendor = await _db.Vendors.FindAsync(body.VendorId);
|
||||
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" });
|
||||
|
||||
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
|
||||
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
|
||||
var scheduledDispatches = start.HasValue && end.HasValue
|
||||
? await ActiveScheduledDispatches()
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId)
|
||||
.ToListAsync()
|
||||
: new List<ScheduledDispatch>();
|
||||
var conflicts = start.HasValue && end.HasValue
|
||||
? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
|
||||
: new List<ScheduledDispatch>();
|
||||
|
||||
var linkedDispatchIds = _db.DispatchWorkOrders
|
||||
.Where(link => link.WorkOrderId == workOrderId)
|
||||
.Select(link => link.DispatchId);
|
||||
var dispatch = await _db.Dispatches
|
||||
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
|
||||
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
|
||||
.OrderByDescending(item => item.CreatedDate)
|
||||
.FirstOrDefaultAsync();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previousVendorId = dispatch?.VendorId;
|
||||
if (dispatch == null)
|
||||
{
|
||||
dispatch = new Dispatch
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
VendorId = vendor.Id,
|
||||
DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(),
|
||||
PONumber = await _dispatchDataService.GenerateNextPONumberAsync(),
|
||||
Status = "Sent",
|
||||
ScheduledDate = start,
|
||||
EstimatedArrivalAt = body.EstimatedArrivalAt,
|
||||
EtaManualOverride = body.EtaManualOverride,
|
||||
DispatchedAt = now,
|
||||
CreatedDate = now,
|
||||
createdby = Actor()
|
||||
};
|
||||
_db.Dispatches.Add(dispatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
dispatch.VendorId = vendor.Id;
|
||||
dispatch.ScheduledDate = start;
|
||||
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
|
||||
dispatch.EtaManualOverride = body.EtaManualOverride;
|
||||
dispatch.LastModificationTime = now;
|
||||
}
|
||||
|
||||
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
|
||||
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
|
||||
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
|
||||
workOrder.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = "Vendor Assignment",
|
||||
OldValue = previousVendorId?.ToString(),
|
||||
NewValue = vendor.Id.ToString(),
|
||||
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.VendorId,
|
||||
VendorName = vendor.CompanyName,
|
||||
dispatch.EstimatedArrivalAt,
|
||||
dispatch.EtaManualOverride,
|
||||
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
|
||||
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
|
||||
Conflicts = conflicts.Select(conflict => new
|
||||
{
|
||||
conflict.WorkOrderId,
|
||||
conflict.WorkOrderNumber,
|
||||
conflict.Start,
|
||||
conflict.End
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights")]
|
||||
public async Task<IActionResult> GetInsights(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime,
|
||||
Filters = new { from, to, locationId, trade, vendorId },
|
||||
MetricDefinitions = MetricDefinitions(),
|
||||
Data = rows
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights.csv")]
|
||||
public async Task<IActionResult> ExportInsightsCsv(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
|
||||
foreach (var row in rows)
|
||||
{
|
||||
csv.AppendLine(string.Join(",",
|
||||
row.VendorId,
|
||||
Csv(row.Vendor),
|
||||
row.CompletedJobs,
|
||||
row.CancellationRefusalRate.ToString("0.####"),
|
||||
row.OnTimeArrivalRate.ToString("0.####"),
|
||||
row.OnTimeCompletionRate.ToString("0.####"),
|
||||
row.AverageCycleHours?.ToString("0.##") ?? ""));
|
||||
}
|
||||
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
|
||||
[HttpGet("insights.pdf")]
|
||||
public async Task<IActionResult> ExportInsightsPdf(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var lines = new List<string>
|
||||
{
|
||||
"Vendor Performance Insights",
|
||||
$"Generated: {_timeProvider.GetUtcNow():u}",
|
||||
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
|
||||
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
|
||||
};
|
||||
lines.AddRange(rows.Take(35).Select(row =>
|
||||
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
|
||||
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf");
|
||||
}
|
||||
|
||||
private IQueryable<ScheduledDispatch> ActiveScheduledDispatches()
|
||||
{
|
||||
var direct = _db.Dispatches.AsNoTracking()
|
||||
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
|
||||
&& dispatch.WorkOrderId.HasValue
|
||||
&& (dispatch.ScheduledDate.HasValue
|
||||
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
|
||||
.Select(dispatch => new ScheduledDispatch
|
||||
{
|
||||
Id = dispatch.Id,
|
||||
VendorId = dispatch.VendorId,
|
||||
VendorName = dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
|
||||
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
|
||||
End = dispatch.WorkOrder.ScheduledEnd
|
||||
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
var linked = _db.DispatchWorkOrders.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& link.WorkOrder != null
|
||||
&& link.Dispatch.WorkOrderId != link.WorkOrderId
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
|
||||
.Select(link => new ScheduledDispatch
|
||||
{
|
||||
Id = link.DispatchId,
|
||||
VendorId = link.Dispatch!.VendorId,
|
||||
VendorName = link.Dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = link.WorkOrderId,
|
||||
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
|
||||
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
|
||||
End = link.WorkOrder.ScheduledEnd
|
||||
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
return direct.Concat(linked);
|
||||
}
|
||||
|
||||
private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List<ScheduledDispatch> dispatches)
|
||||
{
|
||||
for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++)
|
||||
{
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++)
|
||||
{
|
||||
var left = dispatches[leftIndex];
|
||||
var right = dispatches[rightIndex];
|
||||
if (left.VendorId == right.VendorId
|
||||
&& left.WorkOrderId != right.WorkOrderId
|
||||
&& WindowsOverlap(left.Start, left.End, right.Start, right.End))
|
||||
yield return (left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<VendorInsightRow>> BuildInsightRows(
|
||||
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId)
|
||||
{
|
||||
var query = _db.Dispatches.AsNoTracking()
|
||||
.Include(dispatch => dispatch.Vendor)
|
||||
.Include(dispatch => dispatch.WorkOrder)
|
||||
.Where(dispatch => dispatch.Vendor != null);
|
||||
if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from);
|
||||
if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to);
|
||||
if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId);
|
||||
if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade);
|
||||
if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId);
|
||||
|
||||
var dispatches = await query.ToListAsync();
|
||||
return dispatches.GroupBy(dispatch => dispatch.VendorId)
|
||||
.Select(group =>
|
||||
{
|
||||
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
|
||||
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
|
||||
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
|
||||
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList();
|
||||
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
|
||||
return new VendorInsightRow
|
||||
{
|
||||
VendorId = group.Key,
|
||||
Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}",
|
||||
CompletedJobs = completed.Count,
|
||||
CancellationRefusalRate = Rate(canceled, group.Count()),
|
||||
OnTimeArrivalRate = Rate(
|
||||
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
|
||||
acknowledgedWithSchedule.Count),
|
||||
OnTimeCompletionRate = Rate(
|
||||
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate),
|
||||
completedWithDue.Count),
|
||||
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
|
||||
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
|
||||
};
|
||||
})
|
||||
.OrderBy(row => row.Vendor)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
|
||||
=> leftStart < rightEnd && rightStart < leftEnd;
|
||||
|
||||
private static decimal Rate(int numerator, int denominator)
|
||||
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
|
||||
? $"'{value}"
|
||||
: value;
|
||||
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
private static object MetricDefinitions() => new
|
||||
{
|
||||
CompletedJobs = "Dispatches with Completed or Verified status.",
|
||||
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
|
||||
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
|
||||
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
|
||||
AverageCycleHours = "Average elapsed hours from dispatch to completion."
|
||||
};
|
||||
|
||||
private static byte[] SimplePdf(IEnumerable<string> lines)
|
||||
{
|
||||
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
|
||||
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
|
||||
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
|
||||
+ " ET";
|
||||
var objects = new[]
|
||||
{
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
||||
};
|
||||
var builder = new StringBuilder("%PDF-1.4\n");
|
||||
var offsets = new List<int> { 0 };
|
||||
for (var index = 0; index < objects.Length; index++)
|
||||
{
|
||||
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
|
||||
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
|
||||
}
|
||||
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
|
||||
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
|
||||
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
|
||||
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
|
||||
return Encoding.ASCII.GetBytes(builder.ToString());
|
||||
}
|
||||
|
||||
public sealed class AvailabilityRequest
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SitePreferenceRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DocumentReviewRequest
|
||||
{
|
||||
public string ReviewStatus { get; set; } = "";
|
||||
public string? RejectionReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CommercialStatusRequest
|
||||
{
|
||||
public string InvoiceStatus { get; set; } = "Not Submitted";
|
||||
public string PaymentStatus { get; set; } = "Unavailable";
|
||||
public string? InvoiceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VendorAssignmentRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
public DateTime? ScheduledEnd { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ScheduledDispatch
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int VendorId { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public DateTime Start { get; set; }
|
||||
public DateTime End { get; set; }
|
||||
}
|
||||
|
||||
private sealed class VendorInsightRow
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string Vendor { get; set; } = "";
|
||||
public int CompletedJobs { get; set; }
|
||||
public decimal CancellationRefusalRate { get; set; }
|
||||
public decimal OnTimeArrivalRate { get; set; }
|
||||
public decimal OnTimeCompletionRate { get; set; }
|
||||
public double? AverageCycleHours { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly VendorPortalTokenService _tokens;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly SendMessage _sendMessage;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
|
||||
public VendorPortalController(
|
||||
ApplicationDbContext db,
|
||||
VendorPortalTokenService tokens,
|
||||
IConfiguration config,
|
||||
SendMessage sendMessage,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_db = db;
|
||||
_tokens = tokens;
|
||||
_config = config;
|
||||
_sendMessage = sendMessage;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpGet("session")]
|
||||
|
|
@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Id,
|
||||
d.DispatchNumber,
|
||||
d.PONumber,
|
||||
d.InvoiceNumber,
|
||||
d.InvoiceStatus,
|
||||
d.PaymentStatus,
|
||||
d.Status,
|
||||
d.NTEAmount,
|
||||
d.ScheduledDate,
|
||||
|
|
@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var documents = await _db.VendorCompletionDocuments
|
||||
.Where(document => document.DispatchId == id && document.VendorId == vendor.Id)
|
||||
.OrderByDescending(document => document.CreatedDate)
|
||||
.Select(document => new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.ContentType,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.RejectionReason,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId,
|
||||
document.CreatedDate,
|
||||
document.ScannedAt,
|
||||
document.ReviewedAt,
|
||||
CanDownload = document.ScanStatus == "Passed"
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rawUplifts = await (from u in _db.DispatchUpliftRequests
|
||||
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
|
|
@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
dispatch.Id,
|
||||
dispatch.DispatchNumber,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt,
|
||||
dispatch.Status,
|
||||
dispatch.NTEAmount,
|
||||
dispatch.Description,
|
||||
|
|
@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
},
|
||||
Checklist = checklist,
|
||||
Signoffs = signoffs,
|
||||
Documents = documents,
|
||||
Comments = comments,
|
||||
UpliftRequests = upliftRequests
|
||||
}
|
||||
|
|
@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/documents")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadCompletionDocument(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] int? replacesDocumentId = null)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" });
|
||||
if (file.Length > 10 * 1024 * 1024)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" });
|
||||
|
||||
var allowed = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["application/pdf"] = new[] { ".pdf" },
|
||||
["image/jpeg"] = new[] { ".jpg", ".jpeg" },
|
||||
["image/png"] = new[] { ".png" }
|
||||
};
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
if (!allowed.TryGetValue(file.ContentType, out var extensions)
|
||||
|| !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" });
|
||||
|
||||
await using var signatureStream = file.OpenReadStream();
|
||||
if (!await HasExpectedSignature(signatureStream, file.ContentType))
|
||||
return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" });
|
||||
|
||||
var workOrderId = dispatch.WorkOrderId
|
||||
?? await _db.DispatchWorkOrders
|
||||
.Where(link => link.DispatchId == dispatch.Id)
|
||||
.OrderBy(link => link.Id)
|
||||
.Select(link => link.WorkOrderId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (workOrderId == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" });
|
||||
|
||||
VendorCompletionDocument? replaced = null;
|
||||
if (replacesDocumentId.HasValue)
|
||||
{
|
||||
replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document =>
|
||||
document.Id == replacesDocumentId.Value
|
||||
&& document.DispatchId == id
|
||||
&& document.VendorId == vendor.Id);
|
||||
if (replaced == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var document = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = id,
|
||||
WorkOrderId = workOrderId,
|
||||
OriginalFileName = Path.GetFileName(file.FileName),
|
||||
StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}",
|
||||
ContentType = file.ContentType,
|
||||
SizeBytes = file.Length,
|
||||
ScanStatus = "Pending",
|
||||
ReviewStatus = "Processing",
|
||||
Version = (replaced?.Version ?? 0) + 1,
|
||||
ReplacesDocumentId = replaced?.Id,
|
||||
CreatedDate = now,
|
||||
createdby = $"vendor:{vendor.Id}"
|
||||
};
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using (var target = System.IO.File.Create(path))
|
||||
{
|
||||
await file.CopyToAsync(target);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_db.VendorCompletionDocuments.Add(document);
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
FieldName = "Completion Document",
|
||||
OldValue = replaced?.OriginalFileName,
|
||||
NewValue = document.OriginalFileName,
|
||||
Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
|
||||
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item =>
|
||||
item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return StatusCode(StatusCodes.Status423Locked,
|
||||
new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" });
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
if (!System.IO.File.Exists(path)) return NotFound();
|
||||
return PhysicalFile(path, document.ContentType, document.OriginalFileName);
|
||||
}
|
||||
|
||||
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
|
||||
{
|
||||
try
|
||||
|
|
@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> HasExpectedSignature(Stream stream, string contentType)
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
var count = await stream.ReadAsync(bytes);
|
||||
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D;
|
||||
if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<Vendor?> ResolveVendorAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public List<int> VendorIds { get; set; } = new();
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? TaskListTemplateId { get; set; }
|
||||
public List<string>? CustomChecklistItems { get; set; }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public string? Status { get; set; }
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,34 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public class Vendor_DTO
|
||||
{
|
||||
public string? CompanyName { get; set; }
|
||||
public int? CompanyId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class EditVendor_DTO : Vendor_DTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderVendorUpdate_DTO
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? PreferredContact { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public enum DocumentScanResult
|
||||
{
|
||||
Passed,
|
||||
Infected,
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public interface IDocumentScanner
|
||||
{
|
||||
Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class ClamAvDocumentScanner : IDocumentScanner
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public ClamAvDocumentScanner(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var host = _configuration["ClamAV:Host"];
|
||||
var port = _configuration.GetValue<int?>("ClamAV:Port") ?? 3310;
|
||||
if (string.IsNullOrWhiteSpace(host) || !File.Exists(path))
|
||||
return DocumentScanResult.Unavailable;
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellationToken);
|
||||
await using var network = client.GetStream();
|
||||
await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken);
|
||||
await using var file = File.OpenRead(path);
|
||||
var buffer = new byte[64 * 1024];
|
||||
while (true)
|
||||
{
|
||||
var count = await file.ReadAsync(buffer, cancellationToken);
|
||||
if (count == 0) break;
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, count);
|
||||
await network.WriteAsync(length, cancellationToken);
|
||||
await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
|
||||
}
|
||||
await network.WriteAsync(new byte[4], cancellationToken);
|
||||
await network.FlushAsync(cancellationToken);
|
||||
|
||||
var responseBuffer = new byte[1024];
|
||||
var responseLength = await network.ReadAsync(responseBuffer, cancellationToken);
|
||||
var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength);
|
||||
if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
return DocumentScanResult.Infected;
|
||||
return response.Contains("OK", StringComparison.OrdinalIgnoreCase)
|
||||
? DocumentScanResult.Passed
|
||||
: DocumentScanResult.Unavailable;
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return DocumentScanResult.Unavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public class VendorDocumentScanWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<VendorDocumentScanWorker> _logger;
|
||||
|
||||
public VendorDocumentScanWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<VendorDocumentScanWorker> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await ScanPendingAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
|
||||
var pending = await db.VendorCompletionDocuments
|
||||
.Where(document => document.ScanStatus == "Pending")
|
||||
.OrderBy(document => document.CreatedDate)
|
||||
.Take(20)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var document in pending)
|
||||
{
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
var result = await scanner.ScanAsync(path, cancellationToken);
|
||||
if (result == DocumentScanResult.Unavailable)
|
||||
{
|
||||
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
|
||||
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
|
||||
document.RejectionReason = result == DocumentScanResult.Infected
|
||||
? "The upload failed malware scanning."
|
||||
: document.RejectionReason;
|
||||
document.ScannedAt = DateTime.UtcNow;
|
||||
document.LastModificationTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (result == DocumentScanResult.Infected && File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class VendorDocumentStorage
|
||||
{
|
||||
public static string Root(string contentRoot) =>
|
||||
Path.Combine(contentRoot, "App_Data", "vendor-documents");
|
||||
|
||||
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
|
||||
Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
|
|||
|
||||
builder.Services.AddScoped<SendMessage>();
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
|
||||
builder.Services.AddHostedService<VendorDocumentScanWorker>();
|
||||
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
||||
{
|
||||
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
// Production must supply a reachable ClamAV Host (environment variable ClamAV__Host).
|
||||
// While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked).
|
||||
"ClamAV": {
|
||||
"Host": "",
|
||||
"Port": 3310
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JWT": {
|
||||
"ValidAudience": "http://console.seahavenind.com",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@
|
|||
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
// Malware scanner for vendor completion document uploads. While Host is empty the
|
||||
// scanner is considered unavailable and uploaded files stay quarantined (download
|
||||
// returns 423 Locked until a scan passes). Production must supply a real Host.
|
||||
"ClamAV": {
|
||||
"Host": "",
|
||||
"Port": 3310
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JWT": {
|
||||
"ValidAudience": "http://localhost:4200",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,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; }
|
||||
|
|
@ -95,7 +115,11 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
|
||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
|
||||
public DbSet<VendorCompletionDocument> VendorCompletionDocuments { get; set; }
|
||||
public DbSet<SitePreferredVendor> SitePreferredVendors { get; set; }
|
||||
public DbSet<Dispatch> Dispatches { get; set; }
|
||||
public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; }
|
||||
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260720220000_AddVendorDirectoryDetails")]
|
||||
public partial class AddVendorDirectoryDetails : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CompanyPhone",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GoogleMapsUrl",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Notes",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors");
|
||||
migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors");
|
||||
migrationBuilder.DropColumn(name: "Notes", table: "Vendors");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
using System;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260723194013_AddVendorCompanyAndAudit")]
|
||||
public partial class AddVendorCompanyAndAudit : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CompanyId",
|
||||
table: "Vendors",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PreferredContact",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCompanies",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
CompanyPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Address = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
City = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Zip = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
GoogleMapsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCompanies", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorAuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: true),
|
||||
FieldName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
OldValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
NewValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Actor = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorAuditLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorAuditLogs_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
// Backfill: create one VendorCompany per distinct (case-insensitive, trimmed) legacy name.
|
||||
// Keep all company-owned fields from the same deterministic source vendor row.
|
||||
migrationBuilder.Sql(@"
|
||||
WITH RankedVendors AS (
|
||||
SELECT
|
||||
v.[CompanyName] AS [Name],
|
||||
LOWER(LTRIM(RTRIM(v.[CompanyName]))) AS [NormalizedName],
|
||||
v.[CompanyPhone],
|
||||
v.[Email],
|
||||
v.[Address],
|
||||
v.[City],
|
||||
v.[State],
|
||||
v.[Zip],
|
||||
v.[GoogleMapsUrl],
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY LOWER(LTRIM(RTRIM(v.[CompanyName])))
|
||||
ORDER BY v.[Id]
|
||||
) AS [RowNumber]
|
||||
FROM [Vendors] v
|
||||
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> ''
|
||||
)
|
||||
INSERT INTO [VendorCompanies] (
|
||||
[Name], [NormalizedName], [CompanyPhone], [Email], [Address], [City], [State], [Zip], [GoogleMapsUrl], [CreatedDate]
|
||||
)
|
||||
SELECT
|
||||
ranked.[Name],
|
||||
ranked.[NormalizedName],
|
||||
ranked.[CompanyPhone],
|
||||
ranked.[Email],
|
||||
ranked.[Address],
|
||||
ranked.[City],
|
||||
ranked.[State],
|
||||
ranked.[Zip],
|
||||
ranked.[GoogleMapsUrl],
|
||||
GETUTCDATE() AS [CreatedDate]
|
||||
FROM RankedVendors ranked
|
||||
WHERE ranked.[RowNumber] = 1;
|
||||
|
||||
UPDATE v
|
||||
SET v.[CompanyId] = vc.[Id]
|
||||
FROM [Vendors] v
|
||||
INNER JOIN [VendorCompanies] vc
|
||||
ON vc.[NormalizedName] = LOWER(LTRIM(RTRIM(v.[CompanyName])))
|
||||
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> '';
|
||||
");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Vendors_CompanyId",
|
||||
table: "Vendors",
|
||||
column: "CompanyId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorAuditLogs_VendorId",
|
||||
table: "VendorAuditLogs",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompanies_NormalizedName",
|
||||
table: "VendorCompanies",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Vendors_VendorCompanies_CompanyId",
|
||||
table: "Vendors",
|
||||
column: "CompanyId",
|
||||
principalTable: "VendorCompanies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Vendors_VendorCompanies_CompanyId",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Vendors_CompanyId",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorAuditLogs_VendorId",
|
||||
table: "VendorAuditLogs");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCompanies_NormalizedName",
|
||||
table: "VendorCompanies");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorAuditLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCompanies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CompanyId",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PreferredContact",
|
||||
table: "Vendors");
|
||||
}
|
||||
}
|
||||
}
|
||||
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,230 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorRoadmapOperations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unknown");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Not Submitted");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unavailable");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SitePreferredVendors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LocationId = table.Column<int>(type: "int", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SitePreferredVendors", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Locations_LocationId",
|
||||
column: x => x.LocationId,
|
||||
principalTable: "Locations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCompletionDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
DispatchId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Version = table.Column<int>(type: "int", nullable: false),
|
||||
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
|
||||
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Dispatches_DispatchId",
|
||||
column: x => x.DispatchId,
|
||||
principalTable: "Dispatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId",
|
||||
column: x => x.WorkOrderId,
|
||||
principalTable: "workOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId_Trade",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId", "Trade" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_DispatchId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "DispatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_VendorId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_WorkOrderId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "WorkOrderId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SitePreferredVendors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCompletionDocuments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts", (string)null);
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
|
||||
|
|
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("Addresses", (string)null);
|
||||
b.ToTable("Addresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
|
|
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Assets", (string)null);
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
|
||||
|
|
@ -371,7 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Categories", (string)null);
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
|
||||
|
|
@ -438,7 +438,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkerOrderId");
|
||||
|
||||
b.ToTable("Comments", (string)null);
|
||||
b.ToTable("Comments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
|
||||
|
|
@ -504,7 +504,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("ContactDetails", (string)null);
|
||||
b.ToTable("ContactDetails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
|
||||
|
|
@ -596,7 +596,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Contacts", (string)null);
|
||||
b.ToTable("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
|
||||
|
|
@ -614,7 +614,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Departments", (string)null);
|
||||
b.ToTable("Departments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
|
||||
|
|
@ -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)");
|
||||
|
||||
|
|
@ -705,7 +725,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("Dispatches", (string)null);
|
||||
b.ToTable("Dispatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
|
||||
|
|
@ -764,7 +784,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchChecklistItems", (string)null);
|
||||
b.ToTable("DispatchChecklistItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
|
||||
|
|
@ -818,7 +838,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.ToTable("DispatchSignoffs", (string)null);
|
||||
b.ToTable("DispatchSignoffs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
|
||||
|
|
@ -888,7 +908,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
|
||||
b.ToTable("DispatchUpliftRequests", (string)null);
|
||||
b.ToTable("DispatchUpliftRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
|
|
@ -911,7 +931,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchWorkOrders", (string)null);
|
||||
b.ToTable("DispatchWorkOrders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
|
||||
|
|
@ -941,7 +961,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DropdownOptions", (string)null);
|
||||
b.ToTable("DropdownOptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
|
||||
|
|
@ -1046,7 +1066,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("RegionId");
|
||||
|
||||
b.ToTable("Employees", (string)null);
|
||||
b.ToTable("Employees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
|
||||
|
|
@ -1086,7 +1106,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("EmployeeId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("EmployeeAddresses", (string)null);
|
||||
b.ToTable("EmployeeAddresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
|
||||
|
|
@ -1114,7 +1134,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeeEmails", (string)null);
|
||||
b.ToTable("EmployeeEmails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
|
||||
|
|
@ -1144,7 +1164,87 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeePhones", (string)null);
|
||||
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 =>
|
||||
|
|
@ -1212,7 +1312,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("FollowUps", (string)null);
|
||||
b.ToTable("FollowUps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
|
||||
|
|
@ -1237,7 +1337,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ForgetPasswordCodes", (string)null);
|
||||
b.ToTable("ForgetPasswordCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
|
||||
|
|
@ -1255,7 +1355,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobTitles", (string)null);
|
||||
b.ToTable("JobTitles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||
|
|
@ -1325,7 +1425,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Locations", (string)null);
|
||||
b.ToTable("Locations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
|
||||
|
|
@ -1383,7 +1483,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("PMSchedules", (string)null);
|
||||
b.ToTable("PMSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
|
||||
|
|
@ -1457,7 +1557,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("Quotes", (string)null);
|
||||
b.ToTable("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
|
||||
|
|
@ -1505,7 +1605,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("QuoteId");
|
||||
|
||||
b.ToTable("QuotesLines", (string)null);
|
||||
b.ToTable("QuotesLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
|
||||
|
|
@ -1523,7 +1623,66 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Regions", (string)null);
|
||||
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 =>
|
||||
|
|
@ -1566,7 +1725,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaskListTemplates", (string)null);
|
||||
b.ToTable("TaskListTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
|
|
@ -1611,7 +1770,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("TaskListTemplateId");
|
||||
|
||||
b.ToTable("TaskListTemplateItems", (string)null);
|
||||
b.ToTable("TaskListTemplateItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||
|
|
@ -1713,7 +1872,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Templates", (string)null);
|
||||
b.ToTable("Templates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
|
|
@ -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,7 +1958,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Vendors", (string)null);
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("Vendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
|
||||
|
|
@ -1836,7 +2019,196 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAccessTokens", (string)null);
|
||||
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 =>
|
||||
|
|
@ -2094,7 +2466,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderAttachments", (string)null);
|
||||
b.ToTable("workOrderAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
|
||||
|
|
@ -2144,7 +2516,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("WorkOrderAuditLogs", (string)null);
|
||||
b.ToTable("WorkOrderAuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
|
||||
|
|
@ -2188,7 +2560,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderCategories", (string)null);
|
||||
b.ToTable("workOrderCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
|
||||
|
|
@ -2235,7 +2607,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("WorkOrderContacts", (string)null);
|
||||
b.ToTable("WorkOrderContacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
|
|
@ -2264,7 +2636,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "FieldName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderFieldLocks", (string)null);
|
||||
b.ToTable("WorkOrderFieldLocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
|
|
@ -2293,7 +2665,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
b.ToTable("WorkOrderWeekRolledLedgers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
|
|
@ -2723,6 +3095,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")
|
||||
|
|
@ -2765,6 +3156,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")
|
||||
|
|
@ -2776,6 +3177,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")
|
||||
|
|
@ -2977,6 +3416,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.Navigation("Comments");
|
||||
|
||||
b.Navigation("CompletionDocuments");
|
||||
|
||||
b.Navigation("DispatchWorkOrders");
|
||||
|
||||
b.Navigation("Signoffs");
|
||||
|
|
@ -3020,7 +3461,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.Navigation("AuditLogs");
|
||||
|
||||
b.Navigation("Dispatches");
|
||||
|
||||
b.Navigation("SitePreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||
{
|
||||
b.Navigation("Vendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Description { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public string InvoiceStatus { get; set; } = "Not Submitted";
|
||||
public string PaymentStatus { get; set; } = "Unavailable";
|
||||
public string? InvoiceNumber { get; set; }
|
||||
public DateTime? CommercialStatusUpdatedAt { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs
Normal file
15
Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class LinkedWorkOrderInfo
|
||||
{
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public string? WorkOrderTitle { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public LifecycleStatus? LifecycleStatus { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public int? DispatchId { get; set; }
|
||||
}
|
||||
}
|
||||
19
Data.SeaHavenIndustries/Models/SitePreferredVendor.cs
Normal file
19
Data.SeaHavenIndustries/Models/SitePreferredVendor.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class SitePreferredVendor : FullAuditEntity
|
||||
{
|
||||
public int LocationId { get; set; }
|
||||
[ForeignKey(nameof(LocationId))]
|
||||
public Locations? Location { get; set; }
|
||||
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public string? Trade { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class Vendor : FullAuditEntity
|
||||
|
|
@ -6,12 +8,27 @@ namespace Data.SeaHavenIndustries
|
|||
public string? ContactName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? PreferredContact { get; set; }
|
||||
public string AvailabilityStatus { get; set; } = "Unknown";
|
||||
public DateTime? AvailabilityUpdatedAt { get; set; }
|
||||
|
||||
public int? CompanyId { get; set; }
|
||||
[ForeignKey(nameof(CompanyId))]
|
||||
public virtual VendorCompany? Company { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public int TotalJobs { get; set; }
|
||||
public ICollection<Dispatch>? Dispatches { get; set; }
|
||||
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
|
||||
public ICollection<SitePreferredVendor>? SitePreferences { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
Data.SeaHavenIndustries/Models/VendorAuditLog.cs
Normal file
18
Data.SeaHavenIndustries/Models/VendorAuditLog.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorAuditLog
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public virtual Vendor? Vendor { get; set; }
|
||||
public int? WorkOrderId { get; set; }
|
||||
public string? FieldName { get; set; }
|
||||
public string? OldValue { get; set; }
|
||||
public string? NewValue { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
19
Data.SeaHavenIndustries/Models/VendorCompany.cs
Normal file
19
Data.SeaHavenIndustries/Models/VendorCompany.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorCompany : FullAuditEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string NormalizedName { get; set; } = string.Empty;
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Zip { get; set; }
|
||||
public string? GoogleMapsUrl { get; set; }
|
||||
|
||||
public ICollection<Vendor>? Vendors { get; set; }
|
||||
}
|
||||
}
|
||||
31
Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs
Normal file
31
Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorCompletionDocument : FullAuditEntity
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public int DispatchId { get; set; }
|
||||
[ForeignKey(nameof(DispatchId))]
|
||||
public Dispatch? Dispatch { get; set; }
|
||||
|
||||
public int WorkOrderId { get; set; }
|
||||
[ForeignKey(nameof(WorkOrderId))]
|
||||
public WorkOrder? WorkOrder { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = "";
|
||||
public string StoredFileName { get; set; } = "";
|
||||
public string ContentType { get; set; } = "";
|
||||
public long SizeBytes { get; set; }
|
||||
public string ScanStatus { get; set; } = "Pending";
|
||||
public string ReviewStatus { get; set; } = "Processing";
|
||||
public string? RejectionReason { get; set; }
|
||||
public int Version { get; set; } = 1;
|
||||
public int? ReplacesDocumentId { get; set; }
|
||||
public DateTime? ScannedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ namespace Data.SeaHavenIndustries
|
|||
public string? LegacyStatus { get; set; }
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? Service { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
3
SeaHaven.DataServices/Properties/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")]
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs
Normal file
25
SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class VendorPhoneNormalizer
|
||||
{
|
||||
public static readonly Regex NorthAmericanPhoneRegex = new(
|
||||
@"^\(\d{3}\) \d{3}-\d{4}$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public static string? NormalizeToCanonical(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var digits = new string(value.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length == 11 && digits.StartsWith('1'))
|
||||
digits = digits[1..];
|
||||
|
||||
return digits.Length == 10
|
||||
? $"({digits[..3]}) {digits[3..6]}-{digits[6..]}"
|
||||
: value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
159
docs/work-orders/phase-1/README.md
Normal file
159
docs/work-orders/phase-1/README.md
Normal 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: < 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).
|
||||
Loading…
Add table
Reference in a new issue