mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
96 commits
a9a98ddc78
...
bcb1c16d0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb1c16d0c | ||
|
|
656d1ae233 | ||
|
|
332b7e713e | ||
|
|
5c6553f230 | ||
|
|
7d471c9895 | ||
|
|
df291ea9a6 | ||
|
|
f9f2bd9d3f | ||
|
|
638eccd46c | ||
|
|
8be07a9c5f | ||
|
|
78abf84b0d | ||
|
|
e5cf4cec09 | ||
|
|
0e87817416 | ||
|
|
5fa9b4e344 | ||
|
|
917bad3ee1 | ||
|
|
a9dee0bb84 | ||
|
|
8192da7790 | ||
|
|
df828cf48c | ||
|
|
3bd0268129 | ||
|
|
782d0e8ae9 | ||
|
|
1ede0a5ece | ||
|
|
af724ff625 | ||
|
|
b1421de0b5 | ||
|
|
8134c0b77f | ||
|
|
93ef4f578b | ||
|
|
7459dca3c6 | ||
|
|
bdd566de83 | ||
|
|
31675e49dd | ||
|
|
edcdc10395 | ||
|
|
54cd24acf4 | ||
|
|
11fed06f03 | ||
|
|
39829f7274 | ||
|
|
4863d1fdaf | ||
|
|
385812c5c3 | ||
|
|
033db6f203 | ||
|
|
f4985378fc | ||
|
|
8c4102b1cf | ||
|
|
af1c0d19b4 | ||
|
|
1ff29c234e | ||
|
|
cc37a0283b | ||
|
|
40d86c85a1 | ||
|
|
33574c233b | ||
|
|
75969a1e3d | ||
|
|
61b4c84337 | ||
|
|
f3d2a5cfe7 | ||
|
|
76db2b5a37 | ||
|
|
4bd11dcf6b | ||
|
|
d59f92910e | ||
|
|
c722d37a8f | ||
|
|
fa1eca8f89 | ||
|
|
a80a71b16a | ||
|
|
62572d8e03 | ||
|
|
ecc1db3726 | ||
|
|
3983f7667d | ||
|
|
3e2952f8a0 | ||
|
|
2c26ead193 | ||
|
|
4355c07e45 | ||
|
|
5d2a2d0ed0 | ||
|
|
ec6d69a933 | ||
|
|
03dc04ad0b | ||
|
|
64ca9e0bdf | ||
|
|
89cb520db9 | ||
|
|
1074ed860b | ||
|
|
e67254ed7e | ||
|
|
f8511ce732 | ||
|
|
af84001bf1 | ||
|
|
0ce008e5eb | ||
|
|
812a5e994c | ||
|
|
8a5cb0cc90 | ||
|
|
ceeb0c8fa3 | ||
|
|
3122cbc8f7 | ||
|
|
bdfaf1ff61 | ||
|
|
cc8825ace0 | ||
|
|
f977483c17 | ||
|
|
dd2a805c24 | ||
|
|
0ddc43210a | ||
|
|
9dc17d4255 | ||
|
|
88f9245fbf | ||
|
|
8c33eb7f9a | ||
|
|
25eb6293e0 | ||
|
|
c2d0bd45e3 | ||
|
|
83272e4957 | ||
|
|
88caede86a | ||
|
|
eb64c2f55c | ||
|
|
4617e8ba83 | ||
|
|
e5bda0714a | ||
|
|
91122d753d | ||
|
|
673bc3b5a9 | ||
|
|
d040832b87 | ||
|
|
8efe4460bb | ||
|
|
ff53cb9fa6 | ||
|
|
4f697f257a | ||
|
|
65c1cdc7e9 | ||
|
|
5d5b3234c4 | ||
|
|
f52c430c2c | ||
|
|
35b3bba596 | ||
|
|
456bee2226 |
187 changed files with 27047 additions and 459 deletions
17
.env.example
17
.env.example
|
|
@ -1,11 +1,11 @@
|
|||
# Sea Haven Industries — shoc-backend required configuration
|
||||
# Sea Haven Industries ÔÇö shoc-backend required configuration
|
||||
#
|
||||
# This file documents the secrets that used to be hardcoded in appsettings*.json
|
||||
# and source files. Copy the values into one of the supported configuration sources;
|
||||
# do NOT commit real values.
|
||||
#
|
||||
# .NET resolves configuration in this order (later wins):
|
||||
# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only)
|
||||
# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only)
|
||||
# 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value"
|
||||
# 3. Environment variables (use "__" as the section separator)
|
||||
#
|
||||
|
|
@ -32,3 +32,16 @@ GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
# AWS_REGION=us-east-2
|
||||
|
||||
# --- Work Order ingest (Lambda cutover) ---
|
||||
# Leave Enabled=false until ApiKey is a real secret (min 32 chars). Do not use appsettings placeholders.
|
||||
WorkOrderIngest__Enabled=false
|
||||
WorkOrderIngest__ApiKey=<shared-secret-for-X-Ingest-Key-min-32-chars>
|
||||
|
||||
# --- Dynamo sync bridge (keep false after Lambda cutover; avoid dual-run with ingest) ---
|
||||
Sync__Enabled=true
|
||||
|
||||
# --- Legacy endpoint deprecation headers ---
|
||||
LegacyEndpoints__DeprecationEnabled=false
|
||||
LegacyEndpoints__SunsetDate=2026-12-31
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ public class AuthenticationServiceTests
|
|||
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
|
||||
store = s;
|
||||
hasher = h;
|
||||
return new AuthenticationService(manager, Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
|
||||
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
|
||||
}
|
||||
|
||||
private static JwtOptions JwtOptions => new()
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ public class DispatchBatchingBehaviorTests
|
|||
Mock.Of<ICommentDataService>(),
|
||||
emailPort.Object,
|
||||
tokenPort.Object,
|
||||
Options.Create(new FrontendOptions()));
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
|
||||
}
|
||||
|
||||
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) =>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ internal static class IdentityTestHelpers
|
|||
|
||||
var manager = new UserManager<ApplicationUser>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
|
||||
hasher.Object,
|
||||
new List<IUserValidator<ApplicationUser>>(),
|
||||
new List<IPasswordValidator<ApplicationUser>>(),
|
||||
|
|
|
|||
110
Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs
Normal file
110
Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class LocationControllerSitesTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static LocationController NewController(ApplicationDbContext ctx)
|
||||
{
|
||||
var dataService = new LocationDataService(ctx);
|
||||
var service = new LocationService(
|
||||
dataService,
|
||||
Mock.Of<ICreateLocationValidation>(),
|
||||
Mock.Of<IUpdateLocationValidation>());
|
||||
|
||||
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static async Task SeedSitesAsync(ApplicationDbContext ctx)
|
||||
{
|
||||
ctx.Locations.AddRange(
|
||||
new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" },
|
||||
new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" },
|
||||
new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" },
|
||||
new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedSitesAsync(ctx);
|
||||
var controller = NewController(ctx);
|
||||
|
||||
var result = await controller.GetSites();
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
|
||||
|
||||
data.Should().HaveCount(4);
|
||||
data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8");
|
||||
data[0].Should().BeEquivalentTo(new SiteOptionDTO
|
||||
{
|
||||
Id = 2,
|
||||
Code = "BK5",
|
||||
City = "Dallas",
|
||||
State = "TX"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSites_FiltersByCode()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedSitesAsync(ctx);
|
||||
var controller = NewController(ctx);
|
||||
|
||||
var result = await controller.GetSites("bk5");
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
|
||||
|
||||
data.Should().ContainSingle();
|
||||
data[0].Code.Should().Be("BK5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSites_FiltersByCity()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
await SeedSitesAsync(ctx);
|
||||
var controller = NewController(ctx);
|
||||
|
||||
var result = await controller.GetSites("Dallas");
|
||||
|
||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
|
||||
|
||||
data.Should().HaveCount(2);
|
||||
data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8");
|
||||
}
|
||||
}
|
||||
|
|
@ -210,7 +210,7 @@ public class WorkOrderDispatchServiceBehaviorTests
|
|||
Mock.Of<ICommentDataService>(),
|
||||
Mock.Of<IDispatchEmailPort>(),
|
||||
Mock.Of<IVendorTokenPort>(),
|
||||
Options.Create(new FrontendOptions()));
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
319
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
319
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
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 SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.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 dataService = new VendorOperationsDataService(context, new DispatchDataService(context));
|
||||
var service = new VendorOperationsService(dataService, TimeProvider.System);
|
||||
var controller = new VendorOperationsController(service);
|
||||
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(CancellationToken.None);
|
||||
|
||||
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(CancellationToken.None);
|
||||
|
||||
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",
|
||||
CancellationToken.None);
|
||||
|
||||
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<SitePreferenceRequest>
|
||||
{
|
||||
new() { VendorId = inactive.Id }
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
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, CancellationToken.None);
|
||||
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null, CancellationToken.None);
|
||||
|
||||
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 VendorAssignmentRequest { VendorId = vendor.Id },
|
||||
CancellationToken.None);
|
||||
|
||||
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 VendorAssignmentRequest
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
ScheduledStart = assignedWorkOrder.ScheduledStart,
|
||||
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
|
||||
EstimatedArrivalAt = eta,
|
||||
EtaManualOverride = true
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
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 VendorAssignmentRequest
|
||||
{
|
||||
VendorId = replacementVendor.Id,
|
||||
ScheduledStart = workOrder.ScheduledStart,
|
||||
ScheduledEnd = workOrder.ScheduledEnd
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.Dispatches.Should().ContainSingle();
|
||||
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
|
||||
}
|
||||
}
|
||||
179
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
179
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Api.SeaHavenIndustries.Infrastructure;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
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 vendorData = new VendorDataService(context);
|
||||
var tokenService = new VendorPortalTokenService(
|
||||
vendorData,
|
||||
Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
|
||||
var documentData = new VendorDocumentDataService(context);
|
||||
var storageEnvironment = new Mock<IWebHostEnvironment>();
|
||||
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
|
||||
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
|
||||
|
||||
var service = new VendorPortalService(
|
||||
tokenService,
|
||||
new DispatchDataService(context),
|
||||
Mock.Of<IUpliftDataService>(),
|
||||
Mock.Of<ICommentDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<IEmailSender>(),
|
||||
documentData,
|
||||
storage,
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
|
||||
|
||||
var controller = new VendorPortalController(service, Mock.Of<ILogger<VendorPortalController>>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ public class VendorServiceTests
|
|||
data.Object,
|
||||
Mock.Of<IVendorPortalTokenService>(),
|
||||
Mock.Of<IZipCodeDistance>(),
|
||||
Options.Create(new FrontendOptions()),
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
new CreateVendorValidation(),
|
||||
new UpdateVendorValidation(),
|
||||
new WorkOrderVendorUpdateValidation());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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 WorkOrderBoardController NewController(IWorkOrderAdvancedSearchService advancedSearch)
|
||||
{
|
||||
var controller = new WorkOrderBoardController(
|
||||
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()
|
||||
{
|
||||
var advancedSearch = new WorkOrderAdvancedSearchService(
|
||||
Mock.Of<IWorkOrderAdvancedSearchDataService>());
|
||||
var controller = NewController(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");
|
||||
}
|
||||
}
|
||||
206
Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs
Normal file
206
Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ActionConstraints;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Framework-backed route contract tests that prove the public endpoint set exposed by the
|
||||
/// WorkOrder URL space (api/WorkOrder, api/workorders) is preserved exactly after splitting the
|
||||
/// original godfile WorkOrderController into cohesive controllers, and that no verb+route
|
||||
/// combination is registered more than once by different actions.
|
||||
///
|
||||
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
|
||||
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder,
|
||||
/// GetWorkorderById) and the two shared controller-level base routes.
|
||||
/// </summary>
|
||||
public class WorkOrderRouteContractTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
private static readonly HashSet<Type> WorkOrderFamilyControllerTypes = new()
|
||||
{
|
||||
typeof(WorkOrderController),
|
||||
typeof(WorkOrderBoardController),
|
||||
typeof(WorkOrderDetailController),
|
||||
typeof(WorkOrderCompletionController),
|
||||
typeof(WorkOrderMediaController),
|
||||
typeof(WorkOrderDispatchController),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Baseline public endpoint set (verb + action-relative route) that the original single
|
||||
/// WorkOrderController exposed. Every action is reachable under both api/WorkOrder and
|
||||
/// api/workorders; that base-route duplication is collapsed here, so this is the distinct
|
||||
/// action-relative contract. 45 routes come from 43 actions (Editworkorder and
|
||||
/// GetWorkorderById each bind two routes).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||
{
|
||||
"DELETE DeleteWorkorder",
|
||||
"DELETE completion-templates/{id:int}",
|
||||
"DELETE {id:int}/media/{mediaId:int}",
|
||||
"GET GetComments",
|
||||
"GET GetCommentsByWorkorderId",
|
||||
"GET GetDispatch/{id}",
|
||||
"GET GetDispatches/{workOrderId}",
|
||||
"GET GetFilteredWorkorder",
|
||||
"GET GetFilteredWorkorder2",
|
||||
"GET GetWorkOrderList",
|
||||
"GET GetWorkorderById",
|
||||
"GET Getworkorders",
|
||||
"GET GetworkordersDD",
|
||||
"GET board",
|
||||
"GET board/search",
|
||||
"GET completion-templates",
|
||||
"GET completion-templates/{id:int}",
|
||||
"GET lookups/dispatchers",
|
||||
"GET {id:int}",
|
||||
"GET {id:int}/audit",
|
||||
"GET {id:int}/comments",
|
||||
"GET {id:int}/detail",
|
||||
"GET {id:int}/media",
|
||||
"PATCH {id:int}/board",
|
||||
"POST AddChecklistItem",
|
||||
"POST AddComment",
|
||||
"POST AddCommentJson",
|
||||
"POST AddDispatchComment",
|
||||
"POST AddDispatchSignoff",
|
||||
"POST AddWorkorder",
|
||||
"POST ChangeAssignment",
|
||||
"POST ChangeStatus",
|
||||
"POST DispatchToVendor",
|
||||
"POST EditWorkorder",
|
||||
"POST UpdateChecklistItem",
|
||||
"POST UpdateDispatch",
|
||||
"POST VerifyDispatch",
|
||||
"POST board",
|
||||
"POST completion-templates",
|
||||
"POST {id:int}/cancel",
|
||||
"POST {id:int}/comments",
|
||||
"POST {id:int}/completion-doc",
|
||||
"POST {id:int}/media",
|
||||
"PUT completion-templates/{id:int}",
|
||||
"PUT {id:int}",
|
||||
};
|
||||
|
||||
public WorkOrderRouteContractTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionDescriptor> BuildActionDescriptors()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddMvcCore()
|
||||
.AddApplicationPart(typeof(WorkOrderController).Assembly);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var actionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
|
||||
return actionProvider.ActionDescriptors.Items;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor(
|
||||
Func<ActionDescriptor, bool> predicate)
|
||||
{
|
||||
var tuples = new List<(string Verb, string FullTemplate, string Controller, string Action)>();
|
||||
foreach (var action in BuildActionDescriptors().Where(predicate))
|
||||
{
|
||||
var cad = action as ControllerActionDescriptor;
|
||||
var template = cad?.AttributeRouteInfo?.Template?.Trim('/');
|
||||
if (string.IsNullOrEmpty(template))
|
||||
continue;
|
||||
|
||||
var methods = action.ActionConstraints
|
||||
.OfType<HttpMethodActionConstraint>()
|
||||
.SelectMany(c => c.HttpMethods)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var method in methods)
|
||||
tuples.Add((method.ToUpperInvariant(), template, cad!.ControllerName, cad.ActionName));
|
||||
}
|
||||
|
||||
return tuples;
|
||||
}
|
||||
|
||||
private static string Normalize(string fullTemplate)
|
||||
{
|
||||
foreach (var prefix in new[] { "api/WorkOrder/", "api/workorders/" })
|
||||
{
|
||||
if (fullTemplate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return fullTemplate[prefix.Length..];
|
||||
}
|
||||
foreach (var exact in new[] { "api/WorkOrder", "api/workorders" })
|
||||
{
|
||||
if (fullTemplate.Equals(exact, StringComparison.OrdinalIgnoreCase))
|
||||
return string.Empty;
|
||||
}
|
||||
return fullTemplate;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkOrder_Family_Preserves_Expected_Public_Endpoint_Set()
|
||||
{
|
||||
var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad
|
||||
&& WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo))
|
||||
.Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}")
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
Dump(actual);
|
||||
|
||||
var missing = ExpectedWorkOrderEndpoints.Except(actual).OrderBy(x => x).ToList();
|
||||
var unexpected = actual.Except(ExpectedWorkOrderEndpoints).OrderBy(x => x).ToList();
|
||||
|
||||
missing.Should().BeEmpty("every pre-split WorkOrder endpoint must still be reachable");
|
||||
unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints");
|
||||
actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count,
|
||||
"the distinct action-relative endpoint count must match the baseline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkOrder_Family_Has_No_Duplicate_Verb_And_Route_From_Different_Actions()
|
||||
{
|
||||
// Duplicate detection is done at the FULL route-template level. Each action legitimately
|
||||
// resolves under both base routes (api/WorkOrder and api/workorders); those are distinct
|
||||
// full templates. A true collision is the SAME full template produced by two actions,
|
||||
// which would cause an AmbiguousMatchException at request time.
|
||||
var collisions = FullRoutesFor(a => a is ControllerActionDescriptor cad
|
||||
&& WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo))
|
||||
.GroupBy(t => (t.Verb, t.FullTemplate))
|
||||
.Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1)
|
||||
.Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}")
|
||||
.ToList();
|
||||
|
||||
collisions.Should().BeEmpty(
|
||||
"no two actions in the WorkOrder family may register the same verb+full-route");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkOrder_Url_Space_Has_No_Duplicate_Verb_And_Route_From_Different_Actions()
|
||||
{
|
||||
var collisions = FullRoutesFor(_ => true)
|
||||
.Where(t => t.FullTemplate.StartsWith("api/WorkOrder", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.FullTemplate.StartsWith("api/workorders", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(t => (t.Verb, t.FullTemplate))
|
||||
.Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1)
|
||||
.Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}")
|
||||
.ToList();
|
||||
|
||||
collisions.Should().BeEmpty(
|
||||
"no two controllers sharing api/WorkOrder or api/workorders may register the same verb+full-route");
|
||||
}
|
||||
|
||||
private void Dump(IEnumerable<string> endpoints)
|
||||
{
|
||||
_output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):");
|
||||
foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal))
|
||||
_output.WriteLine(" " + endpoint);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("sites")]
|
||||
public async Task<IActionResult> GetSites(string? search = null)
|
||||
{
|
||||
var data = await _locationService.GetSiteOptionsAsync(search);
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("GetLocationList")]
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,24 +1,42 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class SyncController : Controller
|
||||
{
|
||||
private readonly ISyncService _syncService;
|
||||
private readonly SyncOptions _syncOptions;
|
||||
|
||||
public SyncController(ISyncService syncService)
|
||||
public SyncController(ISyncService syncService, IOptions<SyncOptions> syncOptions)
|
||||
{
|
||||
_syncService = syncService;
|
||||
_syncOptions = syncOptions.Value;
|
||||
}
|
||||
|
||||
private IActionResult? SyncDisabledResult()
|
||||
{
|
||||
if (_syncOptions.Enabled)
|
||||
return null;
|
||||
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
|
||||
{
|
||||
message = "DynamoDB sync bridge is disabled. Use POST /api/workorders/ingest."
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("WorkOrders")]
|
||||
public async Task<IActionResult> SyncWorkOrders(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var result = await _syncService.SyncWorkOrdersAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -26,6 +44,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("Comments")]
|
||||
public async Task<IActionResult> SyncComments(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var result = await _syncService.SyncCommentsAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -33,6 +54,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("BackfillInternalWONumbers")]
|
||||
public async Task<IActionResult> BackfillInternalWONumbers(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -40,6 +64,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("BackfillDispatchNumbers")]
|
||||
public async Task<IActionResult> BackfillDispatchNumbers(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -47,6 +74,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("VendorReplies")]
|
||||
public async Task<IActionResult> SyncVendorReplies(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var result = await _syncService.SyncVendorRepliesAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -54,6 +84,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("BackfillCommentTypes")]
|
||||
public async Task<IActionResult> BackfillCommentTypes(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var result = await _syncService.BackfillCommentTypesAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
@ -61,6 +94,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("All")]
|
||||
public async Task<IActionResult> SyncAll(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var result = await _syncService.SyncAllAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
|
|
|||
210
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
210
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
using System.Text;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/vendor-operations")]
|
||||
public class VendorOperationsController : ControllerBase
|
||||
{
|
||||
private readonly IVendorOperationsService _service;
|
||||
|
||||
public VendorOperationsController(IVendorOperationsService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
private IActionResult Map<T>(VendorOperationOutcome<T> outcome) => outcome.Kind switch
|
||||
{
|
||||
VendorOperationOutcomeKind.Ok => Ok(outcome.Value),
|
||||
VendorOperationOutcomeKind.NotFound => outcome.Message is null
|
||||
? NotFound()
|
||||
: NotFound(new Response { Status = "Error", Message = outcome.Message }),
|
||||
VendorOperationOutcomeKind.BadRequest => BadRequest(new Response { Status = "Error", Message = outcome.Message }),
|
||||
VendorOperationOutcomeKind.Conflict => Conflict(new Response { Status = "Conflict", Message = outcome.Message }),
|
||||
_ => StatusCode(StatusCodes.Status500InternalServerError)
|
||||
};
|
||||
|
||||
[HttpGet("notifications")]
|
||||
public async Task<IActionResult> GetNotifications(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _service.GetNotificationsAsync(cancellationToken);
|
||||
return Ok(new { result.GeneratedAt, result.Items });
|
||||
}
|
||||
|
||||
[HttpGet("availability")]
|
||||
public async Task<IActionResult> GetAvailability(
|
||||
[FromQuery] DateTime? start,
|
||||
[FromQuery] DateTime? end,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _service.GetAvailabilityAsync(start, end, locationId, trade, cancellationToken);
|
||||
return Map(outcome);
|
||||
}
|
||||
|
||||
[HttpPut("vendors/{vendorId:int}/availability")]
|
||||
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] VendorAvailabilityRequest body, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _service.UpdateAvailabilityAsync(vendorId, body?.Status, Actor(), cancellationToken);
|
||||
return Map(outcome);
|
||||
}
|
||||
|
||||
[HttpGet("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> GetSitePreferences(int locationId, CancellationToken cancellationToken)
|
||||
{
|
||||
var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken);
|
||||
return Ok(preferences);
|
||||
}
|
||||
|
||||
[HttpPut("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List<SitePreferenceRequest>(), Actor(), cancellationToken);
|
||||
return Map(outcome);
|
||||
}
|
||||
|
||||
[HttpPut("documents/{documentId:int}/review")]
|
||||
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _service.ReviewDocumentAsync(documentId, body ?? new DocumentReviewRequest(), Actor(), cancellationToken);
|
||||
return Map(outcome);
|
||||
}
|
||||
|
||||
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
|
||||
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _service.UpdateCommercialStatusAsync(dispatchId, body ?? new CommercialStatusRequest(), Actor(), cancellationToken);
|
||||
return Map(outcome);
|
||||
}
|
||||
|
||||
[HttpPut("work-orders/{workOrderId:int}/assignment")]
|
||||
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await _service.AssignVendorAsync(workOrderId, body ?? new VendorAssignmentRequest(), Actor(), cancellationToken);
|
||||
return Map(outcome);
|
||||
}
|
||||
|
||||
[HttpGet("insights")]
|
||||
public async Task<IActionResult> GetInsights(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = _service.UtcNow,
|
||||
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,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
|
||||
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-{_service.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,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
|
||||
var lines = new List<string>
|
||||
{
|
||||
"Vendor Performance Insights",
|
||||
$"Generated: {_service.UtcNow: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-{_service.UtcNow:yyyyMMddHHmmss}.pdf");
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
|
@ -226,6 +227,43 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/completion-documents")]
|
||||
[RequestSizeLimit(10_000_000)]
|
||||
public async Task<IActionResult> UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken);
|
||||
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The completion document could not be uploaded") });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")]
|
||||
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await ResolveSessionAsync(cancellationToken);
|
||||
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
|
||||
var result = await _portalService.DownloadCompletionDocumentAsync(session, id, documentId, cancellationToken);
|
||||
return result.Outcome switch
|
||||
{
|
||||
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
|
||||
VendorDocumentDownloadOutcome.Locked => StatusCode(StatusCodes.Status423Locked, new Response { Status = "Locked", Message = "The completion document is not available for download yet." }),
|
||||
_ => NotFound(new Response { Status = "Error", Message = "Completion document not found" })
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||
|
|
|
|||
216
Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs
Normal file
216
Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/WorkOrder")]
|
||||
[Route("api/workorders")]
|
||||
public class WorkOrderBoardController : Controller
|
||||
{
|
||||
private readonly IWorkOrderBoardService _workOrderBoardService;
|
||||
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
|
||||
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
||||
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
||||
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
|
||||
|
||||
public WorkOrderBoardController(
|
||||
IWorkOrderBoardService workOrderBoardService,
|
||||
IWorkOrderBoardUpdateService boardUpdateService,
|
||||
IWorkOrderBoardCreateService boardCreateService,
|
||||
IWorkOrderBoardCancelService boardCancelService,
|
||||
IWorkOrderAdvancedSearchService advancedSearchService)
|
||||
{
|
||||
_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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/WorkOrder")]
|
||||
[Route("api/workorders")]
|
||||
public class WorkOrderCompletionController : Controller
|
||||
{
|
||||
private readonly IWorkOrderCompletionService _workOrderCompletionService;
|
||||
private readonly IFileStoragePort _fileStorage;
|
||||
|
||||
public WorkOrderCompletionController(
|
||||
IWorkOrderCompletionService workOrderCompletionService,
|
||||
IFileStoragePort fileStorage)
|
||||
{
|
||||
_workOrderCompletionService = workOrderCompletionService;
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
[HttpGet("completion-templates")]
|
||||
public async Task<IActionResult> GetCompletionTemplates(
|
||||
[FromQuery] string? serviceKey = null,
|
||||
[FromQuery] WorkOrderType? workOrderType = null)
|
||||
{
|
||||
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
|
||||
return Ok(templates);
|
||||
}
|
||||
|
||||
[HttpGet("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> GetCompletionTemplate(int id)
|
||||
{
|
||||
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
|
||||
if (template == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Template not found." });
|
||||
return Ok(template);
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("completion-templates")]
|
||||
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
|
||||
return Ok(created);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
|
||||
return Ok(updated);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> DeleteCompletionTemplate(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _workOrderCompletionService.DeleteTemplateAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/completion-doc")]
|
||||
[RequestSizeLimit(30_000_000)]
|
||||
public async Task<IActionResult> UploadCompletionDoc(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] string? signOffName = null,
|
||||
[FromForm] string? signOffSignature = null,
|
||||
[FromForm] string? workOrderVersion = null)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
||||
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var fileUrl = await _fileStorage.SaveFileAsync(file);
|
||||
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
|
||||
id,
|
||||
new WorkOrderCompletionDocUploadDto
|
||||
{
|
||||
SignOffName = signOffName,
|
||||
SignOffSignature = signOffSignature,
|
||||
WorkOrderVersion = workOrderVersion
|
||||
},
|
||||
fileUrl,
|
||||
actorId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
|
||||
{
|
||||
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ using FluentValidation;
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -19,16 +19,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
public class WorkOrderController : Controller
|
||||
{
|
||||
private readonly IWorkOrderService _workOrderService;
|
||||
private readonly IWorkOrderDispatchService _dispatchService;
|
||||
private readonly ILogger<WorkOrderController> _logger;
|
||||
|
||||
public WorkOrderController(
|
||||
IWorkOrderService workOrderService,
|
||||
IWorkOrderDispatchService dispatchService,
|
||||
ILogger<WorkOrderController> logger)
|
||||
{
|
||||
_workOrderService = workOrderService;
|
||||
_dispatchService = dispatchService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +131,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Ok(viewModel);
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete]
|
||||
[Route("DeleteWorkorder")]
|
||||
public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model)
|
||||
|
|
@ -344,188 +342,5 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var data = await _workOrderService.GetWorkordersAsync();
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("DispatchToVendor")]
|
||||
public async Task<IActionResult> DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
var input = new DispatchToVendorInput
|
||||
{
|
||||
WorkOrderIds = model.WorkOrderIds,
|
||||
VendorIds = model.VendorIds,
|
||||
NTEAmount = model.NTEAmount,
|
||||
Description = model.Description,
|
||||
ScheduledDate = model.ScheduledDate,
|
||||
TaskListTemplateId = model.TaskListTemplateId,
|
||||
CustomChecklistItems = model.CustomChecklistItems,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("GetDispatches/{workOrderId}")]
|
||||
public async Task<IActionResult> GetDispatches(int workOrderId)
|
||||
{
|
||||
var data = await _dispatchService.GetDispatchesAsync(workOrderId);
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("GetDispatch/{id}")]
|
||||
public async Task<IActionResult> GetDispatchById(int id)
|
||||
{
|
||||
var d = await _dispatchService.GetDispatchDetailAsync(id);
|
||||
if (d == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
return Ok(d);
|
||||
}
|
||||
|
||||
[HttpPost("UpdateDispatch")]
|
||||
public async Task<IActionResult> UpdateDispatch([FromBody] UpdateDispatch_DTO model)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var input = new UpdateDispatchInput
|
||||
{
|
||||
Id = model.Id,
|
||||
Status = model.Status,
|
||||
NTEAmount = model.NTEAmount,
|
||||
ScheduledDate = model.ScheduledDate,
|
||||
CompletedDate = model.CompletedDate,
|
||||
Description = model.Description,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var success = await _dispatchService.UpdateDispatchAsync(input);
|
||||
if (!success)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(new { message = "Dispatch updated" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("AddDispatchComment")]
|
||||
public async Task<IActionResult> AddDispatchComment([FromBody] DispatchComment_DTO model)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var input = new AddDispatchCommentInput
|
||||
{
|
||||
DispatchId = model.DispatchId,
|
||||
Text = model.Text,
|
||||
SendEmail = model.SendEmail,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var result = await _dispatchService.AddDispatchCommentAsync(input);
|
||||
if (result == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("VerifyDispatch")]
|
||||
public async Task<IActionResult> VerifyDispatch(int dispatchId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!);
|
||||
if (result == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DispatchVerificationException ex)
|
||||
{
|
||||
return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("AddDispatchSignoff")]
|
||||
public async Task<IActionResult> AddDispatchSignoff([FromBody] DispatchSignoff_DTO model)
|
||||
{
|
||||
try
|
||||
{
|
||||
var input = new AddDispatchSignoffInput
|
||||
{
|
||||
DispatchId = model.DispatchId,
|
||||
SignoffType = model.SignoffType,
|
||||
Name = model.Name,
|
||||
Signature = model.Signature,
|
||||
SignatureMethod = model.SignatureMethod
|
||||
};
|
||||
|
||||
var result = await _dispatchService.AddDispatchSignoffAsync(input);
|
||||
if (result == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("UpdateChecklistItem")]
|
||||
public async Task<IActionResult> UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model)
|
||||
{
|
||||
var input = new ChecklistItemUpdateInput
|
||||
{
|
||||
Id = model.Id,
|
||||
IsCompleted = model.IsCompleted,
|
||||
CompletedBy = model.CompletedBy
|
||||
};
|
||||
|
||||
var result = await _dispatchService.UpdateChecklistItemAsync(input);
|
||||
if (result == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("AddChecklistItem")]
|
||||
public async Task<IActionResult> AddChecklistItem([FromBody] AddChecklistItem_DTO model)
|
||||
{
|
||||
var input = new AddChecklistItemInput
|
||||
{
|
||||
DispatchId = model.DispatchId,
|
||||
WorkOrderId = model.WorkOrderId,
|
||||
ItemText = model.ItemText
|
||||
};
|
||||
|
||||
var result = await _dispatchService.AddChecklistItemAsync(input);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/WorkOrder")]
|
||||
[Route("api/workorders")]
|
||||
public class WorkOrderDetailController : Controller
|
||||
{
|
||||
private readonly IWorkOrderDetailService _workOrderDetailService;
|
||||
private readonly IWorkOrderCommentService _workOrderCommentService;
|
||||
|
||||
public WorkOrderDetailController(
|
||||
IWorkOrderDetailService workOrderDetailService,
|
||||
IWorkOrderCommentService workOrderCommentService)
|
||||
{
|
||||
_workOrderDetailService = workOrderDetailService;
|
||||
_workOrderCommentService = workOrderCommentService;
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/detail")]
|
||||
public async Task<IActionResult> GetDetail(int id)
|
||||
{
|
||||
var detail = await _workOrderDetailService.GetDetailAsync(id);
|
||||
if (detail == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(detail);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/audit")]
|
||||
public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50)
|
||||
{
|
||||
var audit = await _workOrderDetailService.GetAuditAsync(id, limit);
|
||||
if (audit == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(audit);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/comments")]
|
||||
public async Task<IActionResult> GetBoardComments(int id)
|
||||
{
|
||||
var comments = await _workOrderCommentService.GetCommentsAsync(id);
|
||||
if (comments == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(comments);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/comments")]
|
||||
public async Task<IActionResult> AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId);
|
||||
return Ok(comment);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/WorkOrder")]
|
||||
[Route("api/workorders")]
|
||||
public class WorkOrderDispatchController : Controller
|
||||
{
|
||||
private readonly IWorkOrderDispatchService _dispatchService;
|
||||
private readonly ILogger<WorkOrderDispatchController> _logger;
|
||||
|
||||
public WorkOrderDispatchController(
|
||||
IWorkOrderDispatchService dispatchService,
|
||||
ILogger<WorkOrderDispatchController> logger)
|
||||
{
|
||||
_dispatchService = dispatchService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("DispatchToVendor")]
|
||||
public async Task<IActionResult> DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
var input = new DispatchToVendorInput
|
||||
{
|
||||
WorkOrderIds = model.WorkOrderIds,
|
||||
VendorIds = model.VendorIds,
|
||||
NTEAmount = model.NTEAmount,
|
||||
Description = model.Description,
|
||||
ScheduledDate = model.ScheduledDate,
|
||||
TaskListTemplateId = model.TaskListTemplateId,
|
||||
CustomChecklistItems = model.CustomChecklistItems,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("GetDispatches/{workOrderId}")]
|
||||
public async Task<IActionResult> GetDispatches(int workOrderId)
|
||||
{
|
||||
var data = await _dispatchService.GetDispatchesAsync(workOrderId);
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("GetDispatch/{id}")]
|
||||
public async Task<IActionResult> GetDispatchById(int id)
|
||||
{
|
||||
var d = await _dispatchService.GetDispatchDetailAsync(id);
|
||||
if (d == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
return Ok(d);
|
||||
}
|
||||
|
||||
[HttpPost("UpdateDispatch")]
|
||||
public async Task<IActionResult> UpdateDispatch([FromBody] UpdateDispatch_DTO model)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var input = new UpdateDispatchInput
|
||||
{
|
||||
Id = model.Id,
|
||||
Status = model.Status,
|
||||
NTEAmount = model.NTEAmount,
|
||||
ScheduledDate = model.ScheduledDate,
|
||||
CompletedDate = model.CompletedDate,
|
||||
Description = model.Description,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var success = await _dispatchService.UpdateDispatchAsync(input);
|
||||
if (!success)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(new { message = "Dispatch updated" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("AddDispatchComment")]
|
||||
public async Task<IActionResult> AddDispatchComment([FromBody] DispatchComment_DTO model)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var input = new AddDispatchCommentInput
|
||||
{
|
||||
DispatchId = model.DispatchId,
|
||||
Text = model.Text,
|
||||
SendEmail = model.SendEmail,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var result = await _dispatchService.AddDispatchCommentAsync(input);
|
||||
if (result == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("VerifyDispatch")]
|
||||
public async Task<IActionResult> VerifyDispatch(int dispatchId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!);
|
||||
if (result == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DispatchVerificationException ex)
|
||||
{
|
||||
return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("AddDispatchSignoff")]
|
||||
public async Task<IActionResult> AddDispatchSignoff([FromBody] DispatchSignoff_DTO model)
|
||||
{
|
||||
try
|
||||
{
|
||||
var input = new AddDispatchSignoffInput
|
||||
{
|
||||
DispatchId = model.DispatchId,
|
||||
SignoffType = model.SignoffType,
|
||||
Name = model.Name,
|
||||
Signature = model.Signature,
|
||||
SignatureMethod = model.SignatureMethod
|
||||
};
|
||||
|
||||
var result = await _dispatchService.AddDispatchSignoffAsync(input);
|
||||
if (result == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("UpdateChecklistItem")]
|
||||
public async Task<IActionResult> UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model)
|
||||
{
|
||||
var input = new ChecklistItemUpdateInput
|
||||
{
|
||||
Id = model.Id,
|
||||
IsCompleted = model.IsCompleted,
|
||||
CompletedBy = model.CompletedBy
|
||||
};
|
||||
|
||||
var result = await _dispatchService.UpdateChecklistItemAsync(input);
|
||||
if (result == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("AddChecklistItem")]
|
||||
public async Task<IActionResult> AddChecklistItem([FromBody] AddChecklistItem_DTO model)
|
||||
{
|
||||
var input = new AddChecklistItemInput
|
||||
{
|
||||
DispatchId = model.DispatchId,
|
||||
WorkOrderId = model.WorkOrderId,
|
||||
ItemText = model.ItemText
|
||||
};
|
||||
|
||||
var result = await _dispatchService.AddChecklistItemAsync(input);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using Api.SeaHavenIndustries.Filters;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/workorders")]
|
||||
public class WorkOrderIngestController : ControllerBase
|
||||
{
|
||||
private const int MaxBatchSize = 100;
|
||||
private readonly IWorkOrderIngestService _ingestService;
|
||||
|
||||
public WorkOrderIngestController(IWorkOrderIngestService ingestService)
|
||||
{
|
||||
_ingestService = ingestService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target).
|
||||
/// Auth: X-Ingest-Key header.
|
||||
/// </summary>
|
||||
[HttpPost("ingest")]
|
||||
[IngestApiKey]
|
||||
[ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WorkOrderIngestBatchResultDto>> Ingest(
|
||||
[FromBody] WorkOrderIngestBatchRequestDto? batch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = NormalizeItems(batch);
|
||||
if (items.Count == 0)
|
||||
return BadRequest("At least one item with externalWorkOrderId is required.");
|
||||
|
||||
if (items.Count > MaxBatchSize)
|
||||
return BadRequest($"Max {MaxBatchSize} items per request.");
|
||||
|
||||
var result = await _ingestService.UpsertBatchAsync(items, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
private static List<WorkOrderIngestPayloadDto> NormalizeItems(WorkOrderIngestBatchRequestDto? batch)
|
||||
{
|
||||
if (batch?.Items is { Count: > 0 })
|
||||
return batch.Items;
|
||||
|
||||
return new List<WorkOrderIngestPayloadDto>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using Api.SeaHavenIndustries.DTOs;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/WorkOrder")]
|
||||
[Route("api/workorders")]
|
||||
public class WorkOrderMediaController : Controller
|
||||
{
|
||||
private readonly IWorkOrderMediaService _workOrderMediaService;
|
||||
private readonly IFileStoragePort _fileStorage;
|
||||
|
||||
public WorkOrderMediaController(
|
||||
IWorkOrderMediaService workOrderMediaService,
|
||||
IFileStoragePort fileStorage)
|
||||
{
|
||||
_workOrderMediaService = workOrderMediaService;
|
||||
_fileStorage = fileStorage;
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/media")]
|
||||
public async Task<IActionResult> GetMedia(int id)
|
||||
{
|
||||
var media = await _workOrderMediaService.GetMediaAsync(id);
|
||||
if (media == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(media);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/media")]
|
||||
[RequestSizeLimit(30_000_000)]
|
||||
public async Task<IActionResult> AddMedia(
|
||||
int id,
|
||||
[FromForm] WorkOrderMediaCategory category,
|
||||
[FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
||||
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var fileUrl = await _fileStorage.SaveFileAsync(file);
|
||||
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
|
||||
return Ok(media);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}/media/{mediaId:int}")]
|
||||
public async Task<IActionResult> DeleteMedia(int id, int mediaId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId);
|
||||
return NoContent();
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
/// <summary>Ops health and coexistence metrics (Fase 7).</summary>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[ApiController]
|
||||
[Route("api/workorders/ops")]
|
||||
public class WorkOrderOpsController : ControllerBase
|
||||
{
|
||||
private readonly IWorkOrderOpsHealthService _healthService;
|
||||
|
||||
public WorkOrderOpsController(IWorkOrderOpsHealthService healthService)
|
||||
{
|
||||
_healthService = healthService;
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
[ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WorkOrderOpsHealthDto>> GetHealth(CancellationToken cancellationToken)
|
||||
{
|
||||
var health = await _healthService.GetHealthAsync(cancellationToken);
|
||||
return Ok(health);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
67
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Filters
|
||||
{
|
||||
/// <summary>Validates X-Ingest-Key for POST /api/workorders/ingest.</summary>
|
||||
public class IngestApiKeyFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly WorkOrderIngestOptions _options;
|
||||
|
||||
public IngestApiKeyFilter(IOptions<WorkOrderIngestOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
context.Result = new ObjectResult(new { message = "Work order ingest is disabled." })
|
||||
{
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey)
|
||||
|| _options.ApiKey.Contains("${", StringComparison.Ordinal))
|
||||
{
|
||||
context.Result = new ObjectResult(new { message = "Ingest API key is not configured." })
|
||||
{
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided)
|
||||
|| !FixedTimeEquals(provided.ToString(), _options.ApiKey))
|
||||
{
|
||||
context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." });
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string provided, string expected)
|
||||
{
|
||||
var providedBytes = Encoding.UTF8.GetBytes(provided);
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(expected);
|
||||
return providedBytes.Length == expectedBytes.Length
|
||||
&& CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class IngestApiKeyAttribute : ServiceFilterAttribute
|
||||
{
|
||||
public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter))
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
78
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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, int vendorId, int dispatchId, string storedFileName) =>
|
||||
Path.Combine(Root(contentRoot), vendorId.ToString(), dispatchId.ToString(), storedFileName);
|
||||
|
||||
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
|
||||
ResolvePath(contentRoot, document.VendorId, document.DispatchId, document.StoredFileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
|
||||
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)
|
||||
{
|
||||
if (await RunJobAsync(stoppingToken))
|
||||
_lastRunDate = today;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> 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;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_runState.LastPastDueCacheError = ex.Message;
|
||||
_logger.LogError(ex, "PastDue cache hosted job failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRunNow()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
||||
runAt = new TimeOnly(0, 10);
|
||||
|
||||
return TimeOnly.FromDateTime(now) >= runAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
|
||||
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)
|
||||
{
|
||||
if (await RunJobAsync(stoppingToken))
|
||||
_lastRunDate = today;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> 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;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_runState.LastWeekRolledError = ex.Message;
|
||||
_logger.LogError(ex, "WeekRolled hosted job failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return TimeOnly.FromDateTime(now) >= runAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,4 +72,37 @@ namespace Api.SeaHavenIndustries.Infrastructure
|
|||
return token.Token;
|
||||
}
|
||||
}
|
||||
|
||||
public class VendorDocumentStorageAdapter : IVendorDocumentStoragePort
|
||||
{
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public VendorDocumentStorageAdapter(IWebHostEnvironment environment)
|
||||
{
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using var file = File.Create(path);
|
||||
await content.CopyToAsync(file, cancellationToken);
|
||||
}
|
||||
|
||||
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
|
||||
{
|
||||
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
|
||||
return File.OpenRead(path);
|
||||
}
|
||||
|
||||
public void Delete(int vendorId, int dispatchId, string storedFileName)
|
||||
{
|
||||
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Middleware
|
||||
{
|
||||
/// <summary>Adds Sunset/Deprecation headers to legacy WorkOrder REST routes.</summary>
|
||||
public class LegacyDeprecationMiddleware
|
||||
{
|
||||
private static readonly HashSet<string> LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"GetWorkOrderList",
|
||||
"AddWorkorder",
|
||||
"EditWorkorder",
|
||||
"ChangeStatus",
|
||||
"ChangeAssignment",
|
||||
"DeleteWorkorder",
|
||||
"GetWorkorderById"
|
||||
};
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly LegacyEndpointOptions _options;
|
||||
|
||||
public LegacyDeprecationMiddleware(RequestDelegate next, IOptions<LegacyEndpointOptions> options)
|
||||
{
|
||||
_next = next;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (_options.DeprecationEnabled)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
var isLegacyWo = (path.Contains("/WorkOrder", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("/workorders", StringComparison.OrdinalIgnoreCase))
|
||||
&& LegacyPathSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (isLegacyWo)
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
context.Response.Headers["Deprecation"] = "true";
|
||||
if (!string.IsNullOrWhiteSpace(_options.SunsetDate)
|
||||
&& DateTimeOffset.TryParse(_options.SunsetDate, out var sunset))
|
||||
{
|
||||
context.Response.Headers["Sunset"] = sunset.UtcDateTime.ToString("r");
|
||||
}
|
||||
context.Response.Headers["Link"] = "</api/workorders/board>; rel=\"successor-version\"";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
public class LegacyEndpointOptions
|
||||
{
|
||||
public const string SectionName = "LegacyEndpoints";
|
||||
|
||||
public bool DeprecationEnabled { get; set; }
|
||||
|
||||
/// <summary>ISO date returned in Sunset header (e.g. 2026-12-31).</summary>
|
||||
public string? SunsetDate { get; set; }
|
||||
}
|
||||
}
|
||||
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
public class SyncOptions
|
||||
{
|
||||
public const string SectionName = "Sync";
|
||||
|
||||
/// <summary>When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
}
|
||||
13
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
13
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
public class WorkOrderIngestOptions
|
||||
{
|
||||
public const string SectionName = "WorkOrderIngest";
|
||||
|
||||
/// <summary>When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Shared secret for X-Ingest-Key header (Lambda / service accounts).</summary>
|
||||
public string? ApiKey { 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,7 @@
|
|||
using Api.SeaHavenIndustries.Helper;
|
||||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Api.SeaHavenIndustries.Middleware;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
|
@ -9,6 +12,8 @@ using Microsoft.OpenApi.Models;
|
|||
using System.Text;
|
||||
using SeaHaven.DataServices.DependencyInjection;
|
||||
using SeaHaven.Services.DependencyInjection;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
ConfigurationManager configuration = builder.Configuration;
|
||||
|
|
@ -25,6 +30,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
|
||||
|
|
@ -40,6 +46,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
|
|||
builder.Services.AddScoped<SendMessage>();
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
||||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
||||
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();
|
||||
|
|
@ -57,6 +66,41 @@ builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api
|
|||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>();
|
||||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
|
||||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
|
||||
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorDocumentStoragePort, Api.SeaHavenIndustries.Infrastructure.VendorDocumentStorageAdapter>();
|
||||
|
||||
builder.Services.Configure<WorkOrderJobsOptions>(
|
||||
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
||||
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
||||
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
||||
.Validate(
|
||||
o => !o.Enabled
|
||||
|| (!string.IsNullOrWhiteSpace(o.ApiKey)
|
||||
&& !o.ApiKey.Contains("${", StringComparison.Ordinal)
|
||||
&& o.ApiKey.Length >= 32),
|
||||
"WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.Configure<SyncOptions>(
|
||||
builder.Configuration.GetSection(SyncOptions.SectionName));
|
||||
builder.Services.Configure<LegacyEndpointOptions>(
|
||||
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
|
||||
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
|
||||
builder.Services.AddSingleton<IWorkOrderJobRunStateAccessor>(
|
||||
sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>());
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
|
||||
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
|
||||
builder.Services.AddHostedService<PastDueCacheHostedService>();
|
||||
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
|
||||
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
|
||||
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
|
||||
Microsoft.Extensions.Options.IOptions<LegacyEndpointOptions>>(
|
||||
(health, sync, ingest, legacy) =>
|
||||
{
|
||||
health.SyncEnabled = sync.Value.Enabled;
|
||||
health.IngestEnabled = ingest.Value.Enabled;
|
||||
health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled;
|
||||
health.LegacySunsetDate = legacy.Value.SunsetDate;
|
||||
});
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
|
|
@ -125,6 +169,7 @@ if (!app.Environment.IsDevelopment())
|
|||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseCors();
|
||||
app.UseMiddleware<LegacyDeprecationMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
//"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
|
||||
|
||||
|
|
@ -17,11 +17,34 @@
|
|||
// 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" }
|
||||
},
|
||||
"WorkOrderIngest": {
|
||||
"Enabled": false,
|
||||
"ApiKey": "${WORKORDER_INGEST_API_KEY}"
|
||||
},
|
||||
"Sync": {
|
||||
"Enabled": true
|
||||
},
|
||||
"LegacyEndpoints": {
|
||||
"DeprecationEnabled": false,
|
||||
"SunsetDate": "2026-12-31"
|
||||
},
|
||||
"FrontendBaseUrl": "https://shoc.seahaven.com"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,10 +38,51 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<WorkOrderWeekRolledLedger>()
|
||||
.HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<CompletionDocTemplate>()
|
||||
.HasIndex(t => new { t.ServiceKey, t.IsActive });
|
||||
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
|
||||
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.SiteCode)
|
||||
.HasMaxLength(32);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.InternalWONumber)
|
||||
.HasMaxLength(11);
|
||||
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.SiteCode)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
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<WorkOrder>()
|
||||
.Property(w => w.ExternalWorkOrderId)
|
||||
.HasMaxLength(450);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.ExternalWorkOrderId)
|
||||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
||||
|
||||
builder.Entity<Dispatch>()
|
||||
.Property(d => d.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
|
@ -61,6 +102,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; }
|
||||
|
|
@ -74,13 +125,17 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderContacts> WorkOrderContacts { get; set; }
|
||||
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
|
||||
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
|
||||
public DbSet<CompletionDocTemplate> CompletionDocTemplates { 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 +161,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260625120000_Phase6_CompletionSlideOver")]
|
||||
public partial class Phase6_CompletionSlideOver : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE workOrderAttachments ADD Category int NULL;
|
||||
END
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE CompletionDocTemplates (
|
||||
Id int NOT NULL IDENTITY(1,1),
|
||||
Name nvarchar(max) NOT NULL,
|
||||
ServiceKey nvarchar(450) NOT NULL,
|
||||
WorkOrderType int NULL,
|
||||
TemplateUrl nvarchar(max) NOT NULL,
|
||||
IsActive bit NOT NULL,
|
||||
IsDeleted bit NULL,
|
||||
createdby nvarchar(max) NULL,
|
||||
DeleterUserId nvarchar(max) NULL,
|
||||
DeletionTime datetime2 NULL,
|
||||
CreatedDate datetime2 NULL,
|
||||
LastModificationTime datetime2 NULL,
|
||||
LastModifierUserId int NULL,
|
||||
CONSTRAINT PK_CompletionDocTemplates PRIMARY KEY (Id)
|
||||
);
|
||||
END
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = N'IX_CompletionDocTemplates_ServiceKey_IsActive'
|
||||
AND object_id = OBJECT_ID(N'dbo.CompletionDocTemplates'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_CompletionDocTemplates_ServiceKey_IsActive
|
||||
ON CompletionDocTemplates (ServiceKey, IsActive);
|
||||
END
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE workOrders
|
||||
SET DocStatus = 1
|
||||
WHERE DocStatus IS NULL
|
||||
AND SignOffAttachment IS NOT NULL
|
||||
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE workOrders
|
||||
SET DocStatus = NULL
|
||||
WHERE DocStatus = 1
|
||||
AND SignOffAttachment IS NOT NULL
|
||||
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL
|
||||
DROP TABLE CompletionDocTemplates;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL
|
||||
ALTER TABLE workOrderAttachments DROP COLUMN Category;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(450)",
|
||||
maxLength: 450,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
column: "ExternalWorkOrderId",
|
||||
unique: true,
|
||||
filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_ExternalWorkOrderId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(450)",
|
||||
oldMaxLength: 450,
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260715160000_WoServiceNotesStructuredFields")]
|
||||
public partial class WoServiceNotesStructuredFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PocName",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PocPhone",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PocNotes",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExtraServices",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ServiceNotes",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TechPhone",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VendorNotes",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TechPhone",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VendorNotes",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PocName",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PocPhone",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PocNotes",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExtraServices",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ServiceNotes",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TechPhone",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorNotes",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TechPhone",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorNotes",
|
||||
table: "Dispatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260715180000_WoFlagColor")]
|
||||
public partial class WoFlagColor : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FlagColor",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(7)",
|
||||
maxLength: 7,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FlagColor",
|
||||
table: "workOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,230 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorRoadmapOperations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unknown");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Not Submitted");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unavailable");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SitePreferredVendors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LocationId = table.Column<int>(type: "int", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SitePreferredVendors", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Locations_LocationId",
|
||||
column: x => x.LocationId,
|
||||
principalTable: "Locations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCompletionDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
DispatchId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Version = table.Column<int>(type: "int", nullable: false),
|
||||
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
|
||||
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Dispatches_DispatchId",
|
||||
column: x => x.DispatchId,
|
||||
principalTable: "Dispatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId",
|
||||
column: x => x.WorkOrderId,
|
||||
principalTable: "workOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId_Trade",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId", "Trade" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_DispatchId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "DispatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_VendorId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_WorkOrderId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "WorkOrderId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SitePreferredVendors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCompletionDocuments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
b.ToTable("Accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
|
||||
|
|
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("Addresses");
|
||||
b.ToTable("Addresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
|
|
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
b.ToTable("Assets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
|
||||
|
|
@ -371,7 +371,62 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Categories");
|
||||
b.ToTable("Categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", 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>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ServiceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("TemplateUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceKey", "IsActive")
|
||||
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
|
||||
|
||||
b.ToTable("CompletionDocTemplates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
|
||||
|
|
@ -438,7 +493,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkerOrderId");
|
||||
|
||||
b.ToTable("Comments");
|
||||
b.ToTable("Comments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
|
||||
|
|
@ -504,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("ContactDetails");
|
||||
b.ToTable("ContactDetails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
|
||||
|
|
@ -596,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Contacts");
|
||||
b.ToTable("Contacts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
|
||||
|
|
@ -614,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Departments");
|
||||
b.ToTable("Departments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
|
||||
|
|
@ -631,6 +686,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateTime?>("AcknowledgedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CommercialStatusUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CompletedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -655,6 +713,19 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<bool>("EmailSent")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("EstimatedArrivalAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("EtaManualOverride")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("InvoiceNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("InvoiceStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -670,6 +741,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PaymentStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ReplyToAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -684,9 +759,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Status")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TechPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("VendorNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -705,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("Dispatches");
|
||||
b.ToTable("Dispatches", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
|
||||
|
|
@ -764,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchChecklistItems");
|
||||
b.ToTable("DispatchChecklistItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
|
||||
|
|
@ -818,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.ToTable("DispatchSignoffs");
|
||||
b.ToTable("DispatchSignoffs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
|
||||
|
|
@ -888,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
|
||||
b.ToTable("DispatchUpliftRequests");
|
||||
b.ToTable("DispatchUpliftRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
|
|
@ -911,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchWorkOrders");
|
||||
b.ToTable("DispatchWorkOrders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
|
||||
|
|
@ -941,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DropdownOptions");
|
||||
b.ToTable("DropdownOptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
|
||||
|
|
@ -1046,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("RegionId");
|
||||
|
||||
b.ToTable("Employees");
|
||||
b.ToTable("Employees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
|
||||
|
|
@ -1086,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("EmployeeId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("EmployeeAddresses");
|
||||
b.ToTable("EmployeeAddresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
|
||||
|
|
@ -1114,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeeEmails");
|
||||
b.ToTable("EmployeeEmails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
|
||||
|
|
@ -1144,7 +1227,87 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeePhones");
|
||||
b.ToTable("EmployeePhones", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AllDay")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("EndTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("EventColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<int?>("RecordID")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RecordName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RecordTargetType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ReminderMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StartTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
|
||||
|
|
@ -1212,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("FollowUps");
|
||||
b.ToTable("FollowUps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
|
||||
|
|
@ -1237,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ForgetPasswordCodes");
|
||||
b.ToTable("ForgetPasswordCodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
|
||||
|
|
@ -1255,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobTitles");
|
||||
b.ToTable("JobTitles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||
|
|
@ -1325,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Locations");
|
||||
b.ToTable("Locations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
|
||||
|
|
@ -1383,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("PMSchedules");
|
||||
b.ToTable("PMSchedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
|
||||
|
|
@ -1457,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("Quotes");
|
||||
b.ToTable("Quotes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
|
||||
|
|
@ -1505,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("QuoteId");
|
||||
|
||||
b.ToTable("QuotesLines");
|
||||
b.ToTable("QuotesLines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
|
||||
|
|
@ -1523,7 +1686,66 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Regions");
|
||||
b.ToTable("Regions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("LocationId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NULL");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId", "Trade")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NOT NULL");
|
||||
|
||||
b.ToTable("SitePreferredVendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||
|
|
@ -1566,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaskListTemplates");
|
||||
b.ToTable("TaskListTemplates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
|
|
@ -1611,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("TaskListTemplateId");
|
||||
|
||||
b.ToTable("TaskListTemplateItems");
|
||||
b.ToTable("TaskListTemplateItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||
|
|
@ -1713,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Templates");
|
||||
b.ToTable("Templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
|
|
@ -1727,6 +1949,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)");
|
||||
|
||||
|
|
@ -1794,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("Vendors");
|
||||
b.ToTable("Vendors", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
|
||||
|
|
@ -1853,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAccessTokens");
|
||||
b.ToTable("VendorAccessTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
|
||||
|
|
@ -1958,6 +2187,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")
|
||||
|
|
@ -2024,10 +2340,20 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ExternalWorkOrderId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ExtraServices")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("FlagColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("nvarchar(7)");
|
||||
|
||||
b.Property<string>("InternalWONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(11)
|
||||
.HasColumnType("nvarchar(11)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
|
@ -2059,6 +2385,18 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PO")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PocName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("PocNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("PocPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("PrimaryDispatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
|
@ -2092,6 +2430,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ServiceNotes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -2105,7 +2447,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SiteCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
@ -2125,12 +2468,20 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateOnly?>("TargetWeek")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("TechPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("VendorNTE")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("VendorNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
|
@ -2150,11 +2501,30 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AssignTo");
|
||||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("ExternalWorkOrderId")
|
||||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
||||
b.HasIndex("InternalWONumber")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("PrimaryDispatchId");
|
||||
|
||||
b.ToTable("workOrders");
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("SiteCode")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
b.ToTable("workOrders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||
|
|
@ -2168,6 +2538,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Attachments")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("Category")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -2196,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderAttachments");
|
||||
b.ToTable("workOrderAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
|
||||
|
|
@ -2246,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("WorkOrderAuditLogs");
|
||||
b.ToTable("WorkOrderAuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
|
||||
|
|
@ -2290,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderCategories");
|
||||
b.ToTable("workOrderCategories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
|
||||
|
|
@ -2337,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("WorkOrderContacts");
|
||||
b.ToTable("WorkOrderContacts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
|
|
@ -2366,7 +2739,65 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "FieldName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderFieldLocks");
|
||||
b.ToTable("WorkOrderFieldLocks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
|
|
@ -2796,6 +3227,25 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
|
||||
.WithMany()
|
||||
.HasForeignKey("LocationId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
|
||||
.WithMany("SitePreferences")
|
||||
.HasForeignKey("VendorId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Location");
|
||||
|
||||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template")
|
||||
|
|
@ -2870,6 +3320,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 +3433,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 +3548,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.Navigation("Comments");
|
||||
|
||||
b.Navigation("CompletionDocuments");
|
||||
|
||||
b.Navigation("DispatchWorkOrders");
|
||||
|
||||
b.Navigation("Signoffs");
|
||||
|
|
@ -3106,6 +3596,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("AuditLogs");
|
||||
|
||||
b.Navigation("Dispatches");
|
||||
|
||||
b.Navigation("SitePreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||
|
|
|
|||
13
Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs
Normal file
13
Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class CompletionDocTemplate : FullAuditEntity
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string ServiceKey { get; set; } = "";
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
public string TemplateUrl { get; set; } = "";
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -28,6 +34,13 @@ namespace Data.SeaHavenIndustries
|
|||
public string? VerifiedBy { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>Tech/crew phone for this dispatch (overrides Vendor.Phone on board).</summary>
|
||||
[MaxLength(50)]
|
||||
public string? TechPhone { get; set; }
|
||||
/// <summary>Instructions for the vendor for this appointment/dispatch.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? VendorNotes { get; set; }
|
||||
|
||||
[Timestamp]
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
||||
|
|
@ -35,5 +48,6 @@ namespace Data.SeaHavenIndustries
|
|||
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
|
||||
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
|
||||
public ICollection<DispatchSignoff>? Signoffs { get; set; }
|
||||
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
using System;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class WorkOrderAttachments : FullAuditEntity
|
||||
{
|
||||
public string? Attachments { get; set; }
|
||||
public WorkOrderMediaCategory? Category { get; set; }
|
||||
public int? WorkorderId { get; set; }
|
||||
[ForeignKey(nameof(WorkorderId))]
|
||||
public virtual WorkOrder? WorkOrder { 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,11 +27,31 @@ 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))]
|
||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||
|
||||
// --- Service & Notes / POC (structured create) ---
|
||||
[MaxLength(200)]
|
||||
public string? PocName { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string? PocPhone { get; set; }
|
||||
[MaxLength(2000)]
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? ExtraServices { get; set; }
|
||||
[MaxLength(4000)]
|
||||
public string? ServiceNotes { get; set; }
|
||||
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
|
||||
[MaxLength(50)]
|
||||
public string? TechPhone { get; set; }
|
||||
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? VendorNotes { get; set; }
|
||||
|
||||
// --- Scheduling slice ---
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
|
|
@ -51,6 +71,10 @@ namespace Data.SeaHavenIndustries
|
|||
// --- Completion slice ---
|
||||
public DocStatus? DocStatus { get; set; }
|
||||
|
||||
/// <summary>Board flag color (#RRGGBB from fixed palette). Null = no flag.</summary>
|
||||
[MaxLength(7)]
|
||||
public string? FlagColor { get; set; }
|
||||
|
||||
// --- Legacy SQL columns (drift reconciliation) ---
|
||||
public string? AvettaTask { get; set; }
|
||||
public DateOnly? AssignDate { get; set; }
|
||||
|
|
|
|||
127
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal file
127
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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,
|
||||
WoPocName = w.PocName,
|
||||
WoPocPhone = w.PocPhone,
|
||||
WoPocNotes = w.PocNotes,
|
||||
ContactPoc = 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,
|
||||
DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null,
|
||||
WoTechPhone = w.TechPhone,
|
||||
VendorPhone = 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.ServiceNotes,
|
||||
w.ExtraServices,
|
||||
w.DocStatus,
|
||||
w.FlagColor,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return rows.Select(w =>
|
||||
{
|
||||
var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name;
|
||||
var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber;
|
||||
var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes;
|
||||
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
|
||||
? w.DispatchTechPhone
|
||||
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
|
||||
|
||||
return new WorkOrderBoardRawRow(
|
||||
w.Id,
|
||||
w.InternalWONumber,
|
||||
w.RescheduleCount,
|
||||
w.CarriedOver,
|
||||
w.WorkOrderType,
|
||||
w.SiteCode,
|
||||
w.LocationName,
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
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,
|
||||
techPhone,
|
||||
w.DispatchApptDate,
|
||||
w.Trade ?? w.Problem,
|
||||
w.Problem,
|
||||
w.ServiceNotes,
|
||||
w.ExtraServices,
|
||||
w.DocStatus,
|
||||
w.FlagColor,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
w.DispatchRowVersion,
|
||||
isUnscheduled);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
148
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
148
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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,103 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public CompletionDocTemplateDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
|
||||
{
|
||||
var query = _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.IsActive && t.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(serviceKey))
|
||||
query = query.Where(t => t.ServiceKey == serviceKey);
|
||||
|
||||
if (workOrderType.HasValue)
|
||||
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
|
||||
|
||||
return await query
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(MapRow)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
|
||||
{
|
||||
return await _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Id == id && t.IsDeleted != true)
|
||||
.Select(MapRow)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trade))
|
||||
{
|
||||
var byTrade = await _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade)
|
||||
.OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1)
|
||||
.ThenBy(t => t.Name)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(MapRow)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (byTrade != null)
|
||||
return byTrade;
|
||||
}
|
||||
|
||||
if (!workOrderType.HasValue)
|
||||
return null;
|
||||
|
||||
return await _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType)
|
||||
.OrderBy(t => t.Name)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(MapRow)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
|
||||
{
|
||||
template.CreatedDate = DateTime.UtcNow;
|
||||
await _context.CompletionDocTemplates.AddAsync(template);
|
||||
await _context.SaveChangesAsync();
|
||||
return template;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(CompletionDocTemplate template)
|
||||
{
|
||||
template.LastModificationTime = DateTime.UtcNow;
|
||||
_context.CompletionDocTemplates.Update(template);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var entity = await _context.CompletionDocTemplates.FindAsync(id);
|
||||
if (entity == null)
|
||||
return false;
|
||||
|
||||
entity.IsDeleted = true;
|
||||
entity.DeletionTime = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
|
||||
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
|
||||
{
|
||||
var query = _context.Locations.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var s = search.ToLower();
|
||||
query = query.Where(l =>
|
||||
(l.Name ?? "").ToLower().Contains(s) ||
|
||||
(l.City ?? "").ToLower().Contains(s));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(l => l.Name)
|
||||
.Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Locations> AddAsync(Locations location)
|
||||
{
|
||||
location.CreatedDate = DateTime.Now;
|
||||
|
|
|
|||
|
|
@ -151,5 +151,27 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.Select(user => user.Email)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids)
|
||||
{
|
||||
var idList = ids
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (idList.Count == 0)
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
var users = await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => idList.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FirstName, u.LastName })
|
||||
.ToListAsync();
|
||||
|
||||
return users.ToDictionary(
|
||||
u => u.Id,
|
||||
u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class VendorDocumentDataService : IVendorDocumentDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public VendorDocumentDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.VendorCompletionDocuments
|
||||
.Where(document => document.DispatchId == dispatchId)
|
||||
.OrderByDescending(document => document.Version)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.VendorCompletionDocuments
|
||||
.FirstOrDefaultAsync(document => document.Id == documentId
|
||||
&& document.DispatchId == dispatchId
|
||||
&& document.VendorId == vendorId
|
||||
&& (document.IsDeleted == null || document.IsDeleted == false), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
await _context.VendorCompletionDocuments.AddAsync(document, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class VendorOperationsDataService : IVendorOperationsDataService
|
||||
{
|
||||
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
|
||||
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IDispatchDataService _dispatchDataService;
|
||||
|
||||
public VendorOperationsDataService(ApplicationDbContext context, IDispatchDataService dispatchDataService)
|
||||
{
|
||||
_context = context;
|
||||
_dispatchDataService = dispatchDataService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var directlyAssignedIds = await _context.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
|
||||
.Select(d => d.WorkOrderId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
var multiplyAssignedIds = await _context.DispatchWorkOrders
|
||||
.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||
.Select(link => link.WorkOrderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
return directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(
|
||||
DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(wo => wo.ScheduledDate.HasValue
|
||||
&& wo.ScheduledDate.Value <= cutoff
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& !excludeIds.Contains(wo.Id))
|
||||
.OrderBy(wo => wo.ScheduledDate)
|
||||
.Select(wo => new NoVendorCandidateReadModel
|
||||
{
|
||||
Id = wo.Id,
|
||||
InternalWONumber = wo.InternalWONumber,
|
||||
WorkerOrderNumber = wo.WorkerOrderNumber,
|
||||
ScheduledDate = wo.ScheduledDate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var direct = _context.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 ScheduledDispatchReadModel
|
||||
{
|
||||
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 = _context.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 ScheduledDispatchReadModel
|
||||
{
|
||||
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 await direct.Concat(linked).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Vendors.AsNoTracking()
|
||||
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
|
||||
.Select(v => new ActiveVendorReadModel
|
||||
{
|
||||
Id = v.Id,
|
||||
CompanyName = v.CompanyName,
|
||||
ContactName = v.ContactName,
|
||||
TradeSpecialties = v.TradeSpecialties,
|
||||
AvailabilityStatus = v.AvailabilityStatus,
|
||||
AvailabilityUpdatedAt = v.AvailabilityUpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.SitePreferredVendors
|
||||
.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId)
|
||||
.Include(p => p.Vendor)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.SitePreferredVendors
|
||||
.Where(p => p.LocationId == locationId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
|
||||
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
|
||||
|
||||
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
|
||||
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))
|
||||
.ToDictionaryAsync(vendor => vendor.Id, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<InsightDispatchData>> GetInsightDispatchDataAsync(
|
||||
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.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);
|
||||
|
||||
return await query.Select(dispatch => new InsightDispatchData
|
||||
{
|
||||
VendorId = dispatch.VendorId,
|
||||
VendorName = dispatch.Vendor!.CompanyName,
|
||||
Status = dispatch.Status,
|
||||
DispatchedAt = dispatch.DispatchedAt,
|
||||
CreatedDate = dispatch.CreatedDate,
|
||||
ScheduledDate = dispatch.ScheduledDate,
|
||||
AcknowledgedAt = dispatch.AcknowledgedAt,
|
||||
CompletedDate = dispatch.CompletedDate,
|
||||
DueDate = dispatch.WorkOrder != null ? dispatch.WorkOrder.DueDate : null
|
||||
}).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<Vendor?> GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken)
|
||||
=> _context.Vendors.FirstOrDefaultAsync(v => v.Id == vendorId, cancellationToken);
|
||||
|
||||
public Task<VendorCompletionDocument?> GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken)
|
||||
=> _context.VendorCompletionDocuments.FirstOrDefaultAsync(d => d.Id == documentId, cancellationToken);
|
||||
|
||||
public Task<Dispatch?> GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
=> _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken);
|
||||
|
||||
public Task<WorkOrder?> GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
|
||||
public async Task<Dispatch?> GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
var linkedDispatchIds = _context.DispatchWorkOrders
|
||||
.Where(link => link.WorkOrderId == workOrderId)
|
||||
.Select(link => link.DispatchId);
|
||||
return await _context.Dispatches
|
||||
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
|
||||
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
|
||||
.OrderByDescending(item => item.CreatedDate)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string> GenerateNextDispatchNumberAsync(CancellationToken cancellationToken)
|
||||
=> _dispatchDataService.GenerateNextDispatchNumberAsync();
|
||||
|
||||
public Task<string> GenerateNextPONumberAsync(CancellationToken cancellationToken)
|
||||
=> _dispatchDataService.GenerateNextPONumberAsync();
|
||||
|
||||
public Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken)
|
||||
{
|
||||
_context.Dispatches.Add(dispatch);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken)
|
||||
{
|
||||
_context.WorkOrderAuditLogs.Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken)
|
||||
{
|
||||
_context.VendorAuditLogs.Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken)
|
||||
{
|
||||
_context.SitePreferredVendors.Add(preference);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveSitePreferredVendorsAsync(IEnumerable<SitePreferredVendor> preferences, CancellationToken cancellationToken)
|
||||
{
|
||||
_context.SitePreferredVendors.RemoveRange(preferences);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken)
|
||||
=> _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,92 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderBoardMutationDataService : IWorkOrderBoardMutationDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderBoardMutationDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<bool> VendorExistsAsync(int vendorId, CancellationToken cancellationToken)
|
||||
=> _context.Vendors.AnyAsync(v => v.Id == vendorId && v.IsDeleted != true, cancellationToken);
|
||||
|
||||
public async Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken)
|
||||
=> await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(
|
||||
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
|
||||
cancellationToken);
|
||||
|
||||
public Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.Dispatches.FirstOrDefaultAsync(
|
||||
d => d.Id == dispatchId && d.WorkOrderId == workOrderId,
|
||||
cancellationToken);
|
||||
|
||||
public Task<Dispatch?> GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken)
|
||||
=> _context.Dispatches.FirstOrDefaultAsync(d => d.Id == dispatchId, cancellationToken);
|
||||
|
||||
public void TrackNewWorkOrder(WorkOrder workOrder)
|
||||
=> _context.workOrders.Add(workOrder);
|
||||
|
||||
public void TrackNewDispatch(Dispatch dispatch)
|
||||
=> _context.Dispatches.Add(dispatch);
|
||||
|
||||
public void TrackWorkOrderContact(int workOrderId, int contactId, string? notes)
|
||||
=> _context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||
{
|
||||
WorkorderId = workOrderId,
|
||||
ContactId = contactId,
|
||||
Notes = notes
|
||||
});
|
||||
|
||||
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
|
||||
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
|
||||
|
||||
public void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version)
|
||||
=> _context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = version;
|
||||
|
||||
public bool HasPendingChanges()
|
||||
=> _context.ChangeTracker.HasChanges();
|
||||
|
||||
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var transaction = _context.Database.IsRelational()
|
||||
? await _context.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
await work(cancellationToken);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BoardSaveOutcome> SaveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return BoardSaveOutcome.Saved;
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
_context.ChangeTracker.Clear();
|
||||
return BoardSaveOutcome.ConcurrencyConflict;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderCompletionDataService : IWorkOrderCompletionDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderCompletionDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(
|
||||
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
|
||||
cancellationToken);
|
||||
|
||||
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
|
||||
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
|
||||
|
||||
public Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken)
|
||||
=> _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
|
||||
|
||||
public Task SaveAsync(CancellationToken cancellationToken)
|
||||
=> _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderDetailDataService : IWorkOrderDetailDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderDetailDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(int workOrderId)
|
||||
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.AnyAsync(w => w.Id == workOrderId);
|
||||
|
||||
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
||||
{
|
||||
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.Where(w => w.Id == workOrderId)
|
||||
.Select(w => new WorkOrderDetailExtendedFields(
|
||||
w.Description,
|
||||
w.Trade,
|
||||
w.Problem,
|
||||
w.SubTrade,
|
||||
w.OriginalWeek,
|
||||
w.OriginalDate,
|
||||
w.CompletedDate,
|
||||
w.SignOffName,
|
||||
w.SignOffAttachment,
|
||||
w.SignOffSignature,
|
||||
w.ExtraServices,
|
||||
w.ServiceNotes,
|
||||
w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null
|
||||
? w.PrimaryDispatch.VendorNotes
|
||||
: w.VendorNotes))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId)
|
||||
{
|
||||
return await _context.Comments
|
||||
.AsNoTracking()
|
||||
.Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true)
|
||||
.OrderByDescending(c => c.CreatedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null)
|
||||
{
|
||||
var query = _context.WorkOrderAuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(a => a.WorkOrderId == workOrderId)
|
||||
.OrderByDescending(a => a.CreatedAt);
|
||||
|
||||
if (limit.HasValue)
|
||||
return await query.Take(limit.Value).ToListAsync();
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
|
||||
{
|
||||
return await _context.workOrderAttachments
|
||||
.AsNoTracking()
|
||||
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
|
||||
.OrderByDescending(a => a.CreatedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
|
||||
{
|
||||
return await _context.DispatchSignoffs
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true)
|
||||
.OrderByDescending(s => s.SignedAt)
|
||||
.Select(s => new DispatchSignoffRow(
|
||||
s.DispatchId,
|
||||
s.SignoffType,
|
||||
s.Name,
|
||||
s.SignedAt))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
|
||||
{
|
||||
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,9 +22,13 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName, cancellationToken);
|
||||
}
|
||||
|
||||
public void EnqueueLock(int workOrderId, string fieldName, string? userId)
|
||||
public async Task EnqueueLockAsync(
|
||||
int workOrderId,
|
||||
string fieldName,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsTracked(workOrderId, fieldName))
|
||||
if (await IsLockedAsync(workOrderId, fieldName, cancellationToken))
|
||||
return;
|
||||
|
||||
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderIngestDataService : IWorkOrderIngestDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderIngestDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken)
|
||||
=> await _context.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
|
||||
|
||||
public Task<WorkOrder?> GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWorkOrderId, cancellationToken);
|
||||
|
||||
public void TrackWorkOrder(WorkOrder workOrder)
|
||||
=> _context.workOrders.Add(workOrder);
|
||||
|
||||
public Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken)
|
||||
=> _context.Locations.FirstOrDefaultAsync(l => l.Name == name || l.Title == name, cancellationToken);
|
||||
|
||||
public async Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken)
|
||||
{
|
||||
_context.Locations.Add(location);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var transaction = _context.Database.IsRelational()
|
||||
? await _context.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
await work(cancellationToken);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task SaveAsync(CancellationToken cancellationToken)
|
||||
=> _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderMediaDataService : IWorkOrderMediaDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderMediaDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
|
||||
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrderAttachments.FirstOrDefaultAsync(
|
||||
a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true,
|
||||
cancellationToken);
|
||||
|
||||
public void TrackAttachment(WorkOrderAttachments attachment)
|
||||
=> _context.workOrderAttachments.Add(attachment);
|
||||
|
||||
public Task SaveAsync(CancellationToken cancellationToken)
|
||||
=> _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderOpsHealthDataService : IWorkOrderOpsHealthDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderOpsHealthDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<int> CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken)
|
||||
=> _context.WorkOrderAuditLogs
|
||||
.CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken);
|
||||
|
||||
public Task<int> CountFieldLocksAsync(CancellationToken cancellationToken)
|
||||
=> _context.WorkOrderFieldLocks.CountAsync(cancellationToken);
|
||||
|
||||
public async Task<DateTime?> GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _context.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken))
|
||||
return null;
|
||||
|
||||
return await _context.WorkOrderWeekRolledLedgers
|
||||
.MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ using Data.SeaHavenIndustries;
|
|||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public record SiteOptionRow(int Id, string? Code, string? City, string? State);
|
||||
|
||||
public interface ILocationDataService
|
||||
{
|
||||
Task<Locations?> GetByIdAsync(int id);
|
||||
|
|
@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
// For addressbook endpoint - returns simplified DTO
|
||||
Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null);
|
||||
|
||||
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
|
||||
|
||||
Task<Locations> AddAsync(Locations location);
|
||||
Task UpdateAsync(Locations location);
|
||||
Task DeleteAsync(int id);
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IVendorDocumentDataService
|
||||
{
|
||||
Task<VendorCompletionDocument?> GetLatestForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<VendorCompletionDocument?> GetForVendorDispatchAsync(int documentId, int dispatchId, int vendorId, CancellationToken cancellationToken);
|
||||
Task AddAsync(VendorCompletionDocument document, CancellationToken cancellationToken);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Models;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IVendorOperationsDataService
|
||||
{
|
||||
Task<IReadOnlyList<int>> GetAssignedWorkOrderIdsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<NoVendorCandidateReadModel>> GetNoVendorWorkOrdersAsync(DateTime cutoff, IReadOnlyCollection<int> excludeIds, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ScheduledDispatchReadModel>> GetActiveScheduledDispatchesAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ActiveVendorReadModel>> GetActiveVendorsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<SitePreferredVendor>> GetSitePreferredVendorsAsync(int locationId, CancellationToken cancellationToken);
|
||||
Task<List<SitePreferredVendor>> GetSitePreferredVendorsForReplaceAsync(int locationId, CancellationToken cancellationToken);
|
||||
Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken);
|
||||
Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<InsightDispatchData>> GetInsightDispatchDataAsync(DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Vendor?> GetVendorForUpdateAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task<VendorCompletionDocument?> GetCompletionDocumentForUpdateAsync(int documentId, CancellationToken cancellationToken);
|
||||
Task<Dispatch?> GetDispatchForUpdateAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
Task<WorkOrder?> GetWorkOrderForUpdateAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<Dispatch?> GetExistingActiveDispatchForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
|
||||
Task<string> GenerateNextDispatchNumberAsync(CancellationToken cancellationToken);
|
||||
Task<string> GenerateNextPONumberAsync(CancellationToken cancellationToken);
|
||||
Task AddDispatchAsync(Dispatch dispatch, CancellationToken cancellationToken);
|
||||
Task StageWorkOrderAuditAsync(WorkOrderAuditLog log, CancellationToken cancellationToken);
|
||||
Task StageVendorAuditAsync(VendorAuditLog log, CancellationToken cancellationToken);
|
||||
Task AddSitePreferredVendorAsync(SitePreferredVendor preference, CancellationToken cancellationToken);
|
||||
Task RemoveSitePreferredVendorsAsync(IEnumerable<SitePreferredVendor> preferences, CancellationToken cancellationToken);
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,27 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public enum BoardSaveOutcome
|
||||
{
|
||||
Saved,
|
||||
ConcurrencyConflict
|
||||
}
|
||||
|
||||
public interface IWorkOrderBoardMutationDataService
|
||||
{
|
||||
Task<bool> VendorExistsAsync(int vendorId, CancellationToken cancellationToken);
|
||||
Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken);
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<Dispatch?> GetTrackedDispatchAsync(int dispatchId, int workOrderId, CancellationToken cancellationToken);
|
||||
Task<Dispatch?> GetTrackedDispatchByIdAsync(int dispatchId, CancellationToken cancellationToken);
|
||||
void TrackNewWorkOrder(WorkOrder workOrder);
|
||||
void TrackNewDispatch(Dispatch dispatch);
|
||||
void TrackWorkOrderContact(int workOrderId, int contactId, string? notes);
|
||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||
void SetExpectedDispatchVersion(Dispatch dispatch, byte[] version);
|
||||
bool HasPendingChanges();
|
||||
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
|
||||
Task<BoardSaveOutcome> SaveAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderCompletionDataService
|
||||
{
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
|
||||
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderDetailDataService
|
||||
{
|
||||
Task<bool> ExistsAsync(int workOrderId);
|
||||
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
|
||||
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
|
||||
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
|
||||
}
|
||||
|
||||
public interface ICompletionDocTemplateDataService
|
||||
{
|
||||
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
|
||||
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
|
||||
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
|
||||
Task UpdateAsync(CompletionDocTemplate template);
|
||||
Task<bool> DeleteAsync(int id);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
{
|
||||
Task<bool> IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken);
|
||||
|
||||
void EnqueueLock(int workOrderId, string fieldName, string? userId);
|
||||
Task EnqueueLockAsync(
|
||||
int workOrderId,
|
||||
string fieldName,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderIngestDataService
|
||||
{
|
||||
Task<int> GetMaxWorkOrderIdAsync(CancellationToken cancellationToken);
|
||||
Task<WorkOrder?> GetTrackedByExternalIdAsync(string externalWorkOrderId, CancellationToken cancellationToken);
|
||||
void TrackWorkOrder(WorkOrder workOrder);
|
||||
Task<Locations?> FindLocationAsync(string name, CancellationToken cancellationToken);
|
||||
Task AddAndSaveLocationAsync(Locations location, CancellationToken cancellationToken);
|
||||
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> work, CancellationToken cancellationToken);
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderMediaDataService
|
||||
{
|
||||
Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
|
||||
Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken);
|
||||
void TrackAttachment(WorkOrderAttachments attachment);
|
||||
Task SaveAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderOpsHealthDataService
|
||||
{
|
||||
Task<int> CountSyncRejectedSinceAsync(DateTime since, CancellationToken cancellationToken);
|
||||
Task<int> CountFieldLocksAsync(CancellationToken cancellationToken);
|
||||
Task<DateTime?> GetLastWeekRolledProcessedAtAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
65
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
65
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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,
|
||||
string? ServiceNotes,
|
||||
string? ExtraServices,
|
||||
DocStatus? DocStatus,
|
||||
string? FlagColor,
|
||||
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);
|
||||
}
|
||||
33
SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
Normal file
33
SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public record WorkOrderDetailExtendedFields(
|
||||
string? Description,
|
||||
string? Trade,
|
||||
string? Problem,
|
||||
string? SubTrade,
|
||||
DateOnly? OriginalWeek,
|
||||
DateOnly? OriginalDate,
|
||||
DateTime? CompletedDate,
|
||||
string? SignOffName,
|
||||
string? SignOffAttachment,
|
||||
string? SignOffSignature,
|
||||
string? ExtraServices,
|
||||
string? ServiceNotes,
|
||||
string? VendorNotes);
|
||||
|
||||
public record DispatchSignoffRow(
|
||||
int DispatchId,
|
||||
string? SignoffType,
|
||||
string? Name,
|
||||
DateTime? SignedAt);
|
||||
|
||||
public record CompletionDocTemplateRow(
|
||||
int Id,
|
||||
string Name,
|
||||
string ServiceKey,
|
||||
WorkOrderType? WorkOrderType,
|
||||
string TemplateUrl,
|
||||
bool IsActive);
|
||||
}
|
||||
43
SeaHaven.DataServices/Models/VendorOperationsReadModels.cs
Normal file
43
SeaHaven.DataServices/Models/VendorOperationsReadModels.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
namespace SeaHaven.DataServices.Models;
|
||||
|
||||
public sealed class ScheduledDispatchReadModel
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
public sealed class NoVendorCandidateReadModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? InternalWONumber { get; set; }
|
||||
public string? WorkerOrderNumber { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ActiveVendorReadModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? CompanyName { get; set; }
|
||||
public string? ContactName { get; set; }
|
||||
public string? TradeSpecialties { get; set; }
|
||||
public string? AvailabilityStatus { get; set; }
|
||||
public DateTime? AvailabilityUpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InsightDispatchData
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue