mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
49 commits
4b4ab04615
...
0e87817416
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e87817416 | ||
|
|
5fa9b4e344 | ||
|
|
917bad3ee1 | ||
|
|
a9dee0bb84 | ||
|
|
8192da7790 | ||
|
|
df828cf48c | ||
|
|
3bd0268129 | ||
|
|
782d0e8ae9 | ||
|
|
1ede0a5ece | ||
|
|
af724ff625 | ||
|
|
b1421de0b5 | ||
|
|
8134c0b77f | ||
|
|
93ef4f578b | ||
|
|
7459dca3c6 | ||
|
|
bdd566de83 | ||
|
|
31675e49dd | ||
|
|
edcdc10395 | ||
|
|
54cd24acf4 | ||
|
|
11fed06f03 | ||
|
|
39829f7274 | ||
|
|
4863d1fdaf | ||
|
|
385812c5c3 | ||
|
|
1ff29c234e | ||
|
|
75969a1e3d | ||
|
|
61b4c84337 | ||
|
|
f3d2a5cfe7 | ||
|
|
76db2b5a37 | ||
|
|
4bd11dcf6b | ||
|
|
ceeb0c8fa3 | ||
|
|
3122cbc8f7 | ||
|
|
bdfaf1ff61 | ||
|
|
cc8825ace0 | ||
|
|
9dc17d4255 | ||
|
|
88f9245fbf | ||
|
|
25eb6293e0 | ||
|
|
c2d0bd45e3 | ||
|
|
88caede86a | ||
|
|
e5bda0714a | ||
|
|
91122d753d | ||
|
|
673bc3b5a9 | ||
|
|
d040832b87 | ||
|
|
8efe4460bb | ||
|
|
ff53cb9fa6 | ||
|
|
4f697f257a | ||
|
|
65c1cdc7e9 | ||
|
|
5d5b3234c4 | ||
|
|
f52c430c2c | ||
|
|
35b3bba596 | ||
|
|
456bee2226 |
95 changed files with 21290 additions and 228 deletions
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs
Normal file
101
Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderControllerSearchTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static UserManager<ApplicationUser> MockUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
|
||||
}
|
||||
|
||||
private static WorkOrderController NewController(
|
||||
ApplicationDbContext ctx,
|
||||
IWorkOrderAdvancedSearchService advancedSearch)
|
||||
{
|
||||
var controller = new WorkOrderController(
|
||||
MockUserManager(),
|
||||
Mock.Of<IWorkOrderService>(),
|
||||
Mock.Of<IWorkOrderDataService>(),
|
||||
Mock.Of<ICommentDataService>(),
|
||||
Mock.Of<IQuotesDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<IDispatchDataService>(),
|
||||
Mock.Of<IVendorDataService>(),
|
||||
Mock.Of<ITaskListTemplateDataService>(),
|
||||
ctx,
|
||||
Mock.Of<IWebHostEnvironment>(),
|
||||
Mock.Of<IHttpContextAccessor>(),
|
||||
null!,
|
||||
null!,
|
||||
Mock.Of<IConfiguration>(),
|
||||
Mock.Of<IWorkOrderBoardService>(),
|
||||
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||
advancedSearch);
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new System.Security.Claims.ClaimsPrincipal(
|
||||
new System.Security.Claims.ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new System.Security.Claims.Claim(
|
||||
System.Security.Claims.ClaimTypes.NameIdentifier,
|
||||
"test-user")
|
||||
},
|
||||
"Test"))
|
||||
}
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var advancedSearch = new WorkOrderAdvancedSearchService(
|
||||
Mock.Of<IWorkOrderAdvancedSearchDataService>());
|
||||
var controller = NewController(ctx, advancedSearch);
|
||||
|
||||
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom
|
||||
});
|
||||
|
||||
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
|
||||
body.Status.Should().Be("Error");
|
||||
body.Message.Should().Contain("dateFrom");
|
||||
}
|
||||
}
|
||||
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/vendor-operations")]
|
||||
public class VendorOperationsController : ControllerBase
|
||||
{
|
||||
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IDispatchDataService _dispatchDataService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VendorOperationsController(
|
||||
ApplicationDbContext db,
|
||||
IDispatchDataService dispatchDataService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_db = db;
|
||||
_dispatchDataService = dispatchDataService;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
[HttpGet("notifications")]
|
||||
public async Task<IActionResult> GetNotifications()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var cutoff = now.AddHours(48);
|
||||
|
||||
var directlyAssignedIds = await _db.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
|
||||
.Select(d => d.WorkOrderId!.Value)
|
||||
.ToListAsync();
|
||||
var multiplyAssignedIds = await _db.DispatchWorkOrders
|
||||
.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||
.Select(link => link.WorkOrderId)
|
||||
.ToListAsync();
|
||||
var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
|
||||
|
||||
var noVendor = await _db.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(wo => wo.ScheduledDate.HasValue
|
||||
&& wo.ScheduledDate.Value <= cutoff
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& !assignedWorkOrderIds.Contains(wo.Id))
|
||||
.OrderBy(wo => wo.ScheduledDate)
|
||||
.Select(wo => new
|
||||
{
|
||||
Id = $"no-vendor-{wo.Id}",
|
||||
Type = "NoVendor",
|
||||
Severity = "Medium",
|
||||
Group = "No Vendor",
|
||||
Title = "No vendor assigned — 48h to service",
|
||||
WorkOrderId = wo.Id,
|
||||
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
|
||||
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
|
||||
ScheduledAt = wo.ScheduledDate,
|
||||
Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var conflicts = BuildConflictPairs(dispatches)
|
||||
.Select(pair => new
|
||||
{
|
||||
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
||||
Type = "VendorConflict",
|
||||
Severity = "Medium",
|
||||
Group = "Vendor Conflict",
|
||||
Title = "Vendor has overlapping appointments",
|
||||
VendorId = pair.Left.VendorId,
|
||||
VendorName = pair.Left.VendorName,
|
||||
WorkOrders = new[]
|
||||
{
|
||||
new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End },
|
||||
new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End }
|
||||
},
|
||||
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
||||
Actions = new[]
|
||||
{
|
||||
new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
|
||||
new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = now,
|
||||
Items = noVendor.Cast<object>().Concat(conflicts).OrderBy(item =>
|
||||
item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("availability")]
|
||||
public async Task<IActionResult> GetAvailability(
|
||||
[FromQuery] DateTime? start,
|
||||
[FromQuery] DateTime? end,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var requestedStart = start ?? now;
|
||||
var requestedEnd = end ?? requestedStart.AddHours(2);
|
||||
if (requestedEnd <= requestedStart)
|
||||
return BadRequest(new Response { Status = "Error", Message = "end must be after start" });
|
||||
|
||||
var vendors = await _db.Vendors.AsNoTracking()
|
||||
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
|
||||
.ToListAsync();
|
||||
|
||||
var preferences = locationId.HasValue
|
||||
? await _db.SitePreferredVendors.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId.Value)
|
||||
.ToListAsync()
|
||||
: new List<SitePreferredVendor>();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var result = vendors.Select(vendor =>
|
||||
{
|
||||
var matchingDispatches = dispatches
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id
|
||||
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
|
||||
.ToList();
|
||||
var matchingPreference = preferences
|
||||
.Where(preference => preference.VendorId == vendor.Id
|
||||
&& (string.IsNullOrWhiteSpace(preference.Trade)
|
||||
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(preference => preference.SortOrder)
|
||||
.FirstOrDefault();
|
||||
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
|
||||
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
|
||||
var status = matchingDispatches.Count > 0
|
||||
? "Unavailable"
|
||||
: signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus)
|
||||
? vendor.AvailabilityStatus
|
||||
: "Unknown";
|
||||
|
||||
return new
|
||||
{
|
||||
vendor.Id,
|
||||
vendor.CompanyName,
|
||||
vendor.ContactName,
|
||||
vendor.TradeSpecialties,
|
||||
AvailabilityStatus = status,
|
||||
vendor.AvailabilityUpdatedAt,
|
||||
IsPreferred = matchingPreference != null,
|
||||
PreferenceRank = matchingPreference?.SortOrder,
|
||||
Conflicts = matchingDispatches.Select(dispatch => new
|
||||
{
|
||||
dispatch.WorkOrderId,
|
||||
dispatch.WorkOrderNumber,
|
||||
dispatch.Start,
|
||||
dispatch.End
|
||||
})
|
||||
};
|
||||
})
|
||||
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
|
||||
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
|
||||
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
|
||||
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
|
||||
.ThenBy(vendor => vendor.CompanyName)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result });
|
||||
}
|
||||
|
||||
[HttpPut("vendors/{vendorId:int}/availability")]
|
||||
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body)
|
||||
{
|
||||
var normalized = body.Status?.Trim();
|
||||
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" });
|
||||
|
||||
var vendor = await _db.Vendors.FindAsync(vendorId);
|
||||
if (vendor == null) return NotFound();
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var previous = vendor.AvailabilityStatus;
|
||||
vendor.AvailabilityStatus = normalized;
|
||||
vendor.AvailabilityUpdatedAt = now;
|
||||
vendor.LastModificationTime = now;
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = nameof(Vendor.AvailabilityStatus),
|
||||
OldValue = previous,
|
||||
NewValue = normalized,
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt });
|
||||
}
|
||||
|
||||
[HttpGet("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> GetSitePreferences(int locationId)
|
||||
{
|
||||
var preferences = await _db.SitePreferredVendors
|
||||
.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId)
|
||||
.Include(p => p.Vendor)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.LocationId,
|
||||
p.VendorId,
|
||||
VendorName = p.Vendor!.CompanyName,
|
||||
p.Trade,
|
||||
p.SortOrder,
|
||||
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
|
||||
p.LastModificationTime,
|
||||
p.UpdatedBy
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(preferences);
|
||||
}
|
||||
|
||||
[HttpPut("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body)
|
||||
{
|
||||
if (!await _db.Locations.AnyAsync(location => location.Id == locationId))
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" });
|
||||
|
||||
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
|
||||
var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id);
|
||||
foreach (var item in body)
|
||||
{
|
||||
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" });
|
||||
if (!string.IsNullOrWhiteSpace(item.Trade)
|
||||
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" });
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync();
|
||||
_db.SitePreferredVendors.RemoveRange(existing);
|
||||
_db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor
|
||||
{
|
||||
LocationId = locationId,
|
||||
VendorId = item.VendorId,
|
||||
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
|
||||
SortOrder = item.SortOrder ?? index,
|
||||
CreatedDate = now,
|
||||
LastModificationTime = now,
|
||||
createdby = actor,
|
||||
UpdatedBy = actor
|
||||
}));
|
||||
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
|
||||
{
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = $"SitePreference:{locationId}",
|
||||
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetSitePreferences(locationId);
|
||||
}
|
||||
|
||||
[HttpPut("documents/{documentId:int}/review")]
|
||||
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body)
|
||||
{
|
||||
if (body.ReviewStatus is not ("Approved" or "Rejected"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" });
|
||||
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
|
||||
return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" });
|
||||
|
||||
var document = await _db.VendorCompletionDocuments.FindAsync(documentId);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" });
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previous = document.ReviewStatus;
|
||||
document.ReviewStatus = body.ReviewStatus;
|
||||
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
|
||||
document.ReviewedAt = now;
|
||||
document.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Completion Document {document.OriginalFileName}",
|
||||
OldValue = previous,
|
||||
NewValue = document.ReviewStatus,
|
||||
Action = "completion_document_reviewed",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt });
|
||||
}
|
||||
|
||||
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
|
||||
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body)
|
||||
{
|
||||
var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" };
|
||||
var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
|
||||
if (!invoiceStatuses.Contains(body.InvoiceStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" });
|
||||
if (!paymentStatuses.Contains(body.PaymentStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" });
|
||||
|
||||
var dispatch = await _db.Dispatches.FindAsync(dispatchId);
|
||||
if (dispatch == null) return NotFound();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
|
||||
dispatch.InvoiceStatus = body.InvoiceStatus;
|
||||
dispatch.PaymentStatus = body.PaymentStatus;
|
||||
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
|
||||
dispatch.CommercialStatusUpdatedAt = now;
|
||||
dispatch.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
|
||||
OldValue = oldValue,
|
||||
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
|
||||
Action = "commercial_status_updated",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("work-orders/{workOrderId:int}/assignment")]
|
||||
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body)
|
||||
{
|
||||
var workOrder = await _db.workOrders.FindAsync(workOrderId);
|
||||
if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" });
|
||||
var vendor = await _db.Vendors.FindAsync(body.VendorId);
|
||||
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" });
|
||||
|
||||
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
|
||||
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
|
||||
var scheduledDispatches = start.HasValue && end.HasValue
|
||||
? await ActiveScheduledDispatches()
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId)
|
||||
.ToListAsync()
|
||||
: new List<ScheduledDispatch>();
|
||||
var conflicts = start.HasValue && end.HasValue
|
||||
? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
|
||||
: new List<ScheduledDispatch>();
|
||||
|
||||
var linkedDispatchIds = _db.DispatchWorkOrders
|
||||
.Where(link => link.WorkOrderId == workOrderId)
|
||||
.Select(link => link.DispatchId);
|
||||
var dispatch = await _db.Dispatches
|
||||
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
|
||||
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
|
||||
.OrderByDescending(item => item.CreatedDate)
|
||||
.FirstOrDefaultAsync();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previousVendorId = dispatch?.VendorId;
|
||||
if (dispatch == null)
|
||||
{
|
||||
dispatch = new Dispatch
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
VendorId = vendor.Id,
|
||||
DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(),
|
||||
PONumber = await _dispatchDataService.GenerateNextPONumberAsync(),
|
||||
Status = "Sent",
|
||||
ScheduledDate = start,
|
||||
EstimatedArrivalAt = body.EstimatedArrivalAt,
|
||||
EtaManualOverride = body.EtaManualOverride,
|
||||
DispatchedAt = now,
|
||||
CreatedDate = now,
|
||||
createdby = Actor()
|
||||
};
|
||||
_db.Dispatches.Add(dispatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
dispatch.VendorId = vendor.Id;
|
||||
dispatch.ScheduledDate = start;
|
||||
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
|
||||
dispatch.EtaManualOverride = body.EtaManualOverride;
|
||||
dispatch.LastModificationTime = now;
|
||||
}
|
||||
|
||||
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
|
||||
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
|
||||
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
|
||||
workOrder.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = "Vendor Assignment",
|
||||
OldValue = previousVendorId?.ToString(),
|
||||
NewValue = vendor.Id.ToString(),
|
||||
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.VendorId,
|
||||
VendorName = vendor.CompanyName,
|
||||
dispatch.EstimatedArrivalAt,
|
||||
dispatch.EtaManualOverride,
|
||||
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
|
||||
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
|
||||
Conflicts = conflicts.Select(conflict => new
|
||||
{
|
||||
conflict.WorkOrderId,
|
||||
conflict.WorkOrderNumber,
|
||||
conflict.Start,
|
||||
conflict.End
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights")]
|
||||
public async Task<IActionResult> GetInsights(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime,
|
||||
Filters = new { from, to, locationId, trade, vendorId },
|
||||
MetricDefinitions = MetricDefinitions(),
|
||||
Data = rows
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights.csv")]
|
||||
public async Task<IActionResult> ExportInsightsCsv(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
|
||||
foreach (var row in rows)
|
||||
{
|
||||
csv.AppendLine(string.Join(",",
|
||||
row.VendorId,
|
||||
Csv(row.Vendor),
|
||||
row.CompletedJobs,
|
||||
row.CancellationRefusalRate.ToString("0.####"),
|
||||
row.OnTimeArrivalRate.ToString("0.####"),
|
||||
row.OnTimeCompletionRate.ToString("0.####"),
|
||||
row.AverageCycleHours?.ToString("0.##") ?? ""));
|
||||
}
|
||||
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
|
||||
[HttpGet("insights.pdf")]
|
||||
public async Task<IActionResult> ExportInsightsPdf(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var lines = new List<string>
|
||||
{
|
||||
"Vendor Performance Insights",
|
||||
$"Generated: {_timeProvider.GetUtcNow():u}",
|
||||
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
|
||||
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
|
||||
};
|
||||
lines.AddRange(rows.Take(35).Select(row =>
|
||||
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
|
||||
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf");
|
||||
}
|
||||
|
||||
private IQueryable<ScheduledDispatch> ActiveScheduledDispatches()
|
||||
{
|
||||
var direct = _db.Dispatches.AsNoTracking()
|
||||
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
|
||||
&& dispatch.WorkOrderId.HasValue
|
||||
&& (dispatch.ScheduledDate.HasValue
|
||||
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
|
||||
.Select(dispatch => new ScheduledDispatch
|
||||
{
|
||||
Id = dispatch.Id,
|
||||
VendorId = dispatch.VendorId,
|
||||
VendorName = dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
|
||||
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
|
||||
End = dispatch.WorkOrder.ScheduledEnd
|
||||
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
var linked = _db.DispatchWorkOrders.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& link.WorkOrder != null
|
||||
&& link.Dispatch.WorkOrderId != link.WorkOrderId
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
|
||||
.Select(link => new ScheduledDispatch
|
||||
{
|
||||
Id = link.DispatchId,
|
||||
VendorId = link.Dispatch!.VendorId,
|
||||
VendorName = link.Dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = link.WorkOrderId,
|
||||
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
|
||||
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
|
||||
End = link.WorkOrder.ScheduledEnd
|
||||
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
return direct.Concat(linked);
|
||||
}
|
||||
|
||||
private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List<ScheduledDispatch> dispatches)
|
||||
{
|
||||
for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++)
|
||||
{
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++)
|
||||
{
|
||||
var left = dispatches[leftIndex];
|
||||
var right = dispatches[rightIndex];
|
||||
if (left.VendorId == right.VendorId
|
||||
&& left.WorkOrderId != right.WorkOrderId
|
||||
&& WindowsOverlap(left.Start, left.End, right.Start, right.End))
|
||||
yield return (left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<VendorInsightRow>> BuildInsightRows(
|
||||
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId)
|
||||
{
|
||||
var query = _db.Dispatches.AsNoTracking()
|
||||
.Include(dispatch => dispatch.Vendor)
|
||||
.Include(dispatch => dispatch.WorkOrder)
|
||||
.Where(dispatch => dispatch.Vendor != null);
|
||||
if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from);
|
||||
if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to);
|
||||
if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId);
|
||||
if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade);
|
||||
if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId);
|
||||
|
||||
var dispatches = await query.ToListAsync();
|
||||
return dispatches.GroupBy(dispatch => dispatch.VendorId)
|
||||
.Select(group =>
|
||||
{
|
||||
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
|
||||
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
|
||||
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
|
||||
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList();
|
||||
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
|
||||
return new VendorInsightRow
|
||||
{
|
||||
VendorId = group.Key,
|
||||
Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}",
|
||||
CompletedJobs = completed.Count,
|
||||
CancellationRefusalRate = Rate(canceled, group.Count()),
|
||||
OnTimeArrivalRate = Rate(
|
||||
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
|
||||
acknowledgedWithSchedule.Count),
|
||||
OnTimeCompletionRate = Rate(
|
||||
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate),
|
||||
completedWithDue.Count),
|
||||
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
|
||||
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
|
||||
};
|
||||
})
|
||||
.OrderBy(row => row.Vendor)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
|
||||
=> leftStart < rightEnd && rightStart < leftEnd;
|
||||
|
||||
private static decimal Rate(int numerator, int denominator)
|
||||
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
|
||||
? $"'{value}"
|
||||
: value;
|
||||
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
private static object MetricDefinitions() => new
|
||||
{
|
||||
CompletedJobs = "Dispatches with Completed or Verified status.",
|
||||
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
|
||||
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
|
||||
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
|
||||
AverageCycleHours = "Average elapsed hours from dispatch to completion."
|
||||
};
|
||||
|
||||
private static byte[] SimplePdf(IEnumerable<string> lines)
|
||||
{
|
||||
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
|
||||
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
|
||||
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
|
||||
+ " ET";
|
||||
var objects = new[]
|
||||
{
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
||||
};
|
||||
var builder = new StringBuilder("%PDF-1.4\n");
|
||||
var offsets = new List<int> { 0 };
|
||||
for (var index = 0; index < objects.Length; index++)
|
||||
{
|
||||
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
|
||||
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
|
||||
}
|
||||
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
|
||||
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
|
||||
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
|
||||
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
|
||||
return Encoding.ASCII.GetBytes(builder.ToString());
|
||||
}
|
||||
|
||||
public sealed class AvailabilityRequest
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SitePreferenceRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DocumentReviewRequest
|
||||
{
|
||||
public string ReviewStatus { get; set; } = "";
|
||||
public string? RejectionReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CommercialStatusRequest
|
||||
{
|
||||
public string InvoiceStatus { get; set; } = "Not Submitted";
|
||||
public string PaymentStatus { get; set; } = "Unavailable";
|
||||
public string? InvoiceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VendorAssignmentRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
public DateTime? ScheduledEnd { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ScheduledDispatch
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int VendorId { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public DateTime Start { get; set; }
|
||||
public DateTime End { get; set; }
|
||||
}
|
||||
|
||||
private sealed class VendorInsightRow
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string Vendor { get; set; } = "";
|
||||
public int CompletedJobs { get; set; }
|
||||
public decimal CancellationRefusalRate { get; set; }
|
||||
public decimal OnTimeArrivalRate { get; set; }
|
||||
public decimal OnTimeCompletionRate { get; set; }
|
||||
public double? AverageCycleHours { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly VendorPortalTokenService _tokens;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly SendMessage _sendMessage;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
|
||||
public VendorPortalController(
|
||||
ApplicationDbContext db,
|
||||
VendorPortalTokenService tokens,
|
||||
IConfiguration config,
|
||||
SendMessage sendMessage,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_db = db;
|
||||
_tokens = tokens;
|
||||
_config = config;
|
||||
_sendMessage = sendMessage;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpGet("session")]
|
||||
|
|
@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Id,
|
||||
d.DispatchNumber,
|
||||
d.PONumber,
|
||||
d.InvoiceNumber,
|
||||
d.InvoiceStatus,
|
||||
d.PaymentStatus,
|
||||
d.Status,
|
||||
d.NTEAmount,
|
||||
d.ScheduledDate,
|
||||
|
|
@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var documents = await _db.VendorCompletionDocuments
|
||||
.Where(document => document.DispatchId == id && document.VendorId == vendor.Id)
|
||||
.OrderByDescending(document => document.CreatedDate)
|
||||
.Select(document => new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.ContentType,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.RejectionReason,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId,
|
||||
document.CreatedDate,
|
||||
document.ScannedAt,
|
||||
document.ReviewedAt,
|
||||
CanDownload = document.ScanStatus == "Passed"
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rawUplifts = await (from u in _db.DispatchUpliftRequests
|
||||
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
|
|
@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
dispatch.Id,
|
||||
dispatch.DispatchNumber,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt,
|
||||
dispatch.Status,
|
||||
dispatch.NTEAmount,
|
||||
dispatch.Description,
|
||||
|
|
@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
},
|
||||
Checklist = checklist,
|
||||
Signoffs = signoffs,
|
||||
Documents = documents,
|
||||
Comments = comments,
|
||||
UpliftRequests = upliftRequests
|
||||
}
|
||||
|
|
@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/documents")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadCompletionDocument(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] int? replacesDocumentId = null)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" });
|
||||
if (file.Length > 10 * 1024 * 1024)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" });
|
||||
|
||||
var allowed = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["application/pdf"] = new[] { ".pdf" },
|
||||
["image/jpeg"] = new[] { ".jpg", ".jpeg" },
|
||||
["image/png"] = new[] { ".png" }
|
||||
};
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
if (!allowed.TryGetValue(file.ContentType, out var extensions)
|
||||
|| !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" });
|
||||
|
||||
await using var signatureStream = file.OpenReadStream();
|
||||
if (!await HasExpectedSignature(signatureStream, file.ContentType))
|
||||
return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" });
|
||||
|
||||
var workOrderId = dispatch.WorkOrderId
|
||||
?? await _db.DispatchWorkOrders
|
||||
.Where(link => link.DispatchId == dispatch.Id)
|
||||
.OrderBy(link => link.Id)
|
||||
.Select(link => link.WorkOrderId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (workOrderId == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" });
|
||||
|
||||
VendorCompletionDocument? replaced = null;
|
||||
if (replacesDocumentId.HasValue)
|
||||
{
|
||||
replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document =>
|
||||
document.Id == replacesDocumentId.Value
|
||||
&& document.DispatchId == id
|
||||
&& document.VendorId == vendor.Id);
|
||||
if (replaced == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var document = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = id,
|
||||
WorkOrderId = workOrderId,
|
||||
OriginalFileName = Path.GetFileName(file.FileName),
|
||||
StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}",
|
||||
ContentType = file.ContentType,
|
||||
SizeBytes = file.Length,
|
||||
ScanStatus = "Pending",
|
||||
ReviewStatus = "Processing",
|
||||
Version = (replaced?.Version ?? 0) + 1,
|
||||
ReplacesDocumentId = replaced?.Id,
|
||||
CreatedDate = now,
|
||||
createdby = $"vendor:{vendor.Id}"
|
||||
};
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using (var target = System.IO.File.Create(path))
|
||||
{
|
||||
await file.CopyToAsync(target);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_db.VendorCompletionDocuments.Add(document);
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
FieldName = "Completion Document",
|
||||
OldValue = replaced?.OriginalFileName,
|
||||
NewValue = document.OriginalFileName,
|
||||
Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
|
||||
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item =>
|
||||
item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return StatusCode(StatusCodes.Status423Locked,
|
||||
new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" });
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
if (!System.IO.File.Exists(path)) return NotFound();
|
||||
return PhysicalFile(path, document.ContentType, document.OriginalFileName);
|
||||
}
|
||||
|
||||
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
|
||||
{
|
||||
try
|
||||
|
|
@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> HasExpectedSignature(Stream stream, string contentType)
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
var count = await stream.ReadAsync(bytes);
|
||||
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D;
|
||||
if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<Vendor?> ResolveVendorAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms;
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.VisualBasic;
|
||||
|
|
@ -17,6 +18,8 @@ using System.Security.Claims;
|
|||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -42,6 +45,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly Helper.SendMessage _sendMessage;
|
||||
private readonly Helper.VendorPortalTokenService _vendorTokens;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IWorkOrderBoardService _workOrderBoardService;
|
||||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
||||
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
||||
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
|
||||
|
||||
public WorkOrderController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
|
|
@ -58,7 +66,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
IHttpContextAccessor httpContext,
|
||||
Helper.SendMessage sendMessage,
|
||||
Helper.VendorPortalTokenService vendorTokens,
|
||||
IConfiguration config)
|
||||
IConfiguration config,
|
||||
IWorkOrderBoardService workOrderBoardService,
|
||||
IWorkOrderBoardUpdateService boardUpdateService,
|
||||
IWorkOrderBoardCreateService boardCreateService,
|
||||
IWorkOrderBoardCancelService boardCancelService,
|
||||
IWorkOrderAdvancedSearchService advancedSearchService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_workOrderService = workOrderService;
|
||||
|
|
@ -75,7 +88,186 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_sendMessage = sendMessage;
|
||||
_vendorTokens = vendorTokens;
|
||||
_config = config;
|
||||
_workOrderBoardService = workOrderBoardService;
|
||||
_boardUpdateService = boardUpdateService;
|
||||
_boardCreateService = boardCreateService;
|
||||
_boardCancelService = boardCancelService;
|
||||
_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,
|
||||
[FromQuery] DateOnly? weekEnd = null,
|
||||
[FromQuery] List<string>? dispatchers = null,
|
||||
[FromQuery] bool myWorkOrders = false,
|
||||
[FromQuery] List<WorkOrderType>? types = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4);
|
||||
var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd);
|
||||
if (weekValidationError != null)
|
||||
return BadRequest(weekValidationError);
|
||||
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var query = new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = weekStart,
|
||||
WeekEnd = weekEnd,
|
||||
Dispatchers = dispatchers,
|
||||
MyWorkOrders = myWorkOrders,
|
||||
Types = types,
|
||||
Search = search
|
||||
};
|
||||
|
||||
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("board/search")]
|
||||
public async Task<IActionResult> SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await _advancedSearchService.SearchAsync(query, userId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}/board")]
|
||||
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (WorkOrderBoardConcurrencyException ex)
|
||||
{
|
||||
return Conflict(new WorkOrderBoardConflictDto
|
||||
{
|
||||
CurrentState = ex.CurrentState
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("board")]
|
||||
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _boardCreateService.CreateAsync(request, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
{
|
||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber")
|
||||
{
|
||||
return Conflict(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/cancel")]
|
||||
public async Task<IActionResult> CancelBoardWorkOrder(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _boardCancelService.CancelAsync(id, actorId);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
Code = ex.Code,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("AddWorkorder")]
|
||||
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
|
||||
|
|
@ -295,6 +487,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete]
|
||||
[Route("DeleteWorkorder")]
|
||||
public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model)
|
||||
|
|
@ -494,6 +687,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Description,
|
||||
d.Status,
|
||||
d.ScheduledDate,
|
||||
d.EstimatedArrivalAt,
|
||||
d.EtaManualOverride,
|
||||
d.CompletedDate,
|
||||
d.DispatchedAt,
|
||||
d.EmailSent,
|
||||
|
|
@ -863,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++;
|
||||
|
|
@ -882,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()
|
||||
|
|
@ -961,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 });
|
||||
|
|
@ -987,6 +1200,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Description,
|
||||
d.Status,
|
||||
d.ScheduledDate,
|
||||
d.EstimatedArrivalAt,
|
||||
d.EtaManualOverride,
|
||||
d.CompletedDate,
|
||||
d.DispatchedAt,
|
||||
d.EmailSent,
|
||||
|
|
@ -1013,6 +1228,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
x.Description,
|
||||
x.Status,
|
||||
x.ScheduledDate,
|
||||
x.EstimatedArrivalAt,
|
||||
x.EtaManualOverride,
|
||||
x.CompletedDate,
|
||||
x.DispatchedAt,
|
||||
x.EmailSent,
|
||||
|
|
@ -1101,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
/// <summary>Ops-only endpoints for manual domain job reprocessing.</summary>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[ApiController]
|
||||
[Route("api/workorders/jobs")]
|
||||
public class WorkOrderJobsController : ControllerBase
|
||||
{
|
||||
private readonly IWorkOrderWeekRolledService _weekRolledService;
|
||||
private readonly IPastDueCacheService _pastDueCacheService;
|
||||
|
||||
public WorkOrderJobsController(
|
||||
IWorkOrderWeekRolledService weekRolledService,
|
||||
IPastDueCacheService pastDueCacheService)
|
||||
{
|
||||
_weekRolledService = weekRolledService;
|
||||
_pastDueCacheService = pastDueCacheService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reprocess WeekRolled for a specific operational week (Monday start).
|
||||
/// Idempotent via WorkOrderWeekRolledLedger.
|
||||
/// </summary>
|
||||
[HttpPost("week-rolled")]
|
||||
[ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WeekRolledJobResult>> RunWeekRolled(
|
||||
[FromQuery] DateOnly? sourceWeekStart,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var weekStart = sourceWeekStart
|
||||
?? WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
|
||||
|
||||
if (weekStart.DayOfWeek != DayOfWeek.Monday)
|
||||
return BadRequest("sourceWeekStart must be a Monday (operational week start).");
|
||||
|
||||
var result = await _weekRolledService.ProcessWeekRolledAsync(weekStart, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read).
|
||||
/// </summary>
|
||||
[HttpPost("past-due-cache")]
|
||||
[ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PastDueCacheJobResult>> RunPastDueCache(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _pastDueCacheService.RefreshAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal file
21
Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Filters
|
||||
{
|
||||
public class ArgumentExceptionFilter : IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
if (context.Exception is not ArgumentException ex) return;
|
||||
|
||||
context.Result = new BadRequestObjectResult(new
|
||||
{
|
||||
status = "Error",
|
||||
message = ex.Message,
|
||||
code = 400
|
||||
});
|
||||
context.ExceptionHandled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public enum DocumentScanResult
|
||||
{
|
||||
Passed,
|
||||
Infected,
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public interface IDocumentScanner
|
||||
{
|
||||
Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class ClamAvDocumentScanner : IDocumentScanner
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public ClamAvDocumentScanner(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var host = _configuration["ClamAV:Host"];
|
||||
var port = _configuration.GetValue<int?>("ClamAV:Port") ?? 3310;
|
||||
if (string.IsNullOrWhiteSpace(host) || !File.Exists(path))
|
||||
return DocumentScanResult.Unavailable;
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellationToken);
|
||||
await using var network = client.GetStream();
|
||||
await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken);
|
||||
await using var file = File.OpenRead(path);
|
||||
var buffer = new byte[64 * 1024];
|
||||
while (true)
|
||||
{
|
||||
var count = await file.ReadAsync(buffer, cancellationToken);
|
||||
if (count == 0) break;
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, count);
|
||||
await network.WriteAsync(length, cancellationToken);
|
||||
await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
|
||||
}
|
||||
await network.WriteAsync(new byte[4], cancellationToken);
|
||||
await network.FlushAsync(cancellationToken);
|
||||
|
||||
var responseBuffer = new byte[1024];
|
||||
var responseLength = await network.ReadAsync(responseBuffer, cancellationToken);
|
||||
var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength);
|
||||
if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
return DocumentScanResult.Infected;
|
||||
return response.Contains("OK", StringComparison.OrdinalIgnoreCase)
|
||||
? DocumentScanResult.Passed
|
||||
: DocumentScanResult.Unavailable;
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return DocumentScanResult.Unavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public class VendorDocumentScanWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<VendorDocumentScanWorker> _logger;
|
||||
|
||||
public VendorDocumentScanWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<VendorDocumentScanWorker> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await ScanPendingAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
|
||||
var pending = await db.VendorCompletionDocuments
|
||||
.Where(document => document.ScanStatus == "Pending")
|
||||
.OrderBy(document => document.CreatedDate)
|
||||
.Take(20)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var document in pending)
|
||||
{
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
var result = await scanner.ScanAsync(path, cancellationToken);
|
||||
if (result == DocumentScanResult.Unavailable)
|
||||
{
|
||||
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
|
||||
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
|
||||
document.RejectionReason = result == DocumentScanResult.Infected
|
||||
? "The upload failed malware scanning."
|
||||
: document.RejectionReason;
|
||||
document.ScannedAt = DateTime.UtcNow;
|
||||
document.LastModificationTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (result == DocumentScanResult.Infected && File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class VendorDocumentStorage
|
||||
{
|
||||
public static string Root(string contentRoot) =>
|
||||
Path.Combine(contentRoot, "App_Data", "vendor-documents");
|
||||
|
||||
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
|
||||
Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.HostedServices
|
||||
{
|
||||
public class PastDueCacheHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<PastDueCacheHostedService> _logger;
|
||||
private readonly PastDueCacheJobOptions _options;
|
||||
private readonly WorkOrderJobRunState _runState;
|
||||
private DateOnly? _lastRunDate;
|
||||
|
||||
public PastDueCacheHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<WorkOrderJobsOptions> options,
|
||||
WorkOrderJobRunState runState,
|
||||
ILogger<PastDueCacheHostedService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_options = options.Value.PastDueCache;
|
||||
_runState = runState;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_options.Enabled && ShouldRunNow())
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (_lastRunDate != today)
|
||||
{
|
||||
_lastRunDate = today;
|
||||
await RunJobAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<IPastDueCacheService>();
|
||||
await service.RefreshAsync(stoppingToken);
|
||||
|
||||
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
|
||||
_runState.LastPastDueCacheError = null;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_runState.LastPastDueCacheError = ex.Message;
|
||||
_logger.LogError(ex, "PastDue cache hosted job failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRunNow()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
||||
runAt = new TimeOnly(0, 10);
|
||||
|
||||
var current = TimeOnly.FromDateTime(now);
|
||||
return current >= runAt && current < runAt.AddMinutes(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.HostedServices
|
||||
{
|
||||
public class WorkOrderWeekRolledHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<WorkOrderWeekRolledHostedService> _logger;
|
||||
private readonly WeekRolledJobOptions _options;
|
||||
private readonly WorkOrderJobRunState _runState;
|
||||
private DateOnly? _lastRunDate;
|
||||
|
||||
public WorkOrderWeekRolledHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<WorkOrderJobsOptions> options,
|
||||
WorkOrderJobRunState runState,
|
||||
ILogger<WorkOrderWeekRolledHostedService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_options = options.Value.WeekRolled;
|
||||
_runState = runState;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_options.Enabled && ShouldRunNow())
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (_lastRunDate != today)
|
||||
{
|
||||
_lastRunDate = today;
|
||||
await RunJobAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderWeekRolledService>();
|
||||
await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken);
|
||||
|
||||
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
|
||||
_runState.LastWeekRolledError = null;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_runState.LastWeekRolledError = ex.Message;
|
||||
_logger.LogError(ex, "WeekRolled hosted job failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRunNow()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (!Enum.TryParse<DayOfWeek>(_options.DayOfWeek, ignoreCase: true, out var targetDay))
|
||||
targetDay = DayOfWeek.Monday;
|
||||
|
||||
if (now.DayOfWeek != targetDay)
|
||||
return false;
|
||||
|
||||
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
||||
runAt = new TimeOnly(0, 5);
|
||||
|
||||
var current = TimeOnly.FromDateTime(now);
|
||||
return current >= runAt && current < runAt.AddMinutes(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs
Normal file
11
Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
/// <summary>In-memory last-run timestamps for hosted domain jobs (ops health).</summary>
|
||||
public class WorkOrderJobRunState
|
||||
{
|
||||
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||
public string? LastWeekRolledError { get; set; }
|
||||
public string? LastPastDueCacheError { get; set; }
|
||||
}
|
||||
}
|
||||
23
Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs
Normal file
23
Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
public class WorkOrderJobsOptions
|
||||
{
|
||||
public const string SectionName = "WorkOrderJobs";
|
||||
|
||||
public WeekRolledJobOptions WeekRolled { get; set; } = new();
|
||||
public PastDueCacheJobOptions PastDueCache { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WeekRolledJobOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string RunAtUtc { get; set; } = "00:05";
|
||||
public string DayOfWeek { get; set; } = "Monday";
|
||||
}
|
||||
|
||||
public class PastDueCacheJobOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string RunAtUtc { get; set; } = "00:10";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
using Api.SeaHavenIndustries.Helper;
|
||||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
|
@ -25,6 +27,7 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
|||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
|
||||
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
|
||||
}).AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
|
||||
|
|
@ -39,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();
|
||||
|
|
@ -51,6 +57,12 @@ builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp
|
|||
builder.Services.AddDataServices();
|
||||
builder.Services.AddBusinessServices();
|
||||
|
||||
builder.Services.Configure<WorkOrderJobsOptions>(
|
||||
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
||||
builder.Services.AddSingleton<WorkOrderJobRunState>();
|
||||
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
|
||||
builder.Services.AddHostedService<PastDueCacheHostedService>();
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
|
|
|
|||
|
|
@ -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,11 +17,22 @@
|
|||
// 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",
|
||||
"ValidIssuer": "http://localhost:7195",
|
||||
// Provide the real value via environment variable JWT__Secret or user-secrets.
|
||||
"Secret": "${JWT_SECRET}"
|
||||
},
|
||||
"WorkOrderJobs": {
|
||||
"WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" },
|
||||
"PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" }
|
||||
}
|
||||
}
|
||||
|
|
@ -38,10 +38,36 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<WorkOrderWeekRolledLedger>()
|
||||
.HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
|
||||
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.SiteCode)
|
||||
.HasMaxLength(32);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.InternalWONumber)
|
||||
.HasMaxLength(11);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.SiteCode)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.InternalWONumber)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
builder.Entity<Dispatch>()
|
||||
.Property(d => d.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
|
@ -61,6 +87,16 @@ namespace Data.SeaHavenIndustries
|
|||
.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; }
|
||||
|
|
@ -76,11 +112,14 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
|
||||
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
|
||||
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
|
||||
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; }
|
||||
|
|
@ -106,6 +145,51 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<Department> Departments { get; set; }
|
||||
public DbSet<JobTitle> JobTitles { get; set; }
|
||||
public DbSet<Region> Regions { get; set; }
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
BumpRowVersionsForInMemoryProvider();
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
BumpRowVersionsForInMemoryProvider();
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void BumpRowVersionsForInMemoryProvider()
|
||||
{
|
||||
if (!Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? true)
|
||||
return;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
|
||||
{
|
||||
if (entry.State == EntityState.Modified)
|
||||
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||
}
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<Dispatch>())
|
||||
{
|
||||
if (entry.State == EntityState.Modified)
|
||||
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] IncrementRowVersion(byte[]? current)
|
||||
{
|
||||
var bytes = current != null && current.Length > 0
|
||||
? (byte[])current.Clone()
|
||||
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
|
||||
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
if (++bytes[i] != 0)
|
||||
break;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
}
|
||||
public class ApplicationUser : IdentityUser
|
||||
|
|
|
|||
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal file
14
Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace Data.SeaHavenIndustries.Enums
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared terminal lifecycle statuses for EF-translatable filters and board rules.
|
||||
/// </summary>
|
||||
public static class LifecycleStatusSets
|
||||
{
|
||||
public static readonly LifecycleStatus?[] Terminal =
|
||||
{
|
||||
LifecycleStatus.Completed,
|
||||
LifecycleStatus.Canceled
|
||||
};
|
||||
}
|
||||
}
|
||||
3008
Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs
generated
Normal file
3008
Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,84 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase4_SearchIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "SiteCode",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(32)",
|
||||
maxLength: 32,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "InternalWONumber",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(11)",
|
||||
maxLength: 11,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_SiteCode",
|
||||
table: "workOrders",
|
||||
column: "SiteCode",
|
||||
filter: "[istemplate] = 0");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_InternalWONumber",
|
||||
table: "workOrders",
|
||||
column: "InternalWONumber",
|
||||
filter: "[istemplate] = 0");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
|
||||
table: "workOrders",
|
||||
columns: new[] { "LifecycleStatus", "ScheduledDate" },
|
||||
filter: "[istemplate] = 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_SiteCode",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_InternalWONumber",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "SiteCode",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(32)",
|
||||
oldMaxLength: 32);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "InternalWONumber",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(11)",
|
||||
oldMaxLength: 11);
|
||||
}
|
||||
}
|
||||
}
|
||||
2996
Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs
generated
Normal file
2996
Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,64 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase1_BoardIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders (AssignTo, ScheduledDate);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_WorkOrderType'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_workOrders_WorkOrderType ON workOrders (WorkOrderType);
|
||||
END
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_WorkOrderType'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
DROP INDEX IX_workOrders_WorkOrderType ON workOrders;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_AssignTo_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
DROP INDEX IX_workOrders_AssignTo_ScheduledDate ON workOrders;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||
AND object_id = OBJECT_ID('workOrders'))
|
||||
DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
3048
Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs
generated
Normal file
3048
Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,50 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase5_DomainEvents : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkOrderWeekRolledLedgers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: false),
|
||||
SourceWeekStart = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CorrelationId = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkOrderWeekRolledLedgers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_WorkOrderWeekRolledLedgers_workOrders_WorkOrderId",
|
||||
column: x => x.WorkOrderId,
|
||||
principalTable: "workOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkOrderWeekRolledLedgers_WorkOrderId_SourceWeekStart",
|
||||
table: "WorkOrderWeekRolledLedgers",
|
||||
columns: new[] { "WorkOrderId", "SourceWeekStart" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkOrderWeekRolledLedgers");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -631,6 +631,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateTime?>("AcknowledgedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CommercialStatusUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CompletedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -655,6 +658,19 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<bool>("EmailSent")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("EstimatedArrivalAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("EtaManualOverride")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("InvoiceNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("InvoiceStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -670,6 +686,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PaymentStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ReplyToAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1147,6 +1167,86 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("EmployeePhones");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AllDay")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("EndTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("EventColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<int?>("RecordID")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RecordName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RecordTargetType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ReminderMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StartTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1526,6 +1626,65 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Regions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("LocationId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NULL");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId", "Trade")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NOT NULL");
|
||||
|
||||
b.ToTable("SitePreferredVendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1727,6 +1886,13 @@ 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)");
|
||||
|
||||
|
|
@ -1958,6 +2124,93 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("VendorCompanies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("DispatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ReplacesDocumentId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ReviewStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ReviewedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ScanStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ScannedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("VendorCompletionDocuments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -2027,7 +2280,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("InternalWONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(11)
|
||||
.HasColumnType("nvarchar(11)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
|
@ -2105,7 +2359,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SiteCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
@ -2150,10 +2405,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AssignTo");
|
||||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("InternalWONumber")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("PrimaryDispatchId");
|
||||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("SiteCode")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
b.ToTable("workOrders");
|
||||
});
|
||||
|
||||
|
|
@ -2369,6 +2639,35 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("WorkOrderFieldLocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
@ -2796,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")
|
||||
|
|
@ -2870,6 +3188,33 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
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")
|
||||
|
|
@ -2956,6 +3301,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("WorkOrder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkOrder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
|
||||
|
|
@ -3060,6 +3416,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.Navigation("Comments");
|
||||
|
||||
b.Navigation("CompletionDocuments");
|
||||
|
||||
b.Navigation("DispatchWorkOrders");
|
||||
|
||||
b.Navigation("Signoffs");
|
||||
|
|
@ -3106,6 +3464,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("AuditLogs");
|
||||
|
||||
b.Navigation("Dispatches");
|
||||
|
||||
b.Navigation("SitePreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", 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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ namespace Data.SeaHavenIndustries
|
|||
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))]
|
||||
|
|
@ -27,5 +29,6 @@ namespace Data.SeaHavenIndustries
|
|||
public int TotalJobs { get; set; }
|
||||
public ICollection<Dispatch>? Dispatches { get; set; }
|
||||
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
|
||||
public ICollection<SitePreferredVendor>? SitePreferences { 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; }
|
||||
}
|
||||
}
|
||||
15
Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs
Normal file
15
Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class WorkOrderWeekRolledLedger
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int WorkOrderId { get; set; }
|
||||
[ForeignKey(nameof(WorkOrderId))]
|
||||
public virtual WorkOrder? WorkOrder { get; set; }
|
||||
public DateOnly SourceWeekStart { get; set; }
|
||||
public DateTime ProcessedAt { get; set; }
|
||||
public string CorrelationId { 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))]
|
||||
|
|
|
|||
106
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal file
106
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardProjection
|
||||
{
|
||||
public static async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
|
||||
IQueryable<WorkOrder> query,
|
||||
bool isUnscheduled)
|
||||
{
|
||||
var rows = await query
|
||||
.Select(w => new
|
||||
{
|
||||
w.Id,
|
||||
w.InternalWONumber,
|
||||
w.RescheduleCount,
|
||||
w.CarriedOver,
|
||||
w.WorkOrderType,
|
||||
w.SiteCode,
|
||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||
Poc = w.WorkOrderContacts!
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new
|
||||
{
|
||||
Name = c.POC != null
|
||||
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
|
||||
: null,
|
||||
PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null,
|
||||
c.Notes
|
||||
})
|
||||
.FirstOrDefault(),
|
||||
w.LifecycleStatus,
|
||||
w.LegacyStatus,
|
||||
w.Status,
|
||||
w.AssignTo,
|
||||
w.AssignToUser!.FirstName,
|
||||
w.AssignToUser!.LastName,
|
||||
w.AssignToUser!.Initials,
|
||||
w.AssignToUser!.Color,
|
||||
w.DueDate,
|
||||
w.ScheduledDate,
|
||||
w.ScheduledStart,
|
||||
w.ScheduledEnd,
|
||||
w.TargetWeek,
|
||||
w.ScheduleWeekOnly,
|
||||
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
|
||||
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
? w.PrimaryDispatch.Vendor.CompanyName
|
||||
: null,
|
||||
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
? w.PrimaryDispatch.Vendor.ContactName
|
||||
: null,
|
||||
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
? w.PrimaryDispatch.Vendor.Phone
|
||||
: null,
|
||||
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
|
||||
w.Trade,
|
||||
w.Problem,
|
||||
w.DocStatus,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return rows.Select(w => new WorkOrderBoardRawRow(
|
||||
w.Id,
|
||||
w.InternalWONumber,
|
||||
w.RescheduleCount,
|
||||
w.CarriedOver,
|
||||
w.WorkOrderType,
|
||||
w.SiteCode,
|
||||
w.LocationName,
|
||||
w.Poc?.Name,
|
||||
w.Poc?.PhoneNumber,
|
||||
w.Poc?.Notes,
|
||||
w.LifecycleStatus,
|
||||
w.LegacyStatus ?? w.Status,
|
||||
w.AssignTo,
|
||||
w.FirstName,
|
||||
w.LastName,
|
||||
w.Initials,
|
||||
w.Color,
|
||||
w.DueDate,
|
||||
w.ScheduledDate,
|
||||
w.ScheduledStart,
|
||||
w.ScheduledEnd,
|
||||
w.TargetWeek,
|
||||
w.ScheduleWeekOnly,
|
||||
w.VendorId,
|
||||
w.VendorName,
|
||||
w.TechName,
|
||||
w.TechPhone,
|
||||
w.DispatchApptDate,
|
||||
w.Trade ?? w.Problem,
|
||||
w.Problem,
|
||||
w.DocStatus,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
w.DispatchRowVersion,
|
||||
isUnscheduled)).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
147
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
147
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardQueryFilters
|
||||
{
|
||||
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,
|
||||
bool myWorkOrders,
|
||||
string? currentUserId)
|
||||
{
|
||||
if (myWorkOrders && !string.IsNullOrEmpty(currentUserId))
|
||||
return query.Where(w => w.AssignTo == currentUserId);
|
||||
|
||||
if (dispatchers == null || dispatchers.Count == 0)
|
||||
return query;
|
||||
|
||||
var ids = dispatchers
|
||||
.Where(d => d != "__unassigned__")
|
||||
.ToList();
|
||||
var includeUnassigned = dispatchers.Contains("__unassigned__");
|
||||
|
||||
if (includeUnassigned && ids.Count == 0)
|
||||
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
|
||||
|
||||
if (includeUnassigned && ids.Count > 0)
|
||||
{
|
||||
return query.Where(w =>
|
||||
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
|
||||
}
|
||||
|
||||
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyTypeFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<WorkOrderType>? types)
|
||||
{
|
||||
if (types == null || types.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
|
||||
}
|
||||
|
||||
#region Phase 2 filter helpers (not wired in Phase 1 board API)
|
||||
|
||||
public static IQueryable<WorkOrder> ApplySiteFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<string>? sites)
|
||||
{
|
||||
if (sites == null || sites.Count == 0)
|
||||
return query;
|
||||
|
||||
var normalized = sites
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim())
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyStatusFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<LifecycleStatus>? statuses)
|
||||
{
|
||||
if (statuses == null || statuses.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<DocStatus>? docStatuses)
|
||||
{
|
||||
if (docStatuses == null || docStatuses.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyVendorFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<int>? vendorIds)
|
||||
{
|
||||
if (vendorIds == null || vendorIds.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w =>
|
||||
w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyPmTypeFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<string>? pmTypes)
|
||||
{
|
||||
if (pmTypes == null || pmTypes.Count == 0)
|
||||
return query;
|
||||
|
||||
var normalized = pmTypes
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p.Trim().ToLower())
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w =>
|
||||
normalized.Any(pm =>
|
||||
(w.Trade ?? "").ToLower().Contains(pm)
|
||||
|| (w.Problem ?? "").ToLower().Contains(pm)));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
DateOnly dateFrom,
|
||||
DateOnly dateTo)
|
||||
{
|
||||
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
|
||||
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
|
||||
|
||||
return query.Where(w =>
|
||||
(w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date >= fromDate
|
||||
&& w.ScheduledDate.Value.Date <= toDate)
|
||||
|| (w.ScheduleWeekOnly == true
|
||||
&& w.TargetWeek != null
|
||||
&& w.TargetWeek >= dateFrom
|
||||
&& w.TargetWeek <= dateTo));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
47
SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
Normal file
47
SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardSearchFilter
|
||||
{
|
||||
public static string? NormalizeSearch(string? search)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(search))
|
||||
return null;
|
||||
|
||||
var trimmed = search.Trim();
|
||||
return trimmed.Length < 2 ? null : trimmed;
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> Apply(IQueryable<WorkOrder> query, string? search)
|
||||
{
|
||||
var normalized = NormalizeSearch(search);
|
||||
if (normalized == null)
|
||||
return query;
|
||||
|
||||
var s = normalized.ToLower();
|
||||
return query.Where(w =>
|
||||
(w.SiteCode ?? "").ToLower().Contains(s)
|
||||
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|
||||
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|
||||
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|
||||
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|
||||
|| (w.Trade ?? "").ToLower().Contains(s)
|
||||
|| (w.Problem ?? "").ToLower().Contains(s)
|
||||
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|
||||
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|
||||
|| (w.Status ?? "").ToLower().Contains(s)
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.EnRoute && ("en route".Contains(s) || "enroute".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.OnSite && ("on site".Contains(s) || "onsite".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.InProgress && ("in progress".Contains(s) || "inprogress".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderAdvancedSearchDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query)
|
||||
{
|
||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
|
||||
|
||||
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
|
||||
if (normalizedSearch != null)
|
||||
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
|
||||
|
||||
var totalCount = await baseQuery.CountAsync();
|
||||
|
||||
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
|
||||
var paged = sorted
|
||||
.Skip((query.Page - 1) * query.PageSize)
|
||||
.Take(query.PageSize);
|
||||
|
||||
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);
|
||||
return new WorkOrderAdvancedSearchResult(rows, totalCount);
|
||||
}
|
||||
|
||||
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)
|
||||
{
|
||||
var descending = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
var field = (sortBy ?? "scheduledDate").Trim().ToLowerInvariant();
|
||||
|
||||
return field switch
|
||||
{
|
||||
"wonumber" or "woNumber" => descending
|
||||
? query.OrderByDescending(w => w.InternalWONumber)
|
||||
: query.OrderBy(w => w.InternalWONumber),
|
||||
"duedate" or "dueDate" => descending
|
||||
? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber)
|
||||
: query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber),
|
||||
_ => descending
|
||||
? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber)
|
||||
: query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderBoardDataService : IWorkOrderBoardDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderBoardDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query)
|
||||
{
|
||||
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
|
||||
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
|
||||
|
||||
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
||||
|
||||
var scheduledBase = baseQuery.Where(w =>
|
||||
(w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date >= weekStartDate.Date
|
||||
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
|
||||
|| (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart));
|
||||
|
||||
var unscheduledBase = baseQuery.Where(w =>
|
||||
w.ScheduledDate == null
|
||||
&& !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus));
|
||||
|
||||
var scheduledTotal = await scheduledBase.CountAsync();
|
||||
|
||||
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
|
||||
if (normalizedSearch != null)
|
||||
{
|
||||
scheduledBase = WorkOrderBoardSearchFilter.Apply(scheduledBase, normalizedSearch);
|
||||
unscheduledBase = WorkOrderBoardSearchFilter.Apply(unscheduledBase, normalizedSearch);
|
||||
}
|
||||
|
||||
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
||||
scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber),
|
||||
isUnscheduled: false);
|
||||
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
|
||||
unscheduledBase.OrderBy(w => w.InternalWONumber),
|
||||
isUnscheduled: true);
|
||||
|
||||
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync()
|
||||
{
|
||||
return await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => u.IsDeleted != true)
|
||||
.OrderBy(u => u.FirstName)
|
||||
.ThenBy(u => u.LastName)
|
||||
.Select(u => new DispatcherLookupRow(
|
||||
u.Id,
|
||||
u.FirstName ?? "",
|
||||
u.LastName,
|
||||
u.Initials,
|
||||
u.Color))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId)
|
||||
{
|
||||
var query = _context.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
|
||||
return rows.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
|
||||
{
|
||||
return await _context.workOrders
|
||||
.AsNoTracking()
|
||||
.AnyAsync(w =>
|
||||
w.Id != excludeWorkOrderId
|
||||
&& w.istemplate != true
|
||||
&& (w.IsDeleted != true || w.IsDeleted == null)
|
||||
&& w.InternalWONumber == normalizedWoNumber);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService
|
||||
{
|
||||
private static readonly LifecycleStatus?[] TerminalStatuses =
|
||||
{
|
||||
LifecycleStatus.Completed,
|
||||
LifecycleStatus.Canceled
|
||||
};
|
||||
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderDomainJobDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
DateOnly sourceWeekEnd,
|
||||
int batchSize = 500,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue);
|
||||
var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue);
|
||||
|
||||
// Always return the first page of remaining unprocessed candidates.
|
||||
// Callers must not offset-page: ledger writes shrink this set after each batch.
|
||||
return await _context.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(w => w.IsDeleted != true)
|
||||
.Where(w => w.istemplate != true)
|
||||
.Where(w => w.ScheduledDate != null)
|
||||
.Where(w =>
|
||||
w.ScheduledDate!.Value.Date >= weekStartDate.Date
|
||||
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
|
||||
.Where(w => !TerminalStatuses.Contains(w.LifecycleStatus))
|
||||
.Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l =>
|
||||
l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart))
|
||||
.OrderBy(w => w.Id)
|
||||
.Take(batchSize)
|
||||
.Select(w => w.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
|
||||
int workOrderId,
|
||||
DateOnly sourceWeekStart,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var alreadyProcessed = await _context.WorkOrderWeekRolledLedgers
|
||||
.AnyAsync(l => l.WorkOrderId == workOrderId && l.SourceWeekStart == sourceWeekStart, cancellationToken);
|
||||
|
||||
if (alreadyProcessed)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return new WeekRolledProcessOutcome { Skipped = true };
|
||||
}
|
||||
|
||||
var workOrder = await _context.workOrders
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
|
||||
if (workOrder == null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return new WeekRolledProcessOutcome { Skipped = true };
|
||||
}
|
||||
|
||||
_context.WorkOrderWeekRolledLedgers.Add(new WorkOrderWeekRolledLedger
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
SourceWeekStart = sourceWeekStart,
|
||||
ProcessedAt = DateTime.UtcNow,
|
||||
CorrelationId = correlationId
|
||||
});
|
||||
|
||||
var oldCarriedOver = workOrder.CarriedOver;
|
||||
workOrder.CarriedOver = oldCarriedOver + 1;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return new WeekRolledProcessOutcome { Skipped = true };
|
||||
}
|
||||
|
||||
return new WeekRolledProcessOutcome
|
||||
{
|
||||
Processed = true,
|
||||
OldCarriedOver = oldCarriedOver,
|
||||
NewCarriedOver = workOrder.CarriedOver
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
|
||||
DateTime utcNow,
|
||||
int batchSize = 500,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var today = utcNow.Date;
|
||||
var setCount = 0;
|
||||
var clearedCount = 0;
|
||||
var examinedCount = 0;
|
||||
var skip = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var batch = await _context.workOrders
|
||||
.Where(w => w.IsDeleted != true)
|
||||
.Where(w => w.istemplate != true)
|
||||
.Where(w => w.ScheduledDate != null)
|
||||
.OrderBy(w => w.Id)
|
||||
.Skip(skip)
|
||||
.Take(batchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (batch.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var workOrder in batch)
|
||||
{
|
||||
examinedCount++;
|
||||
var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
|
||||
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
|
||||
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);
|
||||
|
||||
if (shouldBePastDue && !hasFlag)
|
||||
{
|
||||
workOrder.OperationalFlags |= OperationalFlags.PastDue;
|
||||
setCount++;
|
||||
}
|
||||
else if (!shouldBePastDue && hasFlag)
|
||||
{
|
||||
workOrder.OperationalFlags &= ~OperationalFlags.PastDue;
|
||||
clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
skip += batchSize;
|
||||
|
||||
if (batch.Count < batchSize)
|
||||
break;
|
||||
}
|
||||
|
||||
return new PastDueRefreshResult
|
||||
{
|
||||
SetCount = setCount,
|
||||
ClearedCount = clearedCount,
|
||||
ExaminedCount = examinedCount
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderAdvancedSearchDataService
|
||||
{
|
||||
Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardDataService
|
||||
{
|
||||
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
|
||||
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
|
||||
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId);
|
||||
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public sealed class WeekRolledProcessOutcome
|
||||
{
|
||||
public bool Processed { get; init; }
|
||||
public bool Skipped { get; init; }
|
||||
public int OldCarriedOver { get; init; }
|
||||
public int NewCarriedOver { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PastDueRefreshResult
|
||||
{
|
||||
public int SetCount { get; init; }
|
||||
public int ClearedCount { get; init; }
|
||||
public int ExaminedCount { get; init; }
|
||||
}
|
||||
|
||||
public interface IWorkOrderDomainJobDataService
|
||||
{
|
||||
Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
DateOnly sourceWeekEnd,
|
||||
int batchSize = 500,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
|
||||
int workOrderId,
|
||||
DateOnly sourceWeekStart,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
|
||||
DateTime utcNow,
|
||||
int batchSize = 500,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public record WorkOrderAdvancedSearchQuery(
|
||||
string? Search,
|
||||
DateOnly DateFrom,
|
||||
DateOnly DateTo,
|
||||
IReadOnlyList<string>? Sites,
|
||||
IReadOnlyList<WorkOrderType>? Types,
|
||||
IReadOnlyList<string>? Dispatchers,
|
||||
IReadOnlyList<LifecycleStatus>? Statuses,
|
||||
IReadOnlyList<string>? PmTypes,
|
||||
IReadOnlyList<int>? VendorIds,
|
||||
IReadOnlyList<DocStatus>? DocStatuses,
|
||||
bool MyWorkOrders,
|
||||
string? CurrentUserId,
|
||||
int Page,
|
||||
int PageSize,
|
||||
string SortBy,
|
||||
string SortDir);
|
||||
|
||||
public record WorkOrderAdvancedSearchResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> Rows,
|
||||
int TotalCount);
|
||||
}
|
||||
62
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
62
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public record WorkOrderBoardQuery(
|
||||
DateOnly WeekStart,
|
||||
DateOnly WeekEnd,
|
||||
IReadOnlyList<string>? Dispatchers,
|
||||
bool MyWorkOrders,
|
||||
IReadOnlyList<WorkOrderType>? Types,
|
||||
string? Search,
|
||||
string? CurrentUserId);
|
||||
|
||||
public record WorkOrderBoardRawRow(
|
||||
int Id,
|
||||
string? InternalWONumber,
|
||||
int RescheduleCount,
|
||||
int CarriedOver,
|
||||
WorkOrderType? WorkOrderType,
|
||||
string? SiteCode,
|
||||
string? LocationName,
|
||||
string? PocName,
|
||||
string? PocPhone,
|
||||
string? PocNotes,
|
||||
LifecycleStatus? LifecycleStatus,
|
||||
string? LegacyStatus,
|
||||
string? AssignTo,
|
||||
string? DispatcherFirstName,
|
||||
string? DispatcherLastName,
|
||||
string? Initials,
|
||||
string? Color,
|
||||
DateTime? DueDate,
|
||||
DateTime? ScheduledDate,
|
||||
DateTime? ScheduledStart,
|
||||
DateTime? ScheduledEnd,
|
||||
DateOnly? TargetWeek,
|
||||
bool? ScheduleWeekOnly,
|
||||
int? VendorId,
|
||||
string? VendorName,
|
||||
string? TechName,
|
||||
string? TechPhone,
|
||||
DateTime? DispatchApptDate,
|
||||
string? Trade,
|
||||
string? Problem,
|
||||
DocStatus? DocStatus,
|
||||
int? PrimaryDispatchId,
|
||||
byte[]? RowVersion,
|
||||
byte[]? DispatchRowVersion,
|
||||
bool IsUnscheduled);
|
||||
|
||||
public record WorkOrderBoardQueryResult(
|
||||
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
|
||||
IReadOnlyList<WorkOrderBoardRawRow> UnscheduledRows,
|
||||
int ScheduledTotalBeforeSearch);
|
||||
|
||||
public record DispatcherLookupRow(
|
||||
string Id,
|
||||
string FirstName,
|
||||
string? LastName,
|
||||
string? Initials,
|
||||
string? Color);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace SeaHaven.Services.Constants
|
|||
public const string LifecycleStatus = nameof(LifecycleStatus);
|
||||
public const string AssignTo = nameof(AssignTo);
|
||||
public const string ScheduledDate = nameof(ScheduledDate);
|
||||
public const string ScheduledStart = nameof(ScheduledStart);
|
||||
public const string ScheduledEnd = nameof(ScheduledEnd);
|
||||
public const string TargetWeek = nameof(TargetWeek);
|
||||
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);
|
||||
|
|
|
|||
97
SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
Normal file
97
SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class WorkOrderBoardQueryDto
|
||||
{
|
||||
public DateOnly WeekStart { get; set; }
|
||||
public DateOnly? WeekEnd { get; set; }
|
||||
public List<string>? Dispatchers { get; set; }
|
||||
public bool MyWorkOrders { get; set; }
|
||||
public List<WorkOrderType>? Types { get; set; }
|
||||
public string? Search { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardCountsDto
|
||||
{
|
||||
public int Returned { get; set; }
|
||||
public int Total { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardRowDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? WoNumber { get; set; }
|
||||
public int RescheduleCount { get; set; }
|
||||
public int CarriedOver { get; set; }
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
public bool IsPastDue { get; set; }
|
||||
public string? SiteCode { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? PocName { get; set; }
|
||||
public string? PocPhone { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
public LifecycleStatus? LifecycleStatus { get; set; }
|
||||
public string? LifecycleStatusLabel { get; set; }
|
||||
public string? LegacyStatus { get; set; }
|
||||
public string? DispatcherId { get; set; }
|
||||
public string? DispatcherName { get; set; }
|
||||
public string? Initials { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateOnly? TargetWeek { get; set; }
|
||||
public bool? ScheduleWeekOnly { get; set; }
|
||||
public string? DayGroup { get; set; }
|
||||
public int? VendorId { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public string? TechName { get; set; }
|
||||
public string? TechPhone { get; set; }
|
||||
public DateTime? ApptDate { get; set; }
|
||||
public string? ApptTime { get; set; }
|
||||
public string? Pm { get; set; }
|
||||
public DocStatus? DocStatus { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
public byte[]? RowVersion { get; set; }
|
||||
public byte[]? DispatchRowVersion { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
public string Field { get; set; } = "";
|
||||
public string? Value { get; set; }
|
||||
public string? WorkOrderVersion { get; set; }
|
||||
public string? DispatchVersion { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardConflictDto
|
||||
{
|
||||
public string Message { get; set; } = "The record was modified by another user. Refresh and retry.";
|
||||
public WorkOrderBoardRowDto? CurrentState { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardValidationErrorDto
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
public class WorkOrderBoardResponseDto
|
||||
{
|
||||
public DateOnly WeekStart { get; set; }
|
||||
public DateOnly WeekEnd { get; set; }
|
||||
public WorkOrderBoardCountsDto Counts { get; set; } = new();
|
||||
public List<WorkOrderBoardRowDto> Unscheduled { get; set; } = new();
|
||||
public List<WorkOrderBoardRowDto> Scheduled { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DispatcherLookupDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string? Initials { get; set; }
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
}
|
||||
48
SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
Normal file
48
SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
/// <summary>Payload for creating a board work order (SHOC create wizard).</summary>
|
||||
public class WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
public string? WoNumber { get; set; }
|
||||
public WorkOrderType WorkOrderType { get; set; }
|
||||
public string? SiteCode { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public int? LocationId { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string? AssignTo { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateOnly? TargetWeek { get; set; }
|
||||
public bool? ScheduleWeekOnly { get; set; }
|
||||
public DocStatus? DocStatus { get; set; }
|
||||
public int? VendorId { get; set; }
|
||||
public DateTime? ApptDate { get; set; }
|
||||
public string? ApptTime { get; set; }
|
||||
public int? PocContactId { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Advanced search / filter query for the board.</summary>
|
||||
public class WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 50;
|
||||
public string? Search { get; set; }
|
||||
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
||||
public DateOnly? DateFrom { get; set; }
|
||||
public DateOnly? DateTo { get; set; }
|
||||
public List<string>? Sites { get; set; }
|
||||
public List<WorkOrderType>? Types { get; set; }
|
||||
public List<string>? Dispatchers { get; set; }
|
||||
public List<LifecycleStatus>? Statuses { get; set; }
|
||||
public List<string>? PmTypes { get; set; }
|
||||
public List<int>? VendorIds { get; set; }
|
||||
public List<DocStatus>? DocStatuses { get; set; }
|
||||
public bool MyWorkOrders { get; set; }
|
||||
public string? SortBy { get; set; }
|
||||
public string? SortDir { get; set; }
|
||||
}
|
||||
}
|
||||
21
SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs
Normal file
21
SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public sealed class WeekRolledJobResult
|
||||
{
|
||||
public int Processed { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public int Failed { get; set; }
|
||||
public string CorrelationId { get; set; } = "";
|
||||
public DateOnly SourceWeekStart { get; set; }
|
||||
public DateOnly SourceWeekEnd { get; set; }
|
||||
public long DurationMs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PastDueCacheJobResult
|
||||
{
|
||||
public int SetCount { get; set; }
|
||||
public int ClearedCount { get; set; }
|
||||
public int ExaminedCount { get; set; }
|
||||
public long DurationMs { get; set; }
|
||||
}
|
||||
}
|
||||
25
SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs
Normal file
25
SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Exceptions
|
||||
{
|
||||
public class WorkOrderBoardValidationException : Exception
|
||||
{
|
||||
public WorkOrderBoardValidationException(string code, string message) : base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
}
|
||||
|
||||
public class WorkOrderBoardConcurrencyException : Exception
|
||||
{
|
||||
public WorkOrderBoardConcurrencyException(WorkOrderBoardRowDto? currentState)
|
||||
: base("The record was modified by another user. Refresh and retry.")
|
||||
{
|
||||
CurrentState = currentState;
|
||||
}
|
||||
|
||||
public WorkOrderBoardRowDto? CurrentState { get; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +1,85 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class LifecycleStatusMapper
|
||||
{
|
||||
public static LifecycleStatus? FromLegacyStatus(string? status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(status)) return null;
|
||||
|
||||
return status.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"open" or "incomplete" => LifecycleStatus.Incomplete,
|
||||
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
|
||||
"scheduled" => LifecycleStatus.Scheduled,
|
||||
"en route" or "enroute" => LifecycleStatus.EnRoute,
|
||||
"on site" or "onsite" => LifecycleStatus.OnSite,
|
||||
"inprogress" or "in progress" => LifecycleStatus.InProgress,
|
||||
"completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed,
|
||||
"rescheduled" => LifecycleStatus.Rescheduled,
|
||||
"cancelled" or "canceled" => LifecycleStatus.Canceled,
|
||||
"pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static LifecycleStatus? ParseLifecycleStatus(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
if (TryParseFeLabel(value, out var fromLabel))
|
||||
return fromLabel;
|
||||
|
||||
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
|
||||
return parsed;
|
||||
|
||||
return FromLegacyStatus(value);
|
||||
}
|
||||
|
||||
public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
|
||||
{
|
||||
status = default;
|
||||
if (string.IsNullOrWhiteSpace(label)) return false;
|
||||
|
||||
return label.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"incomplete" => Set(LifecycleStatus.Incomplete, out status),
|
||||
"pending" => Set(LifecycleStatus.Pending, out status),
|
||||
"scheduled" => Set(LifecycleStatus.Scheduled, out status),
|
||||
"en route" => Set(LifecycleStatus.EnRoute, out status),
|
||||
"on site" => Set(LifecycleStatus.OnSite, out status),
|
||||
"in progress" => Set(LifecycleStatus.InProgress, out status),
|
||||
"completed" => Set(LifecycleStatus.Completed, out status),
|
||||
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
|
||||
"canceled" => Set(LifecycleStatus.Canceled, out status),
|
||||
"pending quote" => Set(LifecycleStatus.PendingQuote, out status),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToFeLabel(LifecycleStatus status) => status switch
|
||||
{
|
||||
LifecycleStatus.Incomplete => "Incomplete",
|
||||
LifecycleStatus.Pending => "Pending",
|
||||
LifecycleStatus.Scheduled => "Scheduled",
|
||||
LifecycleStatus.EnRoute => "En Route",
|
||||
LifecycleStatus.OnSite => "On Site",
|
||||
LifecycleStatus.InProgress => "In Progress",
|
||||
LifecycleStatus.Completed => "Completed",
|
||||
LifecycleStatus.Rescheduled => "Rescheduled",
|
||||
LifecycleStatus.Canceled => "Canceled",
|
||||
LifecycleStatus.PendingQuote => "Pending Quote",
|
||||
_ => status.ToString()
|
||||
};
|
||||
|
||||
public static bool IsTerminal(LifecycleStatus? status)
|
||||
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
|
||||
|
||||
private static bool Set(LifecycleStatus value, out LifecycleStatus status)
|
||||
{
|
||||
status = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class LifecycleStatusMapper
|
||||
{
|
||||
public static LifecycleStatus? FromLegacyStatus(string? status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(status)) return null;
|
||||
|
||||
return status.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"open" or "incomplete" => LifecycleStatus.Incomplete,
|
||||
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
|
||||
"scheduled" => LifecycleStatus.Scheduled,
|
||||
"en route" or "enroute" => LifecycleStatus.EnRoute,
|
||||
"on site" or "onsite" => LifecycleStatus.OnSite,
|
||||
"inprogress" or "in progress" => LifecycleStatus.InProgress,
|
||||
"completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed,
|
||||
"rescheduled" => LifecycleStatus.Rescheduled,
|
||||
"cancelled" or "canceled" => LifecycleStatus.Canceled,
|
||||
"pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static LifecycleStatus? ParseLifecycleStatus(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
if (TryParseFeLabel(value, out var fromLabel))
|
||||
return fromLabel;
|
||||
|
||||
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
|
||||
return parsed;
|
||||
|
||||
return FromLegacyStatus(value);
|
||||
}
|
||||
|
||||
public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
|
||||
{
|
||||
status = default;
|
||||
if (string.IsNullOrWhiteSpace(label)) return false;
|
||||
|
||||
return label.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"incomplete" => Set(LifecycleStatus.Incomplete, out status),
|
||||
"pending" => Set(LifecycleStatus.Pending, out status),
|
||||
"scheduled" => Set(LifecycleStatus.Scheduled, out status),
|
||||
"en route" => Set(LifecycleStatus.EnRoute, out status),
|
||||
"on site" => Set(LifecycleStatus.OnSite, out status),
|
||||
"in progress" => Set(LifecycleStatus.InProgress, out status),
|
||||
"completed" => Set(LifecycleStatus.Completed, out status),
|
||||
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
|
||||
"canceled" => Set(LifecycleStatus.Canceled, out status),
|
||||
"pending quote" => Set(LifecycleStatus.PendingQuote, out status),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToFeLabel(LifecycleStatus status) => status switch
|
||||
{
|
||||
LifecycleStatus.Incomplete => "Incomplete",
|
||||
LifecycleStatus.Pending => "Pending",
|
||||
LifecycleStatus.Scheduled => "Scheduled",
|
||||
LifecycleStatus.EnRoute => "En Route",
|
||||
LifecycleStatus.OnSite => "On Site",
|
||||
LifecycleStatus.InProgress => "In Progress",
|
||||
LifecycleStatus.Completed => "Completed",
|
||||
LifecycleStatus.Rescheduled => "Rescheduled",
|
||||
LifecycleStatus.Canceled => "Canceled",
|
||||
LifecycleStatus.PendingQuote => "Pending Quote",
|
||||
_ => status.ToString()
|
||||
};
|
||||
|
||||
public static bool IsTerminal(LifecycleStatus? status)
|
||||
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
|
||||
|
||||
private static bool Set(LifecycleStatus value, out LifecycleStatus status)
|
||||
{
|
||||
status = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
SeaHaven.Services/Helpers/LifecycleStatusSets.cs
Normal file
20
SeaHaven.Services/Helpers/LifecycleStatusSets.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared terminal lifecycle statuses for board filters, read-only checks, and jobs.
|
||||
/// Keep this as the single source of truth (EF expressions should copy these values inline).
|
||||
/// </summary>
|
||||
public static class LifecycleStatusSets
|
||||
{
|
||||
public static readonly LifecycleStatus[] Terminal =
|
||||
{
|
||||
LifecycleStatus.Completed,
|
||||
LifecycleStatus.Canceled
|
||||
};
|
||||
|
||||
public static bool IsTerminal(LifecycleStatus? status)
|
||||
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
|
||||
}
|
||||
}
|
||||
57
SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
Normal file
57
SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardApptTimeParser
|
||||
{
|
||||
public static bool TryParse(string? value, out TimeSpan? start, out TimeSpan? end, out string? error)
|
||||
{
|
||||
start = null;
|
||||
end = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return true;
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var parts = trimmed.Split(new[] { '–', '—', '-' })
|
||||
.Select(p => p.Trim())
|
||||
.Where(p => !string.IsNullOrEmpty(p))
|
||||
.ToArray();
|
||||
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
if (!TimeSpan.TryParse(parts[0], out var single))
|
||||
{
|
||||
error = "Invalid appointment time format.";
|
||||
return false;
|
||||
}
|
||||
|
||||
start = single;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
if (!TimeSpan.TryParse(parts[0], out var startTime) || !TimeSpan.TryParse(parts[1], out var endTime))
|
||||
{
|
||||
error = "Invalid appointment time range format.";
|
||||
return false;
|
||||
}
|
||||
|
||||
start = startTime;
|
||||
end = endTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Invalid appointment time format.";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static DateTime? CombineDateAndTime(DateTime? date, TimeSpan? time)
|
||||
{
|
||||
if (!date.HasValue || !time.HasValue)
|
||||
return date;
|
||||
|
||||
return date.Value.Date.Add(time.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs
Normal file
136
SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardFieldMutations
|
||||
{
|
||||
public static List<BoardFieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField = "AssignTo")
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
var old = workOrder.AssignTo;
|
||||
var changes = new List<BoardFieldChange>();
|
||||
|
||||
if (old != normalized)
|
||||
{
|
||||
workOrder.AssignTo = normalized;
|
||||
changes.Add(BoardFieldChange.AssignmentChanged(auditField, old, normalized));
|
||||
}
|
||||
|
||||
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||
return changes;
|
||||
}
|
||||
|
||||
public static List<BoardFieldChange> ApplyScheduledDate(
|
||||
WorkOrder workOrder,
|
||||
DateTime? parsed,
|
||||
string auditField = "ScheduledDate",
|
||||
bool incrementRescheduleOnChange = true)
|
||||
{
|
||||
var previousDate = workOrder.ScheduledDate;
|
||||
var changes = new List<BoardFieldChange>();
|
||||
var oldFormatted = FormatDate(previousDate);
|
||||
var newFormatted = FormatDate(parsed);
|
||||
|
||||
if (oldFormatted == newFormatted)
|
||||
return changes;
|
||||
|
||||
workOrder.ScheduledDate = parsed;
|
||||
changes.Add(BoardFieldChange.Changed(auditField, oldFormatted, newFormatted));
|
||||
|
||||
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
|
||||
{
|
||||
workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value);
|
||||
var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed);
|
||||
if (originalWeek.HasValue)
|
||||
workOrder.OriginalWeek = originalWeek;
|
||||
}
|
||||
|
||||
if (incrementRescheduleOnChange && WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed))
|
||||
{
|
||||
var oldCount = workOrder.RescheduleCount.ToString();
|
||||
workOrder.RescheduleCount++;
|
||||
changes.Add(BoardFieldChange.Changed(
|
||||
WorkOrderBoardFieldNames.RescheduleCount,
|
||||
oldCount,
|
||||
workOrder.RescheduleCount.ToString()));
|
||||
}
|
||||
|
||||
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||
return changes;
|
||||
}
|
||||
|
||||
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<BoardFieldChange>();
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.AssignTo,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
return changes;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
|
||||
changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
|
||||
return changes;
|
||||
}
|
||||
|
||||
public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
|
||||
{
|
||||
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
|
||||
|
||||
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
|
||||
var oldStart = FormatDateTime(workOrder.ScheduledStart);
|
||||
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||
|
||||
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
|
||||
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
|
||||
|
||||
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
||||
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||
var oldCombined = FormatApptRange(oldStart, oldEnd);
|
||||
var newCombined = FormatApptRange(newStart, newEnd);
|
||||
|
||||
if (oldCombined == newCombined)
|
||||
return BoardFieldChange.Unchanged("ApptTime", dispatch.Id);
|
||||
|
||||
return BoardFieldChange.Changed("ApptTime", oldCombined, newCombined, dispatch.Id);
|
||||
}
|
||||
|
||||
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
|
||||
{
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Incomplete;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Incomplete);
|
||||
}
|
||||
|
||||
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
|
||||
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
|
||||
private static string? FormatApptRange(string? start, string? end)
|
||||
=> start == null && end == null ? null : $"{start}|{end}";
|
||||
|
||||
public sealed record BoardFieldChange(
|
||||
string FieldName,
|
||||
string? OldValue,
|
||||
string? NewValue,
|
||||
AuditActionType Action,
|
||||
int? DispatchId,
|
||||
bool HasChanged)
|
||||
{
|
||||
public static BoardFieldChange Unchanged(string fieldName, int? dispatchId = null)
|
||||
=> new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false);
|
||||
|
||||
public static BoardFieldChange Changed(string fieldName, string? oldValue, string? newValue, int? dispatchId = null)
|
||||
=> new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true);
|
||||
|
||||
public static BoardFieldChange StatusChanged(string fieldName, string? oldValue, string? newValue)
|
||||
=> new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true);
|
||||
|
||||
public static BoardFieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue)
|
||||
=> new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
Normal file
82
SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardFieldNames
|
||||
{
|
||||
public const string WoNumber = "woNumber";
|
||||
public const string WorkOrderType = "workOrderType";
|
||||
public const string SiteCode = "siteCode";
|
||||
public const string LifecycleStatus = "lifecycleStatus";
|
||||
public const string AssignTo = "assignTo";
|
||||
public const string DueDate = "dueDate";
|
||||
public const string ScheduledDate = "scheduledDate";
|
||||
public const string TargetWeek = "targetWeek";
|
||||
public const string ScheduleWeekOnly = "scheduleWeekOnly";
|
||||
public const string VendorId = "vendorId";
|
||||
public const string ApptDate = "apptDate";
|
||||
public const string ApptTime = "apptTime";
|
||||
public const string DocStatus = "docStatus";
|
||||
public const string RescheduleCount = "RescheduleCount";
|
||||
|
||||
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
VendorId,
|
||||
ApptDate,
|
||||
ApptTime
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllEditable = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
WoNumber,
|
||||
WorkOrderType,
|
||||
SiteCode,
|
||||
LifecycleStatus,
|
||||
AssignTo,
|
||||
DueDate,
|
||||
ScheduledDate,
|
||||
TargetWeek,
|
||||
ScheduleWeekOnly,
|
||||
VendorId,
|
||||
ApptDate,
|
||||
ApptTime,
|
||||
DocStatus
|
||||
};
|
||||
|
||||
public static bool IsEditable(string field) => Canonicalize(field) != null;
|
||||
|
||||
public static bool IsDispatchField(string field)
|
||||
{
|
||||
var canonical = Canonicalize(field);
|
||||
return canonical != null && DispatchFields.Contains(canonical);
|
||||
}
|
||||
|
||||
public static string? Canonicalize(string field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field))
|
||||
return null;
|
||||
|
||||
return AllEditable.FirstOrDefault(f => string.Equals(f, field, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static string ToAuditFieldName(string field)
|
||||
{
|
||||
var canonical = Canonicalize(field) ?? field;
|
||||
return canonical switch
|
||||
{
|
||||
WoNumber => "InternalWONumber",
|
||||
WorkOrderType => "WorkOrderType",
|
||||
SiteCode => "SiteCode",
|
||||
LifecycleStatus => "LifecycleStatus",
|
||||
AssignTo => "AssignTo",
|
||||
DueDate => "DueDate",
|
||||
ScheduledDate => "ScheduledDate",
|
||||
TargetWeek => "TargetWeek",
|
||||
ScheduleWeekOnly => "ScheduleWeekOnly",
|
||||
VendorId => "VendorId",
|
||||
ApptDate => "ApptDate",
|
||||
ApptTime => "ApptTime",
|
||||
DocStatus => "DocStatus",
|
||||
_ => canonical
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
45
SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
Normal file
45
SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardMutationRules
|
||||
{
|
||||
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses =
|
||||
LifecycleStatusSets.Terminal.Cast<LifecycleStatus?>().ToHashSet();
|
||||
|
||||
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
|
||||
|
||||
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
|
||||
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
|
||||
|
||||
/// <summary>
|
||||
/// SHOC rule: Incomplete + specific scheduled date + assignee → Scheduled.
|
||||
/// Week-only targets do not auto-schedule (same as <see cref="WorkOrderDerivedFields.ApplyAutoScheduleIfEligible"/>).
|
||||
/// </summary>
|
||||
public static bool ShouldAutoSchedule(
|
||||
LifecycleStatus? status,
|
||||
DateTime? scheduledDate,
|
||||
string? assignTo,
|
||||
bool? scheduleWeekOnly = null)
|
||||
=> status == LifecycleStatus.Incomplete
|
||||
&& scheduleWeekOnly != true
|
||||
&& scheduledDate.HasValue
|
||||
&& !string.IsNullOrWhiteSpace(assignTo);
|
||||
|
||||
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
|
||||
=> previousDate.HasValue
|
||||
&& newDate.HasValue
|
||||
&& previousDate.Value.Date != newDate.Value.Date;
|
||||
|
||||
public static DateOnly? ComputeOriginalWeek(DateTime? scheduledDate)
|
||||
=> scheduledDate.HasValue
|
||||
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
|
||||
: null;
|
||||
|
||||
private static DateTime GetWeekStartMonday(DateTime date)
|
||||
{
|
||||
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
||||
return date.Date.AddDays(-diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +1,107 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderDerivedFields
|
||||
{
|
||||
/// <summary>
|
||||
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
|
||||
/// </summary>
|
||||
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
||||
{
|
||||
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (workOrder.ScheduledDate == null)
|
||||
return false;
|
||||
|
||||
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
|
||||
return false;
|
||||
|
||||
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
|
||||
/// </summary>
|
||||
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
|
||||
{
|
||||
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
|
||||
ApplyAutoScheduleIfEligible(workOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
|
||||
/// Week-only targets do not auto-schedule.
|
||||
/// </summary>
|
||||
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
|
||||
{
|
||||
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
|
||||
return false;
|
||||
|
||||
if (workOrder.ScheduleWeekOnly == true)
|
||||
return false;
|
||||
|
||||
if (workOrder.ScheduledDate == null)
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
|
||||
return false;
|
||||
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderDerivedFields
|
||||
{
|
||||
/// <summary>
|
||||
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
|
||||
/// </summary>
|
||||
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
|
||||
{
|
||||
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (workOrder.ScheduledDate == null)
|
||||
return false;
|
||||
|
||||
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
|
||||
return false;
|
||||
|
||||
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Board read-model past-due check. Phase 1 compares UTC calendar dates;
|
||||
/// business/location timezone support is planned for a later phase.
|
||||
/// </summary>
|
||||
public static bool IsPastDue(
|
||||
DateTime? scheduledDate,
|
||||
LifecycleStatus? lifecycleStatus,
|
||||
DateTime? utcNow = null)
|
||||
{
|
||||
if (!scheduledDate.HasValue)
|
||||
return false;
|
||||
|
||||
if (LifecycleStatusMapper.IsTerminal(lifecycleStatus))
|
||||
return false;
|
||||
|
||||
var today = (utcNow ?? DateTime.UtcNow).Date;
|
||||
return scheduledDate.Value.Date < today;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
|
||||
/// </summary>
|
||||
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
|
||||
{
|
||||
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
|
||||
ApplyAutoScheduleIfEligible(workOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
|
||||
/// Week-only targets do not auto-schedule.
|
||||
/// </summary>
|
||||
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
|
||||
{
|
||||
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
|
||||
return false;
|
||||
|
||||
if (workOrder.ScheduleWeekOnly == true)
|
||||
return false;
|
||||
|
||||
if (workOrder.ScheduledDate == null)
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
|
||||
return false;
|
||||
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps scheduled date to Mon–Fri board column. Phase 1 uses the stored
|
||||
/// date's day-of-week (UTC); timezone-aware grouping is planned for a later phase.
|
||||
/// </summary>
|
||||
public static string? GetDayGroup(DateTime? scheduledDate)
|
||||
{
|
||||
if (!scheduledDate.HasValue)
|
||||
return null;
|
||||
|
||||
return scheduledDate.Value.DayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Monday => "monday",
|
||||
DayOfWeek.Tuesday => "tuesday",
|
||||
DayOfWeek.Wednesday => "wednesday",
|
||||
DayOfWeek.Thursday => "thursday",
|
||||
DayOfWeek.Friday => "friday",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static string? FormatApptTime(DateTime? start, DateTime? end)
|
||||
{
|
||||
if (!start.HasValue)
|
||||
return null;
|
||||
|
||||
if (end.HasValue)
|
||||
return $"{start.Value:HH:mm} – {end.Value:HH:mm}";
|
||||
|
||||
return start.Value.ToString("HH:mm");
|
||||
}
|
||||
|
||||
public static string GetLifecycleStatusLabel(LifecycleStatus? status)
|
||||
=> status.HasValue ? LifecycleStatusMapper.ToFeLabel(status.Value) : "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
35
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderOperationalWeek
|
||||
{
|
||||
public static DateOnly GetMondayOfWeek(DateOnly date)
|
||||
{
|
||||
var daysFromMonday = ((int)date.DayOfWeek + 6) % 7;
|
||||
return date.AddDays(-daysFromMonday);
|
||||
}
|
||||
|
||||
public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow)
|
||||
=> GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7);
|
||||
|
||||
public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday)
|
||||
=> weekStartMonday.AddDays(4);
|
||||
|
||||
public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
|
||||
=> $"week:{sourceWeekStart:yyyy-MM-dd}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns a validation error message when the week window is invalid; otherwise null.
|
||||
/// </summary>
|
||||
public static string? ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
|
||||
{
|
||||
if (weekEnd < weekStart)
|
||||
return "weekEnd must be on or after weekStart.";
|
||||
|
||||
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
|
||||
if (spanDays > 6)
|
||||
return "Week window cannot exceed 7 days.";
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public enum WorkOrderAdvancedSearchDatePreset
|
||||
{
|
||||
ThisWeek,
|
||||
LastWeek,
|
||||
ThisMonth,
|
||||
Last3Months,
|
||||
NextWeek,
|
||||
NextMonth,
|
||||
Custom
|
||||
}
|
||||
|
||||
public record WorkOrderSearchDateRange(DateOnly From, DateOnly To);
|
||||
|
||||
public static class WorkOrderSearchDateRangeResolver
|
||||
{
|
||||
public static WorkOrderSearchDateRange Resolve(
|
||||
WorkOrderAdvancedSearchDatePreset? preset,
|
||||
DateOnly? dateFrom,
|
||||
DateOnly? dateTo,
|
||||
DateOnly? referenceDate = null)
|
||||
{
|
||||
var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var resolvedPreset = preset ?? WorkOrderAdvancedSearchDatePreset.ThisWeek;
|
||||
|
||||
if (resolvedPreset == WorkOrderAdvancedSearchDatePreset.Custom)
|
||||
{
|
||||
if (!dateFrom.HasValue || !dateTo.HasValue)
|
||||
throw new ArgumentException("dateFrom and dateTo are required when datePreset is custom.");
|
||||
|
||||
if (dateTo.Value < dateFrom.Value)
|
||||
throw new ArgumentException("dateTo must be on or after dateFrom.");
|
||||
|
||||
return new WorkOrderSearchDateRange(dateFrom.Value, dateTo.Value);
|
||||
}
|
||||
|
||||
return resolvedPreset switch
|
||||
{
|
||||
WorkOrderAdvancedSearchDatePreset.ThisWeek => ThisIsoWeek(today),
|
||||
WorkOrderAdvancedSearchDatePreset.LastWeek => LastIsoWeek(today),
|
||||
WorkOrderAdvancedSearchDatePreset.ThisMonth => ThisMonth(today),
|
||||
WorkOrderAdvancedSearchDatePreset.Last3Months => Last3Months(today),
|
||||
WorkOrderAdvancedSearchDatePreset.NextWeek => NextIsoWeek(today),
|
||||
WorkOrderAdvancedSearchDatePreset.NextMonth => NextMonth(today),
|
||||
_ => ThisIsoWeek(today)
|
||||
};
|
||||
}
|
||||
|
||||
public static DateOnly GetIsoWeekStart(DateOnly date)
|
||||
{
|
||||
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
||||
return date.AddDays(-diff);
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today)
|
||||
{
|
||||
var start = GetIsoWeekStart(today);
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today)
|
||||
{
|
||||
var start = GetIsoWeekStart(today).AddDays(-7);
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today)
|
||||
{
|
||||
var start = GetIsoWeekStart(today).AddDays(7);
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange ThisMonth(DateOnly today)
|
||||
{
|
||||
var start = new DateOnly(today.Year, today.Month, 1);
|
||||
var end = start.AddMonths(1).AddDays(-1);
|
||||
return new WorkOrderSearchDateRange(start, end);
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange NextMonth(DateOnly today)
|
||||
{
|
||||
var start = new DateOnly(today.Year, today.Month, 1).AddMonths(1);
|
||||
var end = start.AddMonths(1).AddDays(-1);
|
||||
return new WorkOrderSearchDateRange(start, end);
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange Last3Months(DateOnly today)
|
||||
{
|
||||
var end = today;
|
||||
var start = today.AddMonths(-3);
|
||||
return new WorkOrderSearchDateRange(start, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +1,47 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderTypeMapper
|
||||
{
|
||||
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue"/>.</summary>
|
||||
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
|
||||
{
|
||||
type = default;
|
||||
if (string.IsNullOrWhiteSpace(label)) return false;
|
||||
|
||||
return label.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pm" => Set(WorkOrderType.PM, out type),
|
||||
"reactive" => Set(WorkOrderType.Reactive, out type),
|
||||
"emergency" => Set(WorkOrderType.Emergency, out type),
|
||||
"add-on" or "addon" => Set(WorkOrderType.AddOn, out type),
|
||||
"po" => Set(WorkOrderType.PO, out type),
|
||||
"project" => Set(WorkOrderType.Project, out type),
|
||||
"inspection" => Set(WorkOrderType.Inspection, out type),
|
||||
"other" => Set(WorkOrderType.Other, out type),
|
||||
"overdue" => false,
|
||||
_ => Enum.TryParse(label, true, out type)
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToFeLabel(WorkOrderType type) => type switch
|
||||
{
|
||||
WorkOrderType.PM => "PM",
|
||||
WorkOrderType.Reactive => "Reactive",
|
||||
WorkOrderType.Emergency => "Emergency",
|
||||
WorkOrderType.AddOn => "Add-On",
|
||||
WorkOrderType.PO => "PO",
|
||||
WorkOrderType.Project => "Project",
|
||||
WorkOrderType.Inspection => "Inspection",
|
||||
WorkOrderType.Other => "Other",
|
||||
_ => type.ToString()
|
||||
};
|
||||
|
||||
private static bool Set(WorkOrderType value, out WorkOrderType type)
|
||||
{
|
||||
type = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderTypeMapper
|
||||
{
|
||||
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue(System.DateTime?, LifecycleStatus?, System.DateTime?)"/>.</summary>
|
||||
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
|
||||
{
|
||||
type = default;
|
||||
if (string.IsNullOrWhiteSpace(label)) return false;
|
||||
|
||||
return label.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pm" => Set(WorkOrderType.PM, out type),
|
||||
"reactive" => Set(WorkOrderType.Reactive, out type),
|
||||
"emergency" => Set(WorkOrderType.Emergency, out type),
|
||||
"add-on" or "addon" => Set(WorkOrderType.AddOn, out type),
|
||||
"po" => Set(WorkOrderType.PO, out type),
|
||||
"project" => Set(WorkOrderType.Project, out type),
|
||||
"inspection" => Set(WorkOrderType.Inspection, out type),
|
||||
"other" => Set(WorkOrderType.Other, out type),
|
||||
"overdue" => false,
|
||||
_ => Enum.TryParse(label, true, out type)
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToFeLabel(WorkOrderType type) => type switch
|
||||
{
|
||||
WorkOrderType.PM => "PM",
|
||||
WorkOrderType.Reactive => "Reactive",
|
||||
WorkOrderType.Emergency => "Emergency",
|
||||
WorkOrderType.AddOn => "Add-On",
|
||||
WorkOrderType.PO => "PO",
|
||||
WorkOrderType.Project => "Project",
|
||||
WorkOrderType.Inspection => "Inspection",
|
||||
WorkOrderType.Other => "Other",
|
||||
_ => type.ToString()
|
||||
};
|
||||
|
||||
private static bool Set(WorkOrderType value, out WorkOrderType type)
|
||||
{
|
||||
type = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
SeaHaven.Services/Implementation/PastDueCacheService.cs
Normal file
48
SeaHaven.Services/Implementation/PastDueCacheService.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class PastDueCacheService : IPastDueCacheService
|
||||
{
|
||||
private readonly IWorkOrderDomainJobDataService _dataService;
|
||||
private readonly ILogger<PastDueCacheService> _logger;
|
||||
|
||||
public PastDueCacheService(
|
||||
IWorkOrderDomainJobDataService dataService,
|
||||
ILogger<PastDueCacheService> logger)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PastDueCacheJobResult> RefreshAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("PastDue cache refresh started at {UtcNow}", utcNow);
|
||||
|
||||
var refreshResult = await _dataService.RefreshPastDueFlagsAsync(utcNow, cancellationToken: cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var result = new PastDueCacheJobResult
|
||||
{
|
||||
SetCount = refreshResult.SetCount,
|
||||
ClearedCount = refreshResult.ClearedCount,
|
||||
ExaminedCount = refreshResult.ExaminedCount,
|
||||
DurationMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"PastDue cache refresh completed. Set={SetCount}, Cleared={ClearedCount}, Examined={ExaminedCount}, DurationMs={DurationMs}",
|
||||
result.SetCount, result.ClearedCount, result.ExaminedCount, result.DurationMs);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderFieldNames.LifecycleStatus,
|
||||
WorkOrderFieldNames.AssignTo,
|
||||
WorkOrderFieldNames.ScheduledDate,
|
||||
WorkOrderFieldNames.ScheduledStart,
|
||||
WorkOrderFieldNames.ScheduledEnd,
|
||||
WorkOrderFieldNames.TargetWeek,
|
||||
WorkOrderFieldNames.ScheduleWeekOnly,
|
||||
|
|
@ -68,6 +69,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderFieldNames.Status => wo.Status,
|
||||
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledStart => wo.ScheduledStart?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
|
||||
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
|
||||
WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
|
||||
|
|
@ -105,6 +107,9 @@ namespace SeaHaven.Services.Implementation
|
|||
case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
|
||||
wo.ScheduledDate = scheduledDate;
|
||||
return true;
|
||||
case WorkOrderFieldNames.ScheduledStart when TryParseDateTime(incomingValue, out var scheduledStart):
|
||||
wo.ScheduledStart = scheduledStart;
|
||||
return true;
|
||||
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
|
||||
wo.ScheduledEnd = scheduledEnd;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService
|
||||
{
|
||||
private const int DefaultPageSize = 50;
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
|
||||
|
||||
public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService)
|
||||
{
|
||||
_searchDataService = searchDataService;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
|
||||
WorkOrderAdvancedSearchQueryDto query,
|
||||
string? currentUserId)
|
||||
{
|
||||
var page = query.Page < 1 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
|
||||
|
||||
var dateRange = WorkOrderSearchDateRangeResolver.Resolve(
|
||||
query.DatePreset,
|
||||
query.DateFrom,
|
||||
query.DateTo);
|
||||
|
||||
var dataQuery = new WorkOrderAdvancedSearchQuery(
|
||||
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
|
||||
dateRange.From,
|
||||
dateRange.To,
|
||||
query.Sites,
|
||||
query.Types,
|
||||
query.Dispatchers,
|
||||
query.Statuses,
|
||||
query.PmTypes,
|
||||
query.VendorIds,
|
||||
query.DocStatuses,
|
||||
query.MyWorkOrders,
|
||||
currentUserId,
|
||||
page,
|
||||
pageSize,
|
||||
query.SortBy ?? "scheduledDate",
|
||||
query.SortDir ?? "asc");
|
||||
|
||||
var result = await _searchDataService.SearchAsync(dataQuery);
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
||||
return new PagedResult<WorkOrderBoardRowDto>
|
||||
{
|
||||
Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
|
|
@ -19,16 +18,39 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
|
||||
=> LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual,
|
||||
AuditActorType.Dispatcher, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId);
|
||||
AuditActorType.Dispatcher, "LifecycleStatus", oldStatus, newStatus, actorId);
|
||||
|
||||
public Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
|
||||
=> LogAsync(workOrderId, AuditActionType.AssignmentChanged, AuditEventType.Manual,
|
||||
AuditActorType.Dispatcher, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId);
|
||||
AuditActorType.Dispatcher, "AssignTo", oldValue, newValue, actorId);
|
||||
|
||||
public Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue)
|
||||
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
|
||||
AuditActorType.Sync, fieldName, oldValue, newValue);
|
||||
|
||||
public Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId)
|
||||
=> LogAsync(
|
||||
workOrderId,
|
||||
AuditActionType.WeekRolled,
|
||||
AuditEventType.System,
|
||||
AuditActorType.System,
|
||||
"CarriedOver",
|
||||
oldCarriedOver.ToString(),
|
||||
newCarriedOver.ToString(),
|
||||
correlationId: correlationId);
|
||||
|
||||
public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
|
||||
|
||||
public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
|
||||
|
||||
public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
|
||||
|
||||
public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber)
|
||||
=> StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
|
||||
|
||||
public async Task LogAsync(
|
||||
int workOrderId,
|
||||
AuditActionType action,
|
||||
|
|
@ -40,6 +62,51 @@ namespace SeaHaven.Services.Implementation
|
|||
string? actorId = null,
|
||||
int? dispatchId = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
AddAuditEntry(workOrderId, action, eventType, actorType, fieldName, oldValue, newValue, actorId, dispatchId, correlationId);
|
||||
|
||||
if (eventType == AuditEventType.Manual &&
|
||||
(action == AuditActionType.FieldChanged ||
|
||||
action == AuditActionType.StatusChanged ||
|
||||
action == AuditActionType.AssignmentChanged))
|
||||
{
|
||||
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task StageAuditAsync(
|
||||
int workOrderId,
|
||||
AuditActionType action,
|
||||
string fieldName,
|
||||
string? oldValue,
|
||||
string? newValue,
|
||||
string? actorId,
|
||||
int? dispatchId = null)
|
||||
{
|
||||
AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher,
|
||||
fieldName, oldValue, newValue, actorId, dispatchId);
|
||||
|
||||
if (action == AuditActionType.FieldChanged ||
|
||||
action == AuditActionType.StatusChanged ||
|
||||
action == AuditActionType.AssignmentChanged)
|
||||
{
|
||||
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAuditEntry(
|
||||
int workOrderId,
|
||||
AuditActionType action,
|
||||
AuditEventType eventType,
|
||||
AuditActorType actorType,
|
||||
string fieldName,
|
||||
string? oldValue,
|
||||
string? newValue,
|
||||
string? actorId,
|
||||
int? dispatchId = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
_context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
|
|
@ -55,38 +122,7 @@ namespace SeaHaven.Services.Implementation
|
|||
CorrelationId = correlationId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (eventType == AuditEventType.Manual &&
|
||||
(action == AuditActionType.FieldChanged ||
|
||||
action == AuditActionType.StatusChanged ||
|
||||
action == AuditActionType.AssignmentChanged))
|
||||
{
|
||||
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
|
||||
}
|
||||
|
||||
await SaveChangesIdempotentAsync();
|
||||
}
|
||||
|
||||
private async Task SaveChangesIdempotentAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
|
||||
{
|
||||
foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock))
|
||||
entry.State = EntityState.Detached;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
|
||||
{
|
||||
var message = ex.InnerException?.Message ?? ex.Message;
|
||||
return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderBoardCancelService : IWorkOrderBoardCancelService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
|
||||
public WorkOrderBoardCancelService(
|
||||
ApplicationDbContext context,
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderAuditService auditService)
|
||||
{
|
||||
_context = context;
|
||||
_boardService = boardService;
|
||||
_auditService = auditService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId)
|
||||
{
|
||||
var workOrder = await _context.workOrders
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
|
||||
{
|
||||
var existing = await _boardService.GetBoardRowAsync(workOrderId);
|
||||
return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
if (workOrder.LifecycleStatus == LifecycleStatus.Completed)
|
||||
throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status.");
|
||||
|
||||
var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? "";
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled);
|
||||
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||
workOrder.LegacyStatus = workOrder.Status;
|
||||
|
||||
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var row = await _boardService.GetBoardRowAsync(workOrderId);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
227
SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
Normal file
227
SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderBoardCreateService : IWorkOrderBoardCreateService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||
|
||||
public WorkOrderBoardCreateService(
|
||||
ApplicationDbContext context,
|
||||
IWorkOrderBoardDataService boardDataService,
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderAuditService auditService,
|
||||
IWorkOrderBoardCreateValidation validator)
|
||||
{
|
||||
_context = context;
|
||||
_boardDataService = boardDataService;
|
||||
_boardService = boardService;
|
||||
_auditService = auditService;
|
||||
_validator = validator;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId)
|
||||
{
|
||||
var validationResult = await _validator.ValidateAsync(request);
|
||||
if (!validationResult.IsValid)
|
||||
throw new ValidationException(validationResult.Errors);
|
||||
|
||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
||||
var siteCode = request.SiteCode!.Trim();
|
||||
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = woNumber,
|
||||
WorkerOrderNumber = woNumber,
|
||||
WorkOrderType = request.WorkOrderType,
|
||||
SiteCode = siteCode,
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(),
|
||||
LocationId = request.LocationId,
|
||||
DueDate = request.DueDate?.Date,
|
||||
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
|
||||
TargetWeek = request.TargetWeek,
|
||||
ScheduleWeekOnly = request.ScheduleWeekOnly,
|
||||
DocStatus = request.DocStatus,
|
||||
createdby = actorId,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
istemplate = false
|
||||
};
|
||||
|
||||
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
||||
|
||||
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
||||
{
|
||||
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),
|
||||
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("WorkOrderType", "", request.WorkOrderType.ToString()),
|
||||
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("SiteCode", "", siteCode),
|
||||
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LifecycleStatus", "", LifecycleStatus.Incomplete.ToString())
|
||||
};
|
||||
|
||||
if (workOrder.Description != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||
if (workOrder.Trade != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
||||
if (workOrder.LocationId.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
|
||||
if (workOrder.DueDate.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DueDate", "", workOrder.DueDate.Value.ToString("yyyy-MM-dd")));
|
||||
if (workOrder.TargetWeek.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TargetWeek", "", workOrder.TargetWeek.Value.ToString("yyyy-MM-dd")));
|
||||
if (workOrder.ScheduleWeekOnly.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ScheduleWeekOnly", "", workOrder.ScheduleWeekOnly.Value.ToString()));
|
||||
if (workOrder.DocStatus.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DocStatus", "", workOrder.DocStatus.Value.ToString()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.AssignTo))
|
||||
changes.AddRange(WorkOrderBoardFieldMutations.ApplyAssignTo(workOrder, request.AssignTo));
|
||||
else if (!string.IsNullOrWhiteSpace(workOrder.AssignTo))
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.AssignmentChanged("AssignTo", "", workOrder.AssignTo));
|
||||
|
||||
if (request.ScheduledDate.HasValue)
|
||||
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
|
||||
|
||||
Dispatch? dispatch = null;
|
||||
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
|
||||
{
|
||||
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
|
||||
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
|
||||
|
||||
dispatch = new Dispatch
|
||||
{
|
||||
VendorId = request.VendorId.Value,
|
||||
Status = "Pending",
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
ScheduledDate = request.ApptDate?.Date
|
||||
};
|
||||
_context.Dispatches.Add(dispatch);
|
||||
workOrder.PrimaryDispatch = dispatch;
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorId", "", request.VendorId.Value.ToString()));
|
||||
|
||||
if (request.ApptDate.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ApptDate", "", request.ApptDate.Value.ToString("yyyy-MM-dd")));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ApptTime))
|
||||
{
|
||||
var apptChange = WorkOrderBoardFieldMutations.ApplyApptTime(workOrder, dispatch, request.ApptTime);
|
||||
if (apptChange.HasChanged)
|
||||
changes.Add(apptChange);
|
||||
}
|
||||
}
|
||||
|
||||
// Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and
|
||||
// audit/lock/contact rows. A transaction keeps the create atomic across both saves.
|
||||
await using var transaction = _context.Database.IsRelational()
|
||||
? await _context.Database.BeginTransactionAsync()
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
_context.workOrders.Add(workOrder);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
||||
{
|
||||
workOrder.PrimaryDispatchId = dispatch.Id;
|
||||
dispatch.WorkOrderId = workOrder.Id;
|
||||
}
|
||||
|
||||
await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber);
|
||||
await StageChangesAsync(workOrder.Id, changes, actorId, dispatch);
|
||||
|
||||
if (request.PocContactId.HasValue)
|
||||
{
|
||||
_context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||
{
|
||||
WorkorderId = workOrder.Id,
|
||||
ContactId = request.PocContactId,
|
||||
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||
}
|
||||
|
||||
private async Task<string> ResolveWoNumberAsync(string? requested)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
|
||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||
|
||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0))
|
||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
var candidate = await GenerateNextSequentialWoNumberAsync();
|
||||
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
throw new WorkOrderBoardValidationException("WoNumberGenerationFailed", "Could not generate a unique WO number.");
|
||||
}
|
||||
|
||||
private async Task<string> GenerateNextSequentialWoNumberAsync()
|
||||
{
|
||||
var maxId = await _context.workOrders.MaxAsync(w => (int?)w.Id) ?? 0;
|
||||
var seed = Math.Max(maxId + 1, 1);
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _))
|
||||
normalized = seed.ToString().PadLeft(11, '0');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async Task StageChangesAsync(
|
||||
int workOrderId,
|
||||
IEnumerable<WorkOrderBoardFieldMutations.BoardFieldChange> changes,
|
||||
string? actorId,
|
||||
Dispatch? dispatch)
|
||||
{
|
||||
foreach (var change in changes.Where(c => c.HasChanged))
|
||||
{
|
||||
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||
switch (change.Action)
|
||||
{
|
||||
case AuditActionType.StatusChanged:
|
||||
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
case AuditActionType.AssignmentChanged:
|
||||
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
default:
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal file
122
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderBoardService : IWorkOrderBoardService
|
||||
{
|
||||
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||
|
||||
public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService)
|
||||
{
|
||||
_boardDataService = boardDataService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardResponseDto> GetBoardAsync(
|
||||
WorkOrderBoardQueryDto query,
|
||||
string? currentUserId)
|
||||
{
|
||||
var weekStart = query.WeekStart;
|
||||
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
|
||||
|
||||
var validationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, weekEnd);
|
||||
if (validationError != null)
|
||||
throw new ArgumentException(validationError);
|
||||
|
||||
var dataQuery = new WorkOrderBoardQuery(
|
||||
weekStart,
|
||||
weekEnd,
|
||||
query.Dispatchers,
|
||||
query.MyWorkOrders,
|
||||
query.Types,
|
||||
query.Search,
|
||||
currentUserId);
|
||||
|
||||
var result = await _boardDataService.GetBoardRowsAsync(dataQuery);
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
||||
return new WorkOrderBoardResponseDto
|
||||
{
|
||||
WeekStart = weekStart,
|
||||
WeekEnd = weekEnd,
|
||||
Counts = new WorkOrderBoardCountsDto
|
||||
{
|
||||
Total = result.ScheduledTotalBeforeSearch,
|
||||
Returned = result.ScheduledRows.Count
|
||||
},
|
||||
Scheduled = result.ScheduledRows.Select(r => MapRawRow(r, utcNow)).ToList(),
|
||||
Unscheduled = result.UnscheduledRows.Select(r => MapRawRow(r, utcNow)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync()
|
||||
{
|
||||
var rows = await _boardDataService.GetDispatcherLookupsAsync();
|
||||
return rows.Select(r => new DispatcherLookupDto
|
||||
{
|
||||
Id = r.Id,
|
||||
Name = FormatName(r.FirstName, r.LastName),
|
||||
Initials = r.Initials,
|
||||
Color = r.Color
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId)
|
||||
{
|
||||
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId);
|
||||
return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
|
||||
{
|
||||
// Appt column: date prefers dispatch appointment (vendor slot), then WO ScheduledDate.
|
||||
// Time prefers WO ScheduledStart/End (dispatcher window), then dispatch datetime.
|
||||
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
|
||||
var apptEnd = row.ScheduledEnd;
|
||||
|
||||
return new WorkOrderBoardRowDto
|
||||
{
|
||||
Id = row.Id,
|
||||
WoNumber = row.InternalWONumber,
|
||||
RescheduleCount = row.RescheduleCount,
|
||||
CarriedOver = row.CarriedOver,
|
||||
WorkOrderType = row.WorkOrderType,
|
||||
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
||||
SiteCode = row.SiteCode,
|
||||
LocationName = row.LocationName,
|
||||
PocName = row.PocName,
|
||||
PocPhone = row.PocPhone,
|
||||
PocNotes = row.PocNotes,
|
||||
LifecycleStatus = row.LifecycleStatus,
|
||||
LifecycleStatusLabel = WorkOrderDerivedFields.GetLifecycleStatusLabel(row.LifecycleStatus),
|
||||
LegacyStatus = row.LegacyStatus,
|
||||
DispatcherId = row.AssignTo,
|
||||
DispatcherName = FormatName(row.DispatcherFirstName, row.DispatcherLastName),
|
||||
Initials = row.Initials,
|
||||
Color = row.Color,
|
||||
DueDate = row.DueDate,
|
||||
ScheduledDate = row.ScheduledDate,
|
||||
TargetWeek = row.TargetWeek,
|
||||
ScheduleWeekOnly = row.ScheduleWeekOnly,
|
||||
DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate),
|
||||
VendorId = row.VendorId,
|
||||
VendorName = row.VendorName,
|
||||
TechName = row.TechName,
|
||||
TechPhone = row.TechPhone,
|
||||
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
||||
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
||||
Pm = row.Trade ?? row.Problem,
|
||||
DocStatus = row.DocStatus,
|
||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||
RowVersion = row.RowVersion,
|
||||
DispatchRowVersion = row.DispatchRowVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatName(string? firstName, string? lastName)
|
||||
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
||||
}
|
||||
}
|
||||
583
SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
Normal file
583
SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderBoardUpdateService : IWorkOrderBoardUpdateService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
|
||||
public WorkOrderBoardUpdateService(
|
||||
ApplicationDbContext context,
|
||||
IWorkOrderBoardDataService boardDataService,
|
||||
IWorkOrderAuditService auditService)
|
||||
{
|
||||
_context = context;
|
||||
_boardDataService = boardDataService;
|
||||
_auditService = auditService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
|
||||
int workOrderId,
|
||||
WorkOrderBoardPatchRequestDto request,
|
||||
string? actorId)
|
||||
{
|
||||
// Intermediate SaveChanges (e.g. new dispatch identity) must stay atomic with the final patch save.
|
||||
await using var transaction = _context.Database.IsRelational()
|
||||
? await _context.Database.BeginTransactionAsync()
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Field))
|
||||
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
|
||||
|
||||
var field = request.Field.Trim();
|
||||
var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field);
|
||||
if (canonicalField == null)
|
||||
throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.");
|
||||
|
||||
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion");
|
||||
if (workOrderVersion == null)
|
||||
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
||||
|
||||
var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion");
|
||||
|
||||
var workOrder = await _context.workOrders
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
|
||||
{
|
||||
var staleState = await LoadBoardRowAsync(workOrderId);
|
||||
throw new WorkOrderBoardConcurrencyException(staleState);
|
||||
}
|
||||
|
||||
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
|
||||
|
||||
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
|
||||
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
|
||||
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first.");
|
||||
|
||||
_context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion;
|
||||
|
||||
Dispatch? dispatch = null;
|
||||
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
|
||||
{
|
||||
var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)
|
||||
? ParseVendorIdHint(request.Value)
|
||||
: null;
|
||||
|
||||
var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue;
|
||||
dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint);
|
||||
|
||||
if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion))
|
||||
{
|
||||
var staleState = await LoadBoardRowAsync(workOrderId);
|
||||
throw new WorkOrderBoardConcurrencyException(staleState);
|
||||
}
|
||||
|
||||
if (dispatchVersion != null)
|
||||
_context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = dispatchVersion;
|
||||
else if (hadPrimaryDispatch && dispatch.RowVersion != null)
|
||||
throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields.");
|
||||
}
|
||||
|
||||
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
||||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
|
||||
changes = changes.Where(c => c.HasChanged).ToList();
|
||||
|
||||
if (changes.Count == 0 && !_context.ChangeTracker.HasChanges())
|
||||
{
|
||||
var unchanged = await LoadBoardRowAsync(workOrderId);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
var persisted = await LoadBoardRowAsync(workOrderId);
|
||||
return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||
switch (change.Action)
|
||||
{
|
||||
case AuditActionType.StatusChanged:
|
||||
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
case AuditActionType.AssignmentChanged:
|
||||
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||
break;
|
||||
default:
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
var currentState = await LoadBoardRowAsync(workOrderId);
|
||||
throw new WorkOrderBoardConcurrencyException(currentState);
|
||||
}
|
||||
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync();
|
||||
|
||||
var row = await LoadBoardRowAsync(workOrderId);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
}
|
||||
catch (WorkOrderBoardConcurrencyException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<FieldChange>> ApplyFieldMutationAsync(
|
||||
string field,
|
||||
WorkOrder workOrder,
|
||||
Dispatch? dispatch,
|
||||
string? value,
|
||||
string auditField)
|
||||
{
|
||||
return field switch
|
||||
{
|
||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
|
||||
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
|
||||
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
|
||||
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
|
||||
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
|
||||
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
|
||||
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Dispatch> ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate)
|
||||
{
|
||||
if (primaryDispatchId.HasValue)
|
||||
{
|
||||
var dispatch = await _context.Dispatches
|
||||
.FirstOrDefaultAsync(d => d.Id == primaryDispatchId.Value && d.WorkOrderId == workOrder.Id);
|
||||
|
||||
if (dispatch == null)
|
||||
throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order.");
|
||||
|
||||
workOrder.PrimaryDispatchId = dispatch.Id;
|
||||
return dispatch;
|
||||
}
|
||||
|
||||
if (workOrder.PrimaryDispatchId.HasValue)
|
||||
{
|
||||
var existing = await _context.Dispatches
|
||||
.FirstOrDefaultAsync(d => d.Id == workOrder.PrimaryDispatchId.Value);
|
||||
|
||||
if (existing != null)
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0)
|
||||
throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId.");
|
||||
|
||||
await EnsureVendorExistsAsync(vendorIdForCreate.Value);
|
||||
|
||||
var created = new Dispatch
|
||||
{
|
||||
WorkOrderId = workOrder.Id,
|
||||
VendorId = vendorIdForCreate.Value,
|
||||
Status = "Pending",
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
_context.Dispatches.Add(created);
|
||||
await _context.SaveChangesAsync();
|
||||
workOrder.PrimaryDispatchId = created.Id;
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task EnsureVendorExistsAsync(int vendorId)
|
||||
{
|
||||
var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true);
|
||||
if (!vendorExists)
|
||||
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
||||
}
|
||||
|
||||
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||
|
||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id))
|
||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||
|
||||
var old = workOrder.InternalWONumber;
|
||||
if (old == normalized)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.InternalWONumber = normalized;
|
||||
workOrder.WorkerOrderNumber = normalized;
|
||||
return FieldChange.ForField(auditField, old, normalized);
|
||||
}
|
||||
|
||||
private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
|
||||
|
||||
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
||||
|
||||
var old = workOrder.WorkOrderType?.ToString();
|
||||
if (workOrder.WorkOrderType == parsed)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.WorkOrderType = parsed;
|
||||
return FieldChange.ForField(auditField, old, parsed.ToString());
|
||||
}
|
||||
|
||||
private static FieldChange ApplyStringField(
|
||||
string? value,
|
||||
string auditField,
|
||||
Action<string?> setter,
|
||||
Func<string?> getter)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
var old = getter();
|
||||
if (old == normalized)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
setter(normalized);
|
||||
return FieldChange.ForField(auditField, old, normalized);
|
||||
}
|
||||
|
||||
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
|
||||
if (parsed == null)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid lifecycleStatus: {value}");
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
|
||||
if (workOrder.LifecycleStatus == parsed)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.LifecycleStatus = parsed;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
|
||||
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||
workOrder.LegacyStatus = workOrder.Status;
|
||||
|
||||
return FieldChange.StatusChanged(auditField, old, parsed.ToString());
|
||||
}
|
||||
|
||||
private static List<FieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
var old = workOrder.AssignTo;
|
||||
var changes = new List<FieldChange>();
|
||||
|
||||
if (old != normalized)
|
||||
{
|
||||
workOrder.AssignTo = normalized;
|
||||
changes.Add(FieldChange.AssignmentChanged(auditField, old, normalized));
|
||||
}
|
||||
|
||||
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||
return changes;
|
||||
}
|
||||
|
||||
private static FieldChange ApplyDateField(
|
||||
string? value,
|
||||
string auditField,
|
||||
Action<DateTime?> setter,
|
||||
Func<DateTime?> getter)
|
||||
{
|
||||
DateTime? parsed = null;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (!DateTime.TryParse(value, out var dt))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid date for {auditField}: {value}");
|
||||
parsed = dt.Date;
|
||||
}
|
||||
|
||||
var old = FormatDate(getter());
|
||||
var newVal = FormatDate(parsed);
|
||||
if (old == newVal)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
setter(parsed);
|
||||
return FieldChange.ForField(auditField, old, newVal);
|
||||
}
|
||||
|
||||
private static List<FieldChange> ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
var previousDate = workOrder.ScheduledDate;
|
||||
DateTime? parsed = null;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (!DateTime.TryParse(value, out var dt))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid scheduledDate: {value}");
|
||||
parsed = dt.Date;
|
||||
}
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
var oldFormatted = FormatDate(previousDate);
|
||||
var newFormatted = FormatDate(parsed);
|
||||
|
||||
if (oldFormatted != newFormatted)
|
||||
{
|
||||
workOrder.ScheduledDate = parsed;
|
||||
changes.Add(FieldChange.ForField(auditField, oldFormatted, newFormatted));
|
||||
|
||||
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
|
||||
{
|
||||
workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value);
|
||||
var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed);
|
||||
if (originalWeek.HasValue)
|
||||
workOrder.OriginalWeek = originalWeek;
|
||||
}
|
||||
|
||||
if (WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed))
|
||||
{
|
||||
var oldCount = workOrder.RescheduleCount.ToString();
|
||||
workOrder.RescheduleCount++;
|
||||
changes.Add(FieldChange.ForField(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||
return changes;
|
||||
}
|
||||
|
||||
private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
DateOnly? parsed = null;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (!DateOnly.TryParse(value, out var d))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid targetWeek: {value}");
|
||||
parsed = d;
|
||||
}
|
||||
|
||||
var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd");
|
||||
var newVal = parsed?.ToString("yyyy-MM-dd");
|
||||
if (old == newVal)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.TargetWeek = parsed;
|
||||
return FieldChange.ForField(auditField, old, newVal);
|
||||
}
|
||||
|
||||
private static FieldChange ApplyBoolField(
|
||||
string? value,
|
||||
string auditField,
|
||||
Action<bool?> setter,
|
||||
Func<bool?> getter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"{auditField} value is required.");
|
||||
|
||||
if (!bool.TryParse(value, out var parsed))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid boolean for {auditField}: {value}");
|
||||
|
||||
var old = getter()?.ToString();
|
||||
if (getter() == parsed)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
setter(parsed);
|
||||
return FieldChange.ForField(auditField, old, parsed.ToString());
|
||||
}
|
||||
|
||||
private async Task<FieldChange> ApplyVendorIdAsync(Dispatch dispatch, string? value, string auditField)
|
||||
{
|
||||
var vendorId = ParseVendorIdHint(value);
|
||||
if (!vendorId.HasValue)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer.");
|
||||
|
||||
await EnsureVendorExistsAsync(vendorId.Value);
|
||||
|
||||
var old = dispatch.VendorId.ToString();
|
||||
if (dispatch.VendorId == vendorId.Value)
|
||||
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
|
||||
|
||||
dispatch.VendorId = vendorId.Value;
|
||||
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch));
|
||||
}
|
||||
|
||||
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
|
||||
{
|
||||
DateTime? parsed = null;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (!DateTime.TryParse(value, out var dt))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid apptDate: {value}");
|
||||
parsed = dt.Date;
|
||||
}
|
||||
|
||||
var old = FormatDate(dispatch.ScheduledDate);
|
||||
var newVal = FormatDate(parsed);
|
||||
if (old == newVal)
|
||||
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
|
||||
|
||||
dispatch.ScheduledDate = parsed;
|
||||
return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch));
|
||||
}
|
||||
|
||||
// apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so
|
||||
// SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock).
|
||||
private static List<FieldChange> ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
|
||||
{
|
||||
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
|
||||
|
||||
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
|
||||
var oldStart = FormatDateTime(workOrder.ScheduledStart);
|
||||
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||
var dispatchId = ResolveDispatchIdForAudit(dispatch);
|
||||
|
||||
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
|
||||
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
|
||||
|
||||
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
||||
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||
var changes = new List<FieldChange>();
|
||||
|
||||
if (oldStart != newStart)
|
||||
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId));
|
||||
if (oldEnd != newEnd)
|
||||
changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId));
|
||||
|
||||
if (changes.Count == 0)
|
||||
changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId));
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "docStatus value is required.");
|
||||
|
||||
if (!Enum.TryParse<DocStatus>(value, true, out var parsed))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid docStatus: {value}");
|
||||
|
||||
var old = workOrder.DocStatus?.ToString();
|
||||
if (workOrder.DocStatus == parsed)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.DocStatus = parsed;
|
||||
return FieldChange.ForField(auditField, old, parsed.ToString());
|
||||
}
|
||||
|
||||
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<FieldChange>();
|
||||
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
workOrder.LifecycleStatus,
|
||||
workOrder.ScheduledDate,
|
||||
workOrder.AssignTo,
|
||||
workOrder.ScheduleWeekOnly))
|
||||
return changes;
|
||||
|
||||
var old = workOrder.LifecycleStatus?.ToString();
|
||||
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
|
||||
changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
|
||||
return changes;
|
||||
}
|
||||
|
||||
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
|
||||
=> current != null && current.AsSpan().SequenceEqual(expected);
|
||||
|
||||
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId)
|
||||
{
|
||||
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId);
|
||||
return raw == null ? null : WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static byte[]? ParseRowVersion(string? base64, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidRowVersion", $"Invalid {fieldName} format.");
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ParseVendorIdHint(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || !int.TryParse(value, out var vendorId) || vendorId <= 0)
|
||||
return null;
|
||||
return vendorId;
|
||||
}
|
||||
|
||||
private static int? ResolveDispatchIdForAudit(Dispatch dispatch)
|
||||
=> dispatch.Id > 0 ? dispatch.Id : null;
|
||||
|
||||
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
|
||||
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
|
||||
private static string? FormatApptRange(string? start, string? end)
|
||||
=> start == null && end == null ? null : $"{start}|{end}";
|
||||
|
||||
private sealed record FieldChange(
|
||||
string FieldName,
|
||||
string? OldValue,
|
||||
string? NewValue,
|
||||
AuditActionType Action,
|
||||
int? DispatchId,
|
||||
bool HasChanged)
|
||||
{
|
||||
public static FieldChange Unchanged(string fieldName, int? dispatchId = null)
|
||||
=> new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false);
|
||||
|
||||
public static FieldChange ForField(string fieldName, string? oldValue, string? newValue, int? dispatchId = null)
|
||||
=> new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true);
|
||||
|
||||
public static FieldChange StatusChanged(string fieldName, string? oldValue, string? newValue)
|
||||
=> new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true);
|
||||
|
||||
public static FieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue)
|
||||
=> new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs
Normal file
110
SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService
|
||||
{
|
||||
private const int BatchSize = 500;
|
||||
|
||||
private readonly IWorkOrderDomainJobDataService _dataService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly ILogger<WorkOrderWeekRolledService> _logger;
|
||||
|
||||
public WorkOrderWeekRolledService(
|
||||
IWorkOrderDomainJobDataService dataService,
|
||||
IWorkOrderAuditService auditService,
|
||||
ILogger<WorkOrderWeekRolledService> logger)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_auditService = auditService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WeekRolledJobResult> ProcessWeekRolledAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var sourceWeekEnd = WorkOrderOperationalWeek.GetOperationalWeekEnd(sourceWeekStart);
|
||||
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(sourceWeekStart);
|
||||
|
||||
var processed = 0;
|
||||
var skipped = 0;
|
||||
var failed = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}",
|
||||
correlationId, sourceWeekStart, sourceWeekEnd);
|
||||
|
||||
// Always fetch the first page of remaining candidates. Processed rows write ledger
|
||||
// entries that exclude them from the next query; offset paging would skip unprocessed rows.
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var candidates = await _dataService.GetWeekRolledCandidatesAsync(
|
||||
sourceWeekStart, sourceWeekEnd, BatchSize, cancellationToken);
|
||||
|
||||
if (candidates.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var workOrderId in candidates)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var outcome = await _dataService.TryProcessWeekRolledAsync(
|
||||
workOrderId, sourceWeekStart, correlationId, cancellationToken);
|
||||
|
||||
if (outcome.Skipped)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outcome.Processed)
|
||||
{
|
||||
await _auditService.LogWeekRolledAsync(
|
||||
workOrderId, outcome.OldCarriedOver, outcome.NewCarriedOver, correlationId);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
_logger.LogError(ex,
|
||||
"WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}",
|
||||
workOrderId, correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count < BatchSize)
|
||||
break;
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var result = new WeekRolledJobResult
|
||||
{
|
||||
Processed = processed,
|
||||
Skipped = skipped,
|
||||
Failed = failed,
|
||||
CorrelationId = correlationId,
|
||||
SourceWeekStart = sourceWeekStart,
|
||||
SourceWeekEnd = sourceWeekEnd,
|
||||
DurationMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
|
||||
correlationId, processed, skipped, failed, result.DurationMs);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
SeaHaven.Services/Interfaces/IPastDueCacheService.cs
Normal file
9
SeaHaven.Services/Interfaces/IPastDueCacheService.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IPastDueCacheService
|
||||
{
|
||||
Task<PastDueCacheJobResult> RefreshAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderAdvancedSearchService
|
||||
{
|
||||
Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
|
||||
WorkOrderAdvancedSearchQueryDto query,
|
||||
string? currentUserId);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,5 +19,11 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
|
||||
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
|
||||
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
|
||||
Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId);
|
||||
|
||||
Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
|
||||
Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
|
||||
Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
|
||||
Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardCancelService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardCreateService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId);
|
||||
}
|
||||
}
|
||||
11
SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs
Normal file
11
SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardService
|
||||
{
|
||||
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId);
|
||||
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
|
||||
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardUpdateService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId);
|
||||
}
|
||||
}
|
||||
11
SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs
Normal file
11
SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderWeekRolledService
|
||||
{
|
||||
Task<WeekRolledJobResult> ProcessWeekRolledAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
using FluentValidation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Validation
|
||||
{
|
||||
public interface IWorkOrderBoardCreateValidation : IValidator<WorkOrderBoardCreateRequestDto>
|
||||
{
|
||||
}
|
||||
|
||||
public class WorkOrderBoardCreateValidation : AbstractValidator<WorkOrderBoardCreateRequestDto>, IWorkOrderBoardCreateValidation
|
||||
{
|
||||
public WorkOrderBoardCreateValidation()
|
||||
{
|
||||
RuleFor(x => x.WorkOrderType)
|
||||
.IsInEnum().WithMessage("workOrderType is required and must be a valid value.");
|
||||
|
||||
RuleFor(x => x.SiteCode)
|
||||
.NotEmpty().WithMessage("siteCode is required.")
|
||||
.MaximumLength(32);
|
||||
|
||||
RuleFor(x => x.TargetWeek)
|
||||
.NotNull().WithMessage("targetWeek is required when scheduleWeekOnly is true.")
|
||||
.When(x => x.ScheduleWeekOnly == true);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(2000)
|
||||
.When(x => !string.IsNullOrEmpty(x.Description));
|
||||
|
||||
RuleFor(x => x.Trade)
|
||||
.MaximumLength(128)
|
||||
.When(x => !string.IsNullOrEmpty(x.Trade));
|
||||
|
||||
RuleFor(x => x.LocationId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.LocationId.HasValue);
|
||||
|
||||
RuleFor(x => x.VendorId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.VendorId.HasValue);
|
||||
|
||||
RuleFor(x => x.PocContactId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.PocContactId.HasValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
180
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal file
180
SeaHavenIndustries.Tests/Phase4SearchIndexesSqlServerTests.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
using System.Reflection;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Migrations;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class Phase4SearchIndexesSqlServerTests
|
||||
{
|
||||
private const string LocalDbMaster =
|
||||
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
|
||||
|
||||
[Fact]
|
||||
public void WorkOrder_IndexedStringColumns_HaveBoundedMaxLengths()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer(LocalDbMaster)
|
||||
.Options;
|
||||
|
||||
using var context = new ApplicationDbContext(options);
|
||||
var entity = context.Model.FindEntityType(typeof(WorkOrder));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
Assert.Equal(32, entity.FindProperty(nameof(WorkOrder.SiteCode))!.GetMaxLength());
|
||||
Assert.Equal(11, entity.FindProperty(nameof(WorkOrder.InternalWONumber))!.GetMaxLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Phase4_SearchIndexes_ApplyOnSqlServer_WhenLocalDbAvailable()
|
||||
{
|
||||
if (!await IsLocalDbAvailableAsync())
|
||||
return;
|
||||
|
||||
var dbName = $"Phase4SearchIndexes_{Guid.NewGuid():N}";
|
||||
var connectionString =
|
||||
$@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True";
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using (var createTable = connection.CreateCommand())
|
||||
{
|
||||
createTable.CommandText =
|
||||
"""
|
||||
CREATE TABLE workOrders (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||
SiteCode nvarchar(max) NULL,
|
||||
InternalWONumber nvarchar(max) NULL,
|
||||
LifecycleStatus int NULL,
|
||||
ScheduledDate datetime2 NULL,
|
||||
istemplate bit NULL
|
||||
);
|
||||
""";
|
||||
await createTable.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await ApplyMigrationUpAsync(context, new Phase4_SearchIndexes());
|
||||
|
||||
await using (var assertCmd = connection.CreateCommand())
|
||||
{
|
||||
assertCmd.CommandText =
|
||||
"""
|
||||
SELECT c.name, t.name AS type_name, c.max_length
|
||||
FROM sys.columns c
|
||||
INNER JOIN sys.types t ON c.user_type_id = t.user_type_id
|
||||
WHERE c.object_id = OBJECT_ID(N'workOrders')
|
||||
AND c.name IN (N'SiteCode', N'InternalWONumber')
|
||||
ORDER BY c.name;
|
||||
""";
|
||||
|
||||
await using var reader = await assertCmd.ExecuteReaderAsync();
|
||||
var columns = new Dictionary<string, (string Type, short MaxLength)>(StringComparer.OrdinalIgnoreCase);
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
columns[reader.GetString(0)] = (reader.GetString(1), reader.GetInt16(2));
|
||||
}
|
||||
|
||||
Assert.Equal("nvarchar", columns["InternalWONumber"].Type);
|
||||
Assert.Equal(22, columns["InternalWONumber"].MaxLength); // nvarchar(11) => 22 bytes
|
||||
Assert.Equal("nvarchar", columns["SiteCode"].Type);
|
||||
Assert.Equal(64, columns["SiteCode"].MaxLength); // nvarchar(32) => 64 bytes
|
||||
}
|
||||
|
||||
await using (var indexCmd = connection.CreateCommand())
|
||||
{
|
||||
indexCmd.CommandText =
|
||||
"""
|
||||
SELECT name
|
||||
FROM sys.indexes
|
||||
WHERE object_id = OBJECT_ID(N'workOrders')
|
||||
AND name IN (
|
||||
N'IX_workOrders_SiteCode',
|
||||
N'IX_workOrders_InternalWONumber',
|
||||
N'IX_workOrders_LifecycleStatus_ScheduledDate'
|
||||
);
|
||||
""";
|
||||
|
||||
await using var reader = await indexCmd.ExecuteReaderAsync();
|
||||
var indexes = new List<string>();
|
||||
while (await reader.ReadAsync())
|
||||
indexes.Add(reader.GetString(0));
|
||||
|
||||
Assert.Contains("IX_workOrders_SiteCode", indexes);
|
||||
Assert.Contains("IX_workOrders_InternalWONumber", indexes);
|
||||
Assert.Contains("IX_workOrders_LifecycleStatus_ScheduledDate", indexes);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DropDatabaseAsync(dbName);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
|
||||
{
|
||||
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Migration.Up not found.");
|
||||
up.Invoke(migration, [builder]);
|
||||
|
||||
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||
var commands = sqlGenerator.Generate(builder.Operations, model: null);
|
||||
foreach (var command in commands)
|
||||
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||
}
|
||||
|
||||
private static async Task<bool> IsLocalDbAvailableAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(LocalDbMaster);
|
||||
await connection.OpenAsync();
|
||||
return true;
|
||||
}
|
||||
catch (SqlException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DropDatabaseAsync(string dbName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(LocalDbMaster);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"""
|
||||
IF DB_ID(N'{dbName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{dbName}];
|
||||
END
|
||||
""";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup for ephemeral LocalDB databases.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
|
@ -20,8 +21,10 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
|
||||
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
|
||||
<ProjectReference Include="..\SeaHaven.DataServices\SeaHaven.DataServices.csproj" />
|
||||
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
151
SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
Normal file
151
SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardCancelServiceTests
|
||||
{
|
||||
private static (ApplicationDbContext Context, WorkOrderBoardCancelService Cancel, WorkOrderBoardUpdateService Update) CreateSut()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var cancel = new WorkOrderBoardCancelService(context, boardService, audit);
|
||||
var update = new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||
return (context, cancel, update);
|
||||
}
|
||||
|
||||
private static string ToVersion(WorkOrder workOrder)
|
||||
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_SetsLifecycleStatusCanceled()
|
||||
{
|
||||
var (context, cancel, _) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Status = "Incomplete",
|
||||
SiteCode = "BK5",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await cancel.CancelAsync(1, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
|
||||
Assert.Equal("Canceled", result.LifecycleStatusLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_IsIdempotentWhenAlreadyCanceled()
|
||||
{
|
||||
var (context, cancel, _) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Canceled,
|
||||
Status = "Canceled",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var before = await context.WorkOrderAuditLogs.CountAsync();
|
||||
var result = await cancel.CancelAsync(1, "actor-1");
|
||||
var after = await context.WorkOrderAuditLogs.CountAsync();
|
||||
|
||||
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
|
||||
Assert.Equal(before, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_BlocksWhenCompleted()
|
||||
{
|
||||
var (context, cancel, _) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Completed,
|
||||
Status = "Completed"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
|
||||
Assert.Equal("CancelNotAllowed", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_BlocksWhenClosedLabel()
|
||||
{
|
||||
var (context, cancel, _) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Completed,
|
||||
Status = "Closed"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
|
||||
Assert.Equal("CancelNotAllowed", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_WritesStatusChangedAudit()
|
||||
{
|
||||
var (context, cancel, _) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
Status = "Scheduled",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await cancel.CancelAsync(1, "actor-1");
|
||||
|
||||
var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged");
|
||||
Assert.Equal("Canceled", log.NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_AfterCancel_IsBlocked()
|
||||
{
|
||||
var (context, cancel, update) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Status = "Incomplete",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await cancel.CancelAsync(1, "actor-1");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "NEW",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||
}
|
||||
}
|
||||
184
SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
Normal file
184
SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardConcurrencyTests
|
||||
{
|
||||
private static ApplicationDbContext CreateContext(string dbName)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context)
|
||||
{
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
return new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParallelVendorAndStatusEdits_OnDifferentAggregates_Succeed()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var seedContext = CreateContext(dbName);
|
||||
seedContext.Vendors.Add(new Vendor { Id = 3, CompanyName = "Parallel Vendor" });
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
VendorId = 3,
|
||||
WorkOrderId = 1,
|
||||
RowVersion = new byte[] { 9, 0, 0, 0, 0, 0, 0, 9 }
|
||||
};
|
||||
seedContext.Dispatches.Add(dispatch);
|
||||
seedContext.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 }
|
||||
});
|
||||
await seedContext.SaveChangesAsync();
|
||||
|
||||
await using var woContext = CreateContext(dbName);
|
||||
await using var dispatchContext = CreateContext(dbName);
|
||||
var woService = CreateService(woContext);
|
||||
var dispatchService = CreateService(dispatchContext);
|
||||
|
||||
var wo = await woContext.workOrders.FindAsync(1);
|
||||
var disp = await dispatchContext.Dispatches.FindAsync(10);
|
||||
|
||||
var statusTask = woService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Scheduled",
|
||||
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
|
||||
}, "dispatcher-a");
|
||||
|
||||
var vendorTask = dispatchService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "3",
|
||||
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!),
|
||||
DispatchVersion = Convert.ToBase64String(disp!.RowVersion!),
|
||||
PrimaryDispatchId = 10
|
||||
}, "dispatcher-b");
|
||||
|
||||
await Task.WhenAll(statusTask, vendorTask);
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, statusTask.Result.LifecycleStatus);
|
||||
Assert.Equal(3, vendorTask.Result.VendorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleWorkOrderVersion_ThrowsConcurrencyWithCurrentState()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var context1 = CreateContext(dbName);
|
||||
context1.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
SiteCode = "A",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context1.SaveChangesAsync();
|
||||
|
||||
await using var context2 = CreateContext(dbName);
|
||||
var wo2 = await context2.workOrders.FirstAsync();
|
||||
wo2.SiteCode = "B";
|
||||
await context2.SaveChangesAsync();
|
||||
|
||||
await using var patchContext = CreateContext(dbName);
|
||||
var service = CreateService(patchContext);
|
||||
var staleVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "C",
|
||||
WorkOrderVersion = staleVersion
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.NotNull(ex.CurrentState);
|
||||
Assert.Equal("B", ex.CurrentState!.SiteCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RescheduleAuditCount_MatchesChangedFields()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var context = CreateContext(dbName);
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 6, 20),
|
||||
RescheduleCount = 0,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||
Value = "2026-06-25",
|
||||
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
|
||||
}, "actor-1");
|
||||
|
||||
var auditCount = await context.WorkOrderAuditLogs.CountAsync();
|
||||
Assert.Equal(2, auditCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondEditWithStaleVersion_FailsAfterFirstEdit()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
await using var seed = CreateContext(dbName);
|
||||
seed.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
SiteCode = "A",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 }
|
||||
});
|
||||
await seed.SaveChangesAsync();
|
||||
|
||||
var originalVersion = Convert.ToBase64String(new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 });
|
||||
|
||||
await using (var ctx1 = CreateContext(dbName))
|
||||
{
|
||||
var svc1 = CreateService(ctx1);
|
||||
await svc1.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "B",
|
||||
WorkOrderVersion = originalVersion
|
||||
}, "user-1");
|
||||
}
|
||||
|
||||
await using var ctx2 = CreateContext(dbName);
|
||||
var svc2 = CreateService(ctx2);
|
||||
await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
|
||||
svc2.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "C",
|
||||
WorkOrderVersion = originalVersion
|
||||
}, "user-2"));
|
||||
}
|
||||
}
|
||||
150
SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs
Normal file
150
SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardCreateRelationalTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_AuditAndFieldLocks_UseGeneratedWorkOrderId()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new SqliteBoardTestDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
const string actorId = "actor-1";
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = actorId,
|
||||
UserName = "actor-1",
|
||||
NormalizedUserName = "ACTOR-1",
|
||||
Email = "actor@test.local",
|
||||
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var service = new WorkOrderBoardCreateService(
|
||||
context,
|
||||
boardData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Description = "Relational create"
|
||||
}, actorId);
|
||||
|
||||
Assert.True(result.Id > 0);
|
||||
|
||||
var logs = await context.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
|
||||
var locks = await context.WorkOrderFieldLocks.AsNoTracking().ToListAsync();
|
||||
|
||||
Assert.NotEmpty(logs);
|
||||
Assert.NotEmpty(locks);
|
||||
Assert.Contains(logs, l => l.Action == "Create");
|
||||
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
|
||||
Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId));
|
||||
Assert.DoesNotContain(logs, l => l.WorkOrderId == 0);
|
||||
Assert.DoesNotContain(locks, l => l.WorkOrderId == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WhenContactPersistenceFails_RollsBackWorkOrder()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new SqliteBoardTestDbContext(options);
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
const string actorId = "actor-1";
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = actorId,
|
||||
UserName = "actor-1",
|
||||
NormalizedUserName = "ACTOR-1",
|
||||
Email = "actor@test.local",
|
||||
NormalizedEmail = "ACTOR@TEST.LOCAL"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var service = new WorkOrderBoardCreateService(
|
||||
context,
|
||||
boardData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Description = "Should roll back",
|
||||
PocContactId = 999_999
|
||||
}, actorId));
|
||||
|
||||
// Fresh context on the same connection so we observe committed state only.
|
||||
await using var verify = new SqliteBoardTestDbContext(options);
|
||||
Assert.Empty(await verify.workOrders.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.WorkOrderFieldLocks.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.WorkOrderContacts.AsNoTracking().ToListAsync());
|
||||
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
|
||||
}
|
||||
|
||||
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
// SQL Server filtered index syntax is invalid on SQLite.
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
|
||||
{
|
||||
if (index.GetFilter() != null)
|
||||
index.SetFilter(null);
|
||||
}
|
||||
|
||||
// SQLite has no rowversion type; treat as plain nullable blobs.
|
||||
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
|
||||
{
|
||||
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
|
||||
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
Normal file
183
SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardCreateServiceTests
|
||||
{
|
||||
private static (ApplicationDbContext Context, WorkOrderBoardCreateService Service) CreateSut()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var validator = new WorkOrderBoardCreateValidation();
|
||||
var service = new WorkOrderBoardCreateService(context, boardData, boardService, audit, validator);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Minimal_ReturnsIncomplete()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
||||
Assert.Equal("BK5", result.SiteCode);
|
||||
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.WoNumber));
|
||||
Assert.Equal(11, result.WoNumber!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithManualWoNumber_NormalizesTo11Digits()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WoNumber = "12345",
|
||||
WorkOrderType = WorkOrderType.PO,
|
||||
SiteCode = "DAL"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("00000012345", result.WoNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateWoNumber_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder { InternalWONumber = "00000099999", WorkerOrderNumber = "00000099999" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WoNumber = "99999",
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AutoSchedulesWhenDateAndAssignee()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
AssignTo = "dispatcher-1",
|
||||
ScheduledDate = new DateTime(2026, 6, 25)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WeekOnlyScheduling_SetsTargetWeek()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
ScheduleWeekOnly = true,
|
||||
TargetWeek = new DateOnly(2026, 6, 22)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.True(result.ScheduleWeekOnly);
|
||||
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WeekOnlyWithoutTargetWeek_ThrowsValidation()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
ScheduleWeekOnly = true
|
||||
}, "actor-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithVendor_CreatesPrimaryDispatch()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
VendorId = 5,
|
||||
ApptDate = new DateTime(2026, 6, 26)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(5, result.VendorId);
|
||||
Assert.NotNull(result.PrimaryDispatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_CreatesFieldLocksForPopulatedFields()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Description = "Test WO"
|
||||
}, "actor-1");
|
||||
|
||||
var locks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||
Assert.Contains(locks, l => l.FieldName == "SiteCode");
|
||||
Assert.Contains(locks, l => l.FieldName == "WorkOrderType");
|
||||
Assert.Contains(locks, l => l.FieldName == "Description");
|
||||
Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WritesCreateAuditEvent()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "actor-1");
|
||||
|
||||
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Contains(logs, l => l.Action == "Create");
|
||||
Assert.Contains(logs, l => l.Action == "FieldChanged" && l.FieldName == "SiteCode");
|
||||
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
|
||||
Assert.True(result.Id > 0);
|
||||
}
|
||||
}
|
||||
101
SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs
Normal file
101
SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardCreateSyncLockTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AfterShocCreate_SyncRejectsLockedSiteCode()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
context,
|
||||
boardData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "dispatcher-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync();
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
{
|
||||
WorkOrder = wo,
|
||||
FieldLocks = fieldLocks,
|
||||
Audit = audit
|
||||
};
|
||||
|
||||
var applied = await policy.TryApplyAsync(syncContext, "SiteCode", "SYNC-SITE");
|
||||
|
||||
Assert.False(applied);
|
||||
Assert.Equal("BK5", wo.SiteCode);
|
||||
|
||||
var rejected = await context.WorkOrderAuditLogs
|
||||
.SingleAsync(l => l.Action == "SyncRejected" && l.FieldName == "SiteCode");
|
||||
Assert.Equal("SYNC-SITE", rejected.NewValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AfterShocCreate_SyncCanUpdateUnlockedField_NotInShocSet()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
context,
|
||||
boardData,
|
||||
boardService,
|
||||
audit,
|
||||
new WorkOrderBoardCreateValidation());
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "dispatcher-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync();
|
||||
wo.Customer = "Original";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
{
|
||||
WorkOrder = wo,
|
||||
FieldLocks = fieldLocks,
|
||||
Audit = audit
|
||||
};
|
||||
|
||||
// Customer is not in SyncFieldMergePolicy apply switch — use Description which wasn't set on create
|
||||
var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync");
|
||||
|
||||
Assert.True(applied);
|
||||
Assert.Equal("From sync", wo.Description);
|
||||
}
|
||||
}
|
||||
65
SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
Normal file
65
SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardMutationRulesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(LifecycleStatus.Canceled, true)]
|
||||
[InlineData(LifecycleStatus.Completed, true)]
|
||||
[InlineData(LifecycleStatus.Scheduled, false)]
|
||||
[InlineData(LifecycleStatus.Incomplete, false)]
|
||||
public void IsReadOnly_ReflectsTerminalStatuses(LifecycleStatus status, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, WorkOrderBoardMutationRules.IsReadOnly(status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldBlockStatusChangeWhenPastDue_BlocksOnlyLifecycleStatus()
|
||||
{
|
||||
Assert.True(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
|
||||
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
|
||||
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee()
|
||||
{
|
||||
Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Incomplete,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_FalseWhenAlreadyScheduled()
|
||||
{
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Scheduled,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAutoSchedule_FalseWhenScheduleWeekOnly()
|
||||
{
|
||||
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||
LifecycleStatus.Incomplete,
|
||||
new DateTime(2026, 6, 25),
|
||||
"dispatcher-1",
|
||||
scheduleWeekOnly: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReschedule_WhenPreviousDateExistsAndChanges()
|
||||
{
|
||||
Assert.True(WorkOrderBoardMutationRules.IsReschedule(
|
||||
new DateTime(2026, 6, 20),
|
||||
new DateTime(2026, 6, 25)));
|
||||
Assert.False(WorkOrderBoardMutationRules.IsReschedule(
|
||||
null,
|
||||
new DateTime(2026, 6, 25)));
|
||||
}
|
||||
}
|
||||
240
SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
Normal file
240
SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardSearchFilterTests
|
||||
{
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("BK5", 1)]
|
||||
[InlineData("10000000001", 1)]
|
||||
[InlineData("HVAC Tech", 1)]
|
||||
[InlineData("Acme HVAC", 1)]
|
||||
[InlineData("sched", 1)]
|
||||
public async Task Apply_MatchesExpectedField(string search, int expectedId)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedSearchFixtures(context);
|
||||
|
||||
var ids = await WorkOrderBoardSearchFilter
|
||||
.Apply(context.workOrders.AsQueryable(), search)
|
||||
.Select(w => w.Id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Contains(expectedId, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeSearch_IgnoresSingleCharacter()
|
||||
{
|
||||
Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch("a"));
|
||||
Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch(" "));
|
||||
Assert.Equal("ab", WorkOrderBoardSearchFilter.NormalizeSearch(" ab "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_LifecycleLabel_MatchesScheduled()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await SeedSearchFixtures(context);
|
||||
|
||||
var ids = await WorkOrderBoardSearchFilter
|
||||
.Apply(context.workOrders.AsQueryable(), "scheduled")
|
||||
.Select(w => w.Id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(ids);
|
||||
Assert.Equal(1, ids[0]);
|
||||
}
|
||||
|
||||
private static async Task SeedSearchFixtures(ApplicationDbContext context)
|
||||
{
|
||||
context.Vendors.Add(new Vendor
|
||||
{
|
||||
Id = 1,
|
||||
CompanyName = "Acme HVAC",
|
||||
ContactName = "HVAC Tech",
|
||||
Phone = "555-0100"
|
||||
});
|
||||
context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1 });
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
SiteCode = "BK5",
|
||||
InternalWONumber = "10000000001",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderBoardSearchCountsTests
|
||||
{
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoardRows_SearchReducesScheduledAndKeepsTotal()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
SiteCode = "BK5",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
SiteCode = "BK6",
|
||||
ScheduledDate = new DateTime(2026, 6, 24),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 3,
|
||||
SiteCode = "BK5",
|
||||
ScheduledDate = null,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new WorkOrderBoardDataService(context);
|
||||
var result = await service.GetBoardRowsAsync(new SeaHaven.DataServices.Interfaces.WorkOrderBoardQuery(
|
||||
weekStart,
|
||||
weekStart.AddDays(4),
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
"BK5",
|
||||
null));
|
||||
|
||||
Assert.Equal(2, result.ScheduledTotalBeforeSearch);
|
||||
Assert.Single(result.ScheduledRows);
|
||||
Assert.Equal("BK5", result.ScheduledRows[0].SiteCode);
|
||||
Assert.Single(result.UnscheduledRows);
|
||||
Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderSearchDateRangeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_ThisWeek_UsesIsoMonday()
|
||||
{
|
||||
var reference = new DateOnly(2026, 6, 24);
|
||||
var range = SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve(
|
||||
SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||
null,
|
||||
null,
|
||||
reference);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 6, 22), range.From);
|
||||
Assert.Equal(new DateOnly(2026, 6, 28), range.To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Custom_RequiresDates()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve(
|
||||
SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
null,
|
||||
null));
|
||||
|
||||
Assert.Contains("dateFrom", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderAdvancedSearchServiceTests
|
||||
{
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_FiltersBySiteAndPaginates()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
SiteCode = "BK5",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000001"
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
SiteCode = "BK6",
|
||||
ScheduledDate = new DateTime(2026, 6, 24),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000002"
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = weekStart,
|
||||
DateTo = weekStart.AddDays(6),
|
||||
Sites = new List<string> { "BK5" },
|
||||
Page = 1,
|
||||
PageSize = 50
|
||||
}, null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal("BK5", result.Items.First().SiteCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_ClampsPageSizeToMax()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||
PageSize = 500
|
||||
}, null);
|
||||
|
||||
Assert.Equal(100, result.PageSize);
|
||||
}
|
||||
}
|
||||
210
SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
Normal file
210
SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardDataServiceTests
|
||||
{
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoardRows_ReturnsScheduledInWeekAndUnscheduled()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC", ContactName = "Tech", Phone = "555-0100" });
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
VendorId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
|
||||
});
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "10000000001",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
SiteCode = "BK5"
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
InternalWONumber = "10000000002",
|
||||
ScheduledDate = null,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
SiteCode = "BK6"
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 3,
|
||||
InternalWONumber = "10000000003",
|
||||
ScheduledDate = new DateTime(2026, 7, 1),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new WorkOrderBoardDataService(context);
|
||||
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
|
||||
weekStart,
|
||||
weekStart.AddDays(4),
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
|
||||
Assert.Single(result.ScheduledRows);
|
||||
Assert.Equal(1, result.ScheduledRows[0].Id);
|
||||
Assert.Equal("Acme HVAC", result.ScheduledRows[0].VendorName);
|
||||
Assert.Single(result.UnscheduledRows);
|
||||
Assert.Equal(2, result.UnscheduledRows[0].Id);
|
||||
Assert.Equal(1, result.ScheduledTotalBeforeSearch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoardRows_FiltersByDispatcher()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AssignTo = "user-a",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
AssignTo = "user-b",
|
||||
ScheduledDate = new DateTime(2026, 6, 24),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new WorkOrderBoardDataService(context);
|
||||
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
|
||||
weekStart,
|
||||
weekStart.AddDays(4),
|
||||
new[] { "user-a" },
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
|
||||
Assert.Single(result.ScheduledRows);
|
||||
Assert.Equal("user-a", result.ScheduledRows[0].AssignTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoardRows_MyWorkOrders_FiltersCurrentUser()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
AssignTo = "me",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
AssignTo = "other",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new WorkOrderBoardDataService(context);
|
||||
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
|
||||
weekStart,
|
||||
weekStart.AddDays(4),
|
||||
null,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
"me"));
|
||||
|
||||
Assert.Single(result.ScheduledRows);
|
||||
Assert.Equal(1, result.ScheduledRows[0].Id);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderBoardServiceTests
|
||||
{
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoardAsync_ComputesCountsAndPastDue()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "10000000001",
|
||||
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(dataService);
|
||||
|
||||
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = weekStart
|
||||
}, null);
|
||||
|
||||
Assert.Equal(1, response.Counts.Total);
|
||||
Assert.Single(response.Scheduled);
|
||||
Assert.True(response.Scheduled[0].IsPastDue);
|
||||
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = new DateOnly(2026, 6, 22),
|
||||
WeekEnd = new DateOnly(2026, 6, 15)
|
||||
}, null));
|
||||
}
|
||||
}
|
||||
521
SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
Normal file
521
SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderBoardUpdateServiceTests
|
||||
{
|
||||
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var service = new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
private static string ToVersion(WorkOrder workOrder)
|
||||
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||
|
||||
private static string ToVersion(Dispatch dispatch)
|
||||
=> Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 });
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_AutoSchedulesIncompleteToScheduled()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
AssignTo = "user-a",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||
Value = "2026-06-25",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_IncrementsRescheduleCount()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = new DateTime(2026, 6, 20),
|
||||
RescheduleCount = 1,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||
Value = "2026-06-25",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(2, result.RescheduleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_BlocksStatusChangeWhenPastDue()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||
Value = "Complete",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_BlocksEditWhenCanceled()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Canceled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK5",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_VendorCreatesPrimaryDispatch()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co" });
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "5",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.NotNull(result.PrimaryDispatchId);
|
||||
Assert.Equal(5, result.VendorId);
|
||||
Assert.Equal(1, await context.Dispatches.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_CreatesFieldLockAndAuditPerField()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
SiteCode = "OLD",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK5",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.Action == AuditActionType.FieldChanged.ToString()).ToListAsync());
|
||||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
SiteCode = "OLD",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK5",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
var updated = await context.workOrders.FindAsync(1);
|
||||
var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK6",
|
||||
WorkOrderVersion = ToVersion(updated!)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("BK6", secondResult.SiteCode);
|
||||
Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync());
|
||||
Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_NotFound_ThrowsNotFoundCode()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||
Value = "BK5",
|
||||
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_NormalizesWoNumber()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000009999",
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.WoNumber,
|
||||
Value = "12345",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("00000012345", result.WoNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_RejectsDuplicateWoNumber()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
|
||||
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.WoNumber,
|
||||
Value = "22222",
|
||||
WorkOrderVersion = ToVersion(wo!)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
ScheduleWeekOnly = true,
|
||||
TargetWeek = new DateOnly(2026, 6, 22),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.AssignTo,
|
||||
Value = "user-a",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
||||
Assert.Equal("user-a", result.DispatcherId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ApptTime_UpdatesScheduledStart()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptTime,
|
||||
Value = "09:00-10:00",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Contains("09:00", result.ApptTime);
|
||||
var reloaded = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded!.ScheduledStart);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ApptTime_DashPrefixedToken_TreatedAsSingleStart()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptTime,
|
||||
Value = "-30",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1");
|
||||
|
||||
var reloaded = await context.workOrders.FindAsync(1);
|
||||
Assert.NotNull(reloaded!.ScheduledStart);
|
||||
Assert.Equal(new DateTime(2026, 6, 25), reloaded.ScheduledEnd);
|
||||
Assert.Equal(new DateTime(2026, 6, 25).Add(TimeSpan.FromDays(30)), reloaded.ScheduledStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ApptTime_LocksScheduledStartAndEnd_ForSync()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
ScheduledDate = new DateTime(2026, 6, 25),
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptTime,
|
||||
Value = "09:00-10:00",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1");
|
||||
|
||||
var lockNames = await context.WorkOrderFieldLocks
|
||||
.Where(l => l.WorkOrderId == 1)
|
||||
.Select(l => l.FieldName)
|
||||
.ToListAsync();
|
||||
Assert.Contains(WorkOrderFieldNames.ScheduledStart, lockNames);
|
||||
Assert.Contains(WorkOrderFieldNames.ScheduledEnd, lockNames);
|
||||
Assert.DoesNotContain("ApptTime", lockNames);
|
||||
|
||||
var locks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, locks);
|
||||
var policy = new SyncFieldMergePolicy(locks);
|
||||
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
{
|
||||
WorkOrder = reloaded,
|
||||
FieldLocks = locks,
|
||||
Audit = audit
|
||||
};
|
||||
|
||||
var appliedStart = await policy.TryApplyAsync(
|
||||
syncContext, WorkOrderFieldNames.ScheduledStart, "2026-06-25T08:00:00");
|
||||
var appliedEnd = await policy.TryApplyAsync(
|
||||
syncContext, WorkOrderFieldNames.ScheduledEnd, "2026-06-25T11:00:00");
|
||||
|
||||
Assert.False(appliedStart);
|
||||
Assert.False(appliedEnd);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 9, 0, 0), reloaded.ScheduledStart);
|
||||
Assert.Equal(new DateTime(2026, 6, 25, 10, 0, 0), reloaded.ScheduledEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_DispatchFieldWithoutVersion_ThrowsDispatchVersionRequired()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ApptDate,
|
||||
Value = "2026-06-26",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("DispatchVersionRequired", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_VendorIdNotFound_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
VendorId = 1,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
};
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Vendor A" });
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.VendorId,
|
||||
Value = "999",
|
||||
WorkOrderVersion = ToVersion(wo),
|
||||
DispatchVersion = ToVersion(dispatch),
|
||||
PrimaryDispatchId = 10
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("VendorNotFound", ex.Code);
|
||||
}
|
||||
}
|
||||
347
SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
Normal file
347
SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderWeekRolledTests
|
||||
{
|
||||
private static readonly DateOnly SourceWeekStart = new(2026, 6, 16);
|
||||
private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20);
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context)
|
||||
{
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var audit = new WorkOrderAuditService(context, new WorkOrderFieldLockService(context));
|
||||
return new WorkOrderWeekRolledService(
|
||||
dataService,
|
||||
audit,
|
||||
NullLogger<WorkOrderWeekRolledService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(1, result.Processed);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
Assert.Equal(0, result.Failed);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_SkipsTerminalStatuses()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Completed
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Canceled
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(0, result.Processed);
|
||||
Assert.Equal(0, result.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_Idempotent_SecondRunSkips()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var first = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
var second = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(1, first.Processed);
|
||||
Assert.Equal(0, second.Processed);
|
||||
Assert.Equal(0, second.Failed);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_WritesAuditEvent()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.InProgress,
|
||||
CarriedOver = 2
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Single(logs);
|
||||
Assert.Equal("WeekRolled", logs[0].Action);
|
||||
Assert.Equal("System", logs[0].EventType);
|
||||
Assert.Equal("System", logs[0].ActorType);
|
||||
Assert.Equal("CarriedOver", logs[0].FieldName);
|
||||
Assert.Equal("2", logs[0].OldValue);
|
||||
Assert.Equal("3", logs[0].NewValue);
|
||||
Assert.Equal("week:2026-06-16", logs[0].CorrelationId);
|
||||
|
||||
var locks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||
Assert.Empty(locks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
|
||||
|
||||
var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
|
||||
var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
|
||||
|
||||
Assert.True(first.Processed);
|
||||
Assert.True(second.Skipped);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
|
||||
|
||||
await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
|
||||
|
||||
var service = CreateService(context);
|
||||
var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(0, rerun.Processed);
|
||||
Assert.Equal(0, rerun.Failed);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_ProcessesAllEligible_WhenMoreThanBatchSize()
|
||||
{
|
||||
// WorkOrderWeekRolledService.BatchSize is 500; more than one page must all be carried over.
|
||||
const int eligibleCount = 501;
|
||||
|
||||
await using var context = CreateContext();
|
||||
for (var i = 1; i <= eligibleCount; i++)
|
||||
{
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = i,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
}
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(eligibleCount, result.Processed);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
Assert.Equal(0, result.Failed);
|
||||
|
||||
var carriedOverCount = await context.workOrders.CountAsync(w => w.CarriedOver == 1);
|
||||
Assert.Equal(eligibleCount, carriedOverCount);
|
||||
|
||||
var ledgerCount = await context.WorkOrderWeekRolledLedgers.CountAsync();
|
||||
Assert.Equal(eligibleCount, ledgerCount);
|
||||
|
||||
var auditCount = await context.WorkOrderAuditLogs.CountAsync(l => l.Action == "WeekRolled");
|
||||
Assert.Equal(eligibleCount, auditCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PastDueCache_SyncsOperationalFlags()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var pastDate = DateTime.UtcNow.Date.AddDays(-3);
|
||||
var futureDate = DateTime.UtcNow.Date.AddDays(3);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = pastDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
ScheduledDate = pastDate,
|
||||
LifecycleStatus = LifecycleStatus.Completed,
|
||||
OperationalFlags = OperationalFlags.PastDue
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 3,
|
||||
ScheduledDate = futureDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.PastDue
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var result = await dataService.RefreshPastDueFlagsAsync(DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(1, result.SetCount);
|
||||
Assert.Equal(2, result.ClearedCount);
|
||||
|
||||
var wo1 = await context.workOrders.FindAsync(1);
|
||||
var wo2 = await context.workOrders.FindAsync(2);
|
||||
var wo3 = await context.workOrders.FindAsync(3);
|
||||
|
||||
Assert.True(wo1!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
|
||||
Assert.False(wo2!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
|
||||
Assert.False(wo3!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Board_ReflectsCarriedOver_AfterWeekRolled()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal(1, row!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Board_IsPastDue_UnaffectedByCache()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var pastDate = DateTime.UtcNow.Date.AddDays(-2);
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = pastDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.None
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.True(row!.IsPastDue);
|
||||
Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Board_IsPastDue_IgnoresStalePastDueFlag()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var futureDate = DateTime.UtcNow.Date.AddDays(5);
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = futureDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.PastDue
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.False(row!.IsPastDue);
|
||||
}
|
||||
}
|
||||
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).
|
||||
40
scripts/load-test/README.md
Normal file
40
scripts/load-test/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Load Test — Work Order Search (Tier S)
|
||||
|
||||
Harness k6 para validar SLOs da Fase 4 em ambiente local ou staging.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- [k6](https://k6.io/docs/get-started/installation/) instalado
|
||||
- API rodando com JWT válido
|
||||
- (Opcional) Seed sintético: `scripts/seed-work-orders-search.ps1 -Count 15000`
|
||||
|
||||
## Variáveis de ambiente
|
||||
|
||||
| Variável | Descrição | Default |
|
||||
|----------|-----------|---------|
|
||||
| `BASE_URL` | URL base da API | `https://localhost:5001` |
|
||||
| `JWT` | Bearer token | _(obrigatório)_ |
|
||||
| `WEEK_START` | Segunda-feira ISO para board | `2026-06-22` |
|
||||
|
||||
## Execução
|
||||
|
||||
```powershell
|
||||
$env:JWT = "<seu-token>"
|
||||
$env:BASE_URL = "https://localhost:5001"
|
||||
k6 run scripts/load-test/work-order-search.k6.js
|
||||
```
|
||||
|
||||
## SLOs Tier S (p95)
|
||||
|
||||
| Métrica | Limite |
|
||||
|---------|--------|
|
||||
| `board_no_search` | < 800ms |
|
||||
| `board_with_search` | < 1000ms |
|
||||
| `advanced_preset` | < 1200ms |
|
||||
| `advanced_multi_filter` | < 1500ms |
|
||||
|
||||
## Staging (pós Volume Discovery)
|
||||
|
||||
1. Executar `scripts/volume-discovery.ps1`
|
||||
2. Confirmar tier em `docs/work-orders/phase-4/search-tier-decision.md`
|
||||
3. Repetir k6 com volume real; ajustar thresholds se tier = M
|
||||
60
scripts/load-test/work-order-search.k6.js
Normal file
60
scripts/load-test/work-order-search.k6.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { Trend } from 'k6/metrics';
|
||||
|
||||
const boardNoSearch = new Trend('board_no_search', true);
|
||||
const boardWithSearch = new Trend('board_with_search', true);
|
||||
const advancedPreset = new Trend('advanced_preset', true);
|
||||
const advancedMultiFilter = new Trend('advanced_multi_filter', true);
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'https://localhost:5001';
|
||||
const JWT = __ENV.JWT || '';
|
||||
const WEEK_START = __ENV.WEEK_START || '2026-06-22';
|
||||
|
||||
export const options = {
|
||||
vus: 10,
|
||||
duration: '30s',
|
||||
thresholds: {
|
||||
board_no_search: ['p(95)<800'],
|
||||
board_with_search: ['p(95)<1000'],
|
||||
advanced_preset: ['p(95)<1200'],
|
||||
advanced_multi_filter: ['p(95)<1500'],
|
||||
},
|
||||
};
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${JWT}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const boardRes = http.get(
|
||||
`${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}`,
|
||||
{ headers }
|
||||
);
|
||||
boardNoSearch.add(boardRes.timings.duration);
|
||||
check(boardRes, { 'board 200': (r) => r.status === 200 });
|
||||
|
||||
const searchRes = http.get(
|
||||
`${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}&search=BK5`,
|
||||
{ headers }
|
||||
);
|
||||
boardWithSearch.add(searchRes.timings.duration);
|
||||
check(searchRes, { 'board search 200': (r) => r.status === 200 });
|
||||
|
||||
const advRes = http.get(
|
||||
`${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&page=1&pageSize=50`,
|
||||
{ headers }
|
||||
);
|
||||
advancedPreset.add(advRes.timings.duration);
|
||||
check(advRes, { 'advanced preset 200': (r) => r.status === 200 });
|
||||
|
||||
const multiRes = http.get(
|
||||
`${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&sites=BK5&types=PM&search=HVAC&page=1`,
|
||||
{ headers }
|
||||
);
|
||||
advancedMultiFilter.add(multiRes.timings.duration);
|
||||
check(multiRes, { 'advanced multi 200': (r) => r.status === 200 });
|
||||
|
||||
sleep(0.5);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue