mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
Merge pull request #29 from Sea-Haven-Industries/codex/vendor-roadmap-completion-backend
Complete vendor operations roadmap backend
This commit is contained in:
commit
782d0e8ae9
21 changed files with 5630 additions and 2 deletions
313
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
313
Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
using System.Collections;
|
||||
using System.Security.Claims;
|
||||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class VendorOperationsControllerTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static VendorOperationsController NewController(ApplicationDbContext context)
|
||||
{
|
||||
var controller = new VendorOperationsController(
|
||||
context,
|
||||
new DispatchDataService(context),
|
||||
TimeProvider.System);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static object Prop(object source, string name) =>
|
||||
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||
|
||||
[Fact]
|
||||
public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours()
|
||||
{
|
||||
using var context = NewContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-48",
|
||||
WorkerOrderTitle = "Needs vendor",
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(47)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetNotifications();
|
||||
|
||||
var value = ((OkObjectResult)result).Value!;
|
||||
var items = ((IEnumerable)Prop(value, "Items")).Cast<object>().ToList();
|
||||
items.Should().ContainSingle();
|
||||
Prop(items[0], "Type").Should().Be("NoVendor");
|
||||
Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-ASSIGNED",
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(24)
|
||||
};
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = workOrder.ScheduledDate
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetNotifications();
|
||||
|
||||
var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast<object>();
|
||||
items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var site = new Locations { Name = "St Louis" };
|
||||
var preferred = new Vendor
|
||||
{
|
||||
CompanyName = "Preferred",
|
||||
ContactName = "Alex",
|
||||
IsActive = true,
|
||||
TradeSpecialties = "HVAC",
|
||||
AvailabilityStatus = "Available",
|
||||
AvailabilityUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-BUSY",
|
||||
ScheduledStart = DateTime.UtcNow.AddHours(1),
|
||||
ScheduledEnd = DateTime.UtcNow.AddHours(3)
|
||||
};
|
||||
context.AddRange(site, preferred, busy, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.SitePreferredVendors.Add(new SitePreferredVendor
|
||||
{
|
||||
LocationId = site.Id,
|
||||
VendorId = preferred.Id,
|
||||
Trade = "HVAC",
|
||||
SortOrder = 0
|
||||
});
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = busy.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = workOrder.ScheduledStart
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).GetAvailability(
|
||||
DateTime.UtcNow.AddHours(2),
|
||||
DateTime.UtcNow.AddHours(4),
|
||||
site.Id,
|
||||
"HVAC");
|
||||
|
||||
var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast<object>().ToList();
|
||||
Prop(rows[0], "CompanyName").Should().Be("Preferred");
|
||||
Prop(rows[0], "IsPreferred").Should().Be(true);
|
||||
var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy");
|
||||
Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceSitePreferences_RejectsInactiveVendor()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var site = new Locations { Name = "St Louis" };
|
||||
var inactive = new Vendor { CompanyName = "Inactive", IsActive = false };
|
||||
context.AddRange(site, inactive);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).ReplaceSitePreferences(site.Id,
|
||||
new List<VendorOperationsController.SitePreferenceRequest>
|
||||
{
|
||||
new() { VendorId = inactive.Id }
|
||||
});
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.SitePreferredVendors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsightsExports_ReturnStableCsvAndPdfFormats()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false };
|
||||
var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Completed",
|
||||
DispatchedAt = DateTime.UtcNow.AddHours(-4),
|
||||
ScheduledDate = DateTime.UtcNow.AddHours(-2),
|
||||
AcknowledgedAt = DateTime.UtcNow.AddHours(-3),
|
||||
CompletedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var controller = NewController(context);
|
||||
|
||||
var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null);
|
||||
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null);
|
||||
|
||||
csv.ContentType.Should().Be("text/csv");
|
||||
var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents);
|
||||
csvText.Should().Contain("AverageCycleHours");
|
||||
csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\"");
|
||||
pdf.ContentType.Should().Be("application/pdf");
|
||||
System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_RejectsInactiveVendor()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var vendor = new Vendor { CompanyName = "Inactive", IsActive = false };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
workOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id });
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.Dispatches.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var start = DateTime.UtcNow.AddHours(2);
|
||||
var vendor = new Vendor
|
||||
{
|
||||
CompanyName = "Gateway",
|
||||
IsActive = true,
|
||||
AvailabilityStatus = "Available",
|
||||
AvailabilityUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var existingWorkOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-EXISTING",
|
||||
ScheduledStart = start,
|
||||
ScheduledEnd = start.AddHours(2)
|
||||
};
|
||||
var assignedWorkOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-NEW",
|
||||
ScheduledStart = start.AddMinutes(30),
|
||||
ScheduledEnd = start.AddHours(1)
|
||||
};
|
||||
context.AddRange(vendor, existingWorkOrder, assignedWorkOrder);
|
||||
await context.SaveChangesAsync();
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = existingWorkOrder.Id,
|
||||
Status = "Sent",
|
||||
ScheduledDate = existingWorkOrder.ScheduledStart
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
var eta = start.AddMinutes(45);
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
assignedWorkOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
ScheduledStart = assignedWorkOrder.ScheduledStart,
|
||||
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
|
||||
EstimatedArrivalAt = eta,
|
||||
EtaManualOverride = true
|
||||
});
|
||||
|
||||
var value = ((OkObjectResult)result).Value!;
|
||||
Prop(value, "AvailabilityStatus").Should().Be("Unavailable");
|
||||
((IEnumerable)Prop(value, "Conflicts")).Cast<object>().Should().ContainSingle();
|
||||
var dispatch = await context.Dispatches.SingleAsync(item =>
|
||||
item.WorkOrderId == assignedWorkOrder.Id);
|
||||
dispatch.EstimatedArrivalAt.Should().Be(eta);
|
||||
dispatch.EtaManualOverride.Should().BeTrue();
|
||||
dispatch.DispatchNumber.Should().StartWith("DISP-");
|
||||
context.WorkOrderAuditLogs.Should().ContainSingle(log =>
|
||||
log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var originalVendor = new Vendor { CompanyName = "Original", IsActive = true };
|
||||
var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true };
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
InternalWONumber = "WO-LINKED",
|
||||
ScheduledStart = DateTime.UtcNow.AddHours(2),
|
||||
ScheduledEnd = DateTime.UtcNow.AddHours(4)
|
||||
};
|
||||
context.AddRange(originalVendor, replacementVendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = originalVendor.Id,
|
||||
Status = "Sent",
|
||||
DispatchNumber = "DISP-LINKED"
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
await context.SaveChangesAsync();
|
||||
context.DispatchWorkOrders.Add(new DispatchWorkOrder
|
||||
{
|
||||
DispatchId = dispatch.Id,
|
||||
WorkOrderId = workOrder.Id
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await NewController(context).AssignVendor(
|
||||
workOrder.Id,
|
||||
new VendorOperationsController.VendorAssignmentRequest
|
||||
{
|
||||
VendorId = replacementVendor.Id,
|
||||
ScheduledStart = workOrder.ScheduledStart,
|
||||
ScheduledEnd = workOrder.ScheduledEnd
|
||||
});
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.Dispatches.Should().ContainSingle();
|
||||
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
|
||||
}
|
||||
}
|
||||
164
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
164
Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Api.SeaHavenIndustries.Helper;
|
||||
using Data.SeaHavenIndustries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class VendorPortalDocumentTests : IDisposable
|
||||
{
|
||||
private const string Token = "vendor-document-test-token";
|
||||
private readonly string _contentRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"seahaven-vendor-documents-{Guid.NewGuid():N}");
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private VendorPortalController NewController(ApplicationDbContext context)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["VendorPortal:TokenLifetimeDays"] = "365",
|
||||
["SendGrid:ApiKey"] = "not-used"
|
||||
})
|
||||
.Build();
|
||||
var environment = new Mock<IWebHostEnvironment>();
|
||||
environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
|
||||
var controller = new VendorPortalController(
|
||||
context,
|
||||
new VendorPortalTokenService(context, configuration),
|
||||
configuration,
|
||||
new SendMessage(configuration),
|
||||
environment.Object);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
};
|
||||
controller.Request.Headers["X-Vendor-Token"] = Token;
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch(
|
||||
ApplicationDbContext context)
|
||||
{
|
||||
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
|
||||
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
|
||||
context.AddRange(vendor, workOrder);
|
||||
await context.SaveChangesAsync();
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
WorkOrderId = workOrder.Id,
|
||||
Status = "Completed"
|
||||
};
|
||||
context.Dispatches.Add(dispatch);
|
||||
context.VendorAccessTokens.Add(new VendorAccessToken
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
Token = Token,
|
||||
IssuedAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(1)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
return (vendor, dispatch);
|
||||
}
|
||||
|
||||
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
|
||||
{
|
||||
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = contentType
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf"));
|
||||
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var controller = NewController(context);
|
||||
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
|
||||
|
||||
var upload = await controller.UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(pdf, "completion.pdf", "application/pdf"));
|
||||
|
||||
upload.Should().BeOfType<OkObjectResult>();
|
||||
var document = await context.VendorCompletionDocuments.SingleAsync();
|
||||
document.ScanStatus.Should().Be("Pending");
|
||||
document.ReviewStatus.Should().Be("Processing");
|
||||
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
|
||||
|
||||
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
|
||||
|
||||
var locked = download.Should().BeOfType<ObjectResult>().Subject;
|
||||
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(pdf, "completion.pdf", "Application/PDF"));
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
|
||||
{
|
||||
using var context = NewContext();
|
||||
var (_, dispatch) = await SeedDispatch(context);
|
||||
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 };
|
||||
|
||||
var result = await NewController(context).UploadCompletionDocument(
|
||||
dispatch.Id,
|
||||
FormFile(png, "photo.png", "Image/PNG"));
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
context.VendorCompletionDocuments.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_contentRoot))
|
||||
{
|
||||
Directory.Delete(_contentRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
724
Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/vendor-operations")]
|
||||
public class VendorOperationsController : ControllerBase
|
||||
{
|
||||
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IDispatchDataService _dispatchDataService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VendorOperationsController(
|
||||
ApplicationDbContext db,
|
||||
IDispatchDataService dispatchDataService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_db = db;
|
||||
_dispatchDataService = dispatchDataService;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
[HttpGet("notifications")]
|
||||
public async Task<IActionResult> GetNotifications()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var cutoff = now.AddHours(48);
|
||||
|
||||
var directlyAssignedIds = await _db.Dispatches
|
||||
.AsNoTracking()
|
||||
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
|
||||
.Select(d => d.WorkOrderId!.Value)
|
||||
.ToListAsync();
|
||||
var multiplyAssignedIds = await _db.DispatchWorkOrders
|
||||
.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
|
||||
.Select(link => link.WorkOrderId)
|
||||
.ToListAsync();
|
||||
var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
|
||||
|
||||
var noVendor = await _db.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(wo => wo.ScheduledDate.HasValue
|
||||
&& wo.ScheduledDate.Value <= cutoff
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& wo.LifecycleStatus != LifecycleStatus.Canceled
|
||||
&& !assignedWorkOrderIds.Contains(wo.Id))
|
||||
.OrderBy(wo => wo.ScheduledDate)
|
||||
.Select(wo => new
|
||||
{
|
||||
Id = $"no-vendor-{wo.Id}",
|
||||
Type = "NoVendor",
|
||||
Severity = "Medium",
|
||||
Group = "No Vendor",
|
||||
Title = "No vendor assigned — 48h to service",
|
||||
WorkOrderId = wo.Id,
|
||||
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
|
||||
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
|
||||
ScheduledAt = wo.ScheduledDate,
|
||||
Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var conflicts = BuildConflictPairs(dispatches)
|
||||
.Select(pair => new
|
||||
{
|
||||
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
|
||||
Type = "VendorConflict",
|
||||
Severity = "Medium",
|
||||
Group = "Vendor Conflict",
|
||||
Title = "Vendor has overlapping appointments",
|
||||
VendorId = pair.Left.VendorId,
|
||||
VendorName = pair.Left.VendorName,
|
||||
WorkOrders = new[]
|
||||
{
|
||||
new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End },
|
||||
new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End }
|
||||
},
|
||||
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
|
||||
Actions = new[]
|
||||
{
|
||||
new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
|
||||
new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = now,
|
||||
Items = noVendor.Cast<object>().Concat(conflicts).OrderBy(item =>
|
||||
item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("availability")]
|
||||
public async Task<IActionResult> GetAvailability(
|
||||
[FromQuery] DateTime? start,
|
||||
[FromQuery] DateTime? end,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var requestedStart = start ?? now;
|
||||
var requestedEnd = end ?? requestedStart.AddHours(2);
|
||||
if (requestedEnd <= requestedStart)
|
||||
return BadRequest(new Response { Status = "Error", Message = "end must be after start" });
|
||||
|
||||
var vendors = await _db.Vendors.AsNoTracking()
|
||||
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
|
||||
.ToListAsync();
|
||||
|
||||
var preferences = locationId.HasValue
|
||||
? await _db.SitePreferredVendors.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId.Value)
|
||||
.ToListAsync()
|
||||
: new List<SitePreferredVendor>();
|
||||
|
||||
var dispatches = await ActiveScheduledDispatches().ToListAsync();
|
||||
var result = vendors.Select(vendor =>
|
||||
{
|
||||
var matchingDispatches = dispatches
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id
|
||||
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
|
||||
.ToList();
|
||||
var matchingPreference = preferences
|
||||
.Where(preference => preference.VendorId == vendor.Id
|
||||
&& (string.IsNullOrWhiteSpace(preference.Trade)
|
||||
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(preference => preference.SortOrder)
|
||||
.FirstOrDefault();
|
||||
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
|
||||
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
|
||||
var status = matchingDispatches.Count > 0
|
||||
? "Unavailable"
|
||||
: signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus)
|
||||
? vendor.AvailabilityStatus
|
||||
: "Unknown";
|
||||
|
||||
return new
|
||||
{
|
||||
vendor.Id,
|
||||
vendor.CompanyName,
|
||||
vendor.ContactName,
|
||||
vendor.TradeSpecialties,
|
||||
AvailabilityStatus = status,
|
||||
vendor.AvailabilityUpdatedAt,
|
||||
IsPreferred = matchingPreference != null,
|
||||
PreferenceRank = matchingPreference?.SortOrder,
|
||||
Conflicts = matchingDispatches.Select(dispatch => new
|
||||
{
|
||||
dispatch.WorkOrderId,
|
||||
dispatch.WorkOrderNumber,
|
||||
dispatch.Start,
|
||||
dispatch.End
|
||||
})
|
||||
};
|
||||
})
|
||||
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
|
||||
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
|
||||
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
|
||||
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
|
||||
.ThenBy(vendor => vendor.CompanyName)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result });
|
||||
}
|
||||
|
||||
[HttpPut("vendors/{vendorId:int}/availability")]
|
||||
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body)
|
||||
{
|
||||
var normalized = body.Status?.Trim();
|
||||
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" });
|
||||
|
||||
var vendor = await _db.Vendors.FindAsync(vendorId);
|
||||
if (vendor == null) return NotFound();
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var previous = vendor.AvailabilityStatus;
|
||||
vendor.AvailabilityStatus = normalized;
|
||||
vendor.AvailabilityUpdatedAt = now;
|
||||
vendor.LastModificationTime = now;
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = nameof(Vendor.AvailabilityStatus),
|
||||
OldValue = previous,
|
||||
NewValue = normalized,
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt });
|
||||
}
|
||||
|
||||
[HttpGet("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> GetSitePreferences(int locationId)
|
||||
{
|
||||
var preferences = await _db.SitePreferredVendors
|
||||
.AsNoTracking()
|
||||
.Where(p => p.LocationId == locationId)
|
||||
.Include(p => p.Vendor)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.LocationId,
|
||||
p.VendorId,
|
||||
VendorName = p.Vendor!.CompanyName,
|
||||
p.Trade,
|
||||
p.SortOrder,
|
||||
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
|
||||
p.LastModificationTime,
|
||||
p.UpdatedBy
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(preferences);
|
||||
}
|
||||
|
||||
[HttpPut("sites/{locationId:int}/preferred-vendors")]
|
||||
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body)
|
||||
{
|
||||
if (!await _db.Locations.AnyAsync(location => location.Id == locationId))
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" });
|
||||
|
||||
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
|
||||
var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id);
|
||||
foreach (var item in body)
|
||||
{
|
||||
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" });
|
||||
if (!string.IsNullOrWhiteSpace(item.Trade)
|
||||
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" });
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var actor = Actor();
|
||||
var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync();
|
||||
_db.SitePreferredVendors.RemoveRange(existing);
|
||||
_db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor
|
||||
{
|
||||
LocationId = locationId,
|
||||
VendorId = item.VendorId,
|
||||
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
|
||||
SortOrder = item.SortOrder ?? index,
|
||||
CreatedDate = now,
|
||||
LastModificationTime = now,
|
||||
createdby = actor,
|
||||
UpdatedBy = actor
|
||||
}));
|
||||
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
|
||||
{
|
||||
_db.VendorAuditLogs.Add(new VendorAuditLog
|
||||
{
|
||||
VendorId = vendorId,
|
||||
FieldName = $"SitePreference:{locationId}",
|
||||
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
|
||||
Actor = actor,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetSitePreferences(locationId);
|
||||
}
|
||||
|
||||
[HttpPut("documents/{documentId:int}/review")]
|
||||
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body)
|
||||
{
|
||||
if (body.ReviewStatus is not ("Approved" or "Rejected"))
|
||||
return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" });
|
||||
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
|
||||
return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" });
|
||||
|
||||
var document = await _db.VendorCompletionDocuments.FindAsync(documentId);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" });
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previous = document.ReviewStatus;
|
||||
document.ReviewStatus = body.ReviewStatus;
|
||||
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
|
||||
document.ReviewedAt = now;
|
||||
document.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Completion Document {document.OriginalFileName}",
|
||||
OldValue = previous,
|
||||
NewValue = document.ReviewStatus,
|
||||
Action = "completion_document_reviewed",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt });
|
||||
}
|
||||
|
||||
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
|
||||
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body)
|
||||
{
|
||||
var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" };
|
||||
var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
|
||||
if (!invoiceStatuses.Contains(body.InvoiceStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" });
|
||||
if (!paymentStatuses.Contains(body.PaymentStatus))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" });
|
||||
|
||||
var dispatch = await _db.Dispatches.FindAsync(dispatchId);
|
||||
if (dispatch == null) return NotFound();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
|
||||
dispatch.InvoiceStatus = body.InvoiceStatus;
|
||||
dispatch.PaymentStatus = body.PaymentStatus;
|
||||
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
|
||||
dispatch.CommercialStatusUpdatedAt = now;
|
||||
dispatch.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = Actor(),
|
||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
|
||||
OldValue = oldValue,
|
||||
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
|
||||
Action = "commercial_status_updated",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("work-orders/{workOrderId:int}/assignment")]
|
||||
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body)
|
||||
{
|
||||
var workOrder = await _db.workOrders.FindAsync(workOrderId);
|
||||
if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" });
|
||||
var vendor = await _db.Vendors.FindAsync(body.VendorId);
|
||||
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" });
|
||||
|
||||
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
|
||||
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
|
||||
var scheduledDispatches = start.HasValue && end.HasValue
|
||||
? await ActiveScheduledDispatches()
|
||||
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId)
|
||||
.ToListAsync()
|
||||
: new List<ScheduledDispatch>();
|
||||
var conflicts = start.HasValue && end.HasValue
|
||||
? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
|
||||
: new List<ScheduledDispatch>();
|
||||
|
||||
var linkedDispatchIds = _db.DispatchWorkOrders
|
||||
.Where(link => link.WorkOrderId == workOrderId)
|
||||
.Select(link => link.DispatchId);
|
||||
var dispatch = await _db.Dispatches
|
||||
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
|
||||
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
|
||||
.OrderByDescending(item => item.CreatedDate)
|
||||
.FirstOrDefaultAsync();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var previousVendorId = dispatch?.VendorId;
|
||||
if (dispatch == null)
|
||||
{
|
||||
dispatch = new Dispatch
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
VendorId = vendor.Id,
|
||||
DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(),
|
||||
PONumber = await _dispatchDataService.GenerateNextPONumberAsync(),
|
||||
Status = "Sent",
|
||||
ScheduledDate = start,
|
||||
EstimatedArrivalAt = body.EstimatedArrivalAt,
|
||||
EtaManualOverride = body.EtaManualOverride,
|
||||
DispatchedAt = now,
|
||||
CreatedDate = now,
|
||||
createdby = Actor()
|
||||
};
|
||||
_db.Dispatches.Add(dispatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
dispatch.VendorId = vendor.Id;
|
||||
dispatch.ScheduledDate = start;
|
||||
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
|
||||
dispatch.EtaManualOverride = body.EtaManualOverride;
|
||||
dispatch.LastModificationTime = now;
|
||||
}
|
||||
|
||||
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
|
||||
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
|
||||
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
|
||||
workOrder.LastModificationTime = now;
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
UserId = Actor(),
|
||||
FieldName = "Vendor Assignment",
|
||||
OldValue = previousVendorId?.ToString(),
|
||||
NewValue = vendor.Id.ToString(),
|
||||
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
dispatch.Id,
|
||||
dispatch.VendorId,
|
||||
VendorName = vendor.CompanyName,
|
||||
dispatch.EstimatedArrivalAt,
|
||||
dispatch.EtaManualOverride,
|
||||
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
|
||||
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
|
||||
Conflicts = conflicts.Select(conflict => new
|
||||
{
|
||||
conflict.WorkOrderId,
|
||||
conflict.WorkOrderNumber,
|
||||
conflict.Start,
|
||||
conflict.End
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights")]
|
||||
public async Task<IActionResult> GetInsights(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
return Ok(new
|
||||
{
|
||||
GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime,
|
||||
Filters = new { from, to, locationId, trade, vendorId },
|
||||
MetricDefinitions = MetricDefinitions(),
|
||||
Data = rows
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("insights.csv")]
|
||||
public async Task<IActionResult> ExportInsightsCsv(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
|
||||
foreach (var row in rows)
|
||||
{
|
||||
csv.AppendLine(string.Join(",",
|
||||
row.VendorId,
|
||||
Csv(row.Vendor),
|
||||
row.CompletedJobs,
|
||||
row.CancellationRefusalRate.ToString("0.####"),
|
||||
row.OnTimeArrivalRate.ToString("0.####"),
|
||||
row.OnTimeCompletionRate.ToString("0.####"),
|
||||
row.AverageCycleHours?.ToString("0.##") ?? ""));
|
||||
}
|
||||
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||
}
|
||||
|
||||
[HttpGet("insights.pdf")]
|
||||
public async Task<IActionResult> ExportInsightsPdf(
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int? locationId,
|
||||
[FromQuery] string? trade,
|
||||
[FromQuery] int? vendorId)
|
||||
{
|
||||
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
|
||||
var lines = new List<string>
|
||||
{
|
||||
"Vendor Performance Insights",
|
||||
$"Generated: {_timeProvider.GetUtcNow():u}",
|
||||
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
|
||||
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
|
||||
};
|
||||
lines.AddRange(rows.Take(35).Select(row =>
|
||||
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
|
||||
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf");
|
||||
}
|
||||
|
||||
private IQueryable<ScheduledDispatch> ActiveScheduledDispatches()
|
||||
{
|
||||
var direct = _db.Dispatches.AsNoTracking()
|
||||
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
|
||||
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
|
||||
&& dispatch.WorkOrderId.HasValue
|
||||
&& (dispatch.ScheduledDate.HasValue
|
||||
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
|
||||
.Select(dispatch => new ScheduledDispatch
|
||||
{
|
||||
Id = dispatch.Id,
|
||||
VendorId = dispatch.VendorId,
|
||||
VendorName = dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = dispatch.WorkOrderId!.Value,
|
||||
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
|
||||
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
|
||||
End = dispatch.WorkOrder.ScheduledEnd
|
||||
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
var linked = _db.DispatchWorkOrders.AsNoTracking()
|
||||
.Where(link => link.Dispatch != null
|
||||
&& link.WorkOrder != null
|
||||
&& link.Dispatch.WorkOrderId != link.WorkOrderId
|
||||
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
|
||||
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
|
||||
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
|
||||
.Select(link => new ScheduledDispatch
|
||||
{
|
||||
Id = link.DispatchId,
|
||||
VendorId = link.Dispatch!.VendorId,
|
||||
VendorName = link.Dispatch.Vendor!.CompanyName,
|
||||
WorkOrderId = link.WorkOrderId,
|
||||
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
|
||||
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
|
||||
End = link.WorkOrder.ScheduledEnd
|
||||
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)
|
||||
});
|
||||
|
||||
return direct.Concat(linked);
|
||||
}
|
||||
|
||||
private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List<ScheduledDispatch> dispatches)
|
||||
{
|
||||
for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++)
|
||||
{
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++)
|
||||
{
|
||||
var left = dispatches[leftIndex];
|
||||
var right = dispatches[rightIndex];
|
||||
if (left.VendorId == right.VendorId
|
||||
&& left.WorkOrderId != right.WorkOrderId
|
||||
&& WindowsOverlap(left.Start, left.End, right.Start, right.End))
|
||||
yield return (left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<VendorInsightRow>> BuildInsightRows(
|
||||
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId)
|
||||
{
|
||||
var query = _db.Dispatches.AsNoTracking()
|
||||
.Include(dispatch => dispatch.Vendor)
|
||||
.Include(dispatch => dispatch.WorkOrder)
|
||||
.Where(dispatch => dispatch.Vendor != null);
|
||||
if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from);
|
||||
if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to);
|
||||
if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId);
|
||||
if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade);
|
||||
if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId);
|
||||
|
||||
var dispatches = await query.ToListAsync();
|
||||
return dispatches.GroupBy(dispatch => dispatch.VendorId)
|
||||
.Select(group =>
|
||||
{
|
||||
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
|
||||
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
|
||||
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
|
||||
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList();
|
||||
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
|
||||
return new VendorInsightRow
|
||||
{
|
||||
VendorId = group.Key,
|
||||
Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}",
|
||||
CompletedJobs = completed.Count,
|
||||
CancellationRefusalRate = Rate(canceled, group.Count()),
|
||||
OnTimeArrivalRate = Rate(
|
||||
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
|
||||
acknowledgedWithSchedule.Count),
|
||||
OnTimeCompletionRate = Rate(
|
||||
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate),
|
||||
completedWithDue.Count),
|
||||
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
|
||||
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
|
||||
};
|
||||
})
|
||||
.OrderBy(row => row.Vendor)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
|
||||
=> leftStart < rightEnd && rightStart < leftEnd;
|
||||
|
||||
private static decimal Rate(int numerator, int denominator)
|
||||
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
|
||||
? $"'{value}"
|
||||
: value;
|
||||
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
private static object MetricDefinitions() => new
|
||||
{
|
||||
CompletedJobs = "Dispatches with Completed or Verified status.",
|
||||
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
|
||||
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
|
||||
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
|
||||
AverageCycleHours = "Average elapsed hours from dispatch to completion."
|
||||
};
|
||||
|
||||
private static byte[] SimplePdf(IEnumerable<string> lines)
|
||||
{
|
||||
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
|
||||
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
|
||||
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
|
||||
+ " ET";
|
||||
var objects = new[]
|
||||
{
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
||||
};
|
||||
var builder = new StringBuilder("%PDF-1.4\n");
|
||||
var offsets = new List<int> { 0 };
|
||||
for (var index = 0; index < objects.Length; index++)
|
||||
{
|
||||
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
|
||||
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
|
||||
}
|
||||
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
|
||||
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
|
||||
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
|
||||
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
|
||||
return Encoding.ASCII.GetBytes(builder.ToString());
|
||||
}
|
||||
|
||||
public sealed class AvailabilityRequest
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SitePreferenceRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public int? SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DocumentReviewRequest
|
||||
{
|
||||
public string ReviewStatus { get; set; } = "";
|
||||
public string? RejectionReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CommercialStatusRequest
|
||||
{
|
||||
public string InvoiceStatus { get; set; } = "Not Submitted";
|
||||
public string PaymentStatus { get; set; } = "Unavailable";
|
||||
public string? InvoiceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VendorAssignmentRequest
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
public DateTime? ScheduledEnd { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ScheduledDispatch
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int VendorId { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public int WorkOrderId { get; set; }
|
||||
public string? WorkOrderNumber { get; set; }
|
||||
public DateTime Start { get; set; }
|
||||
public DateTime End { get; set; }
|
||||
}
|
||||
|
||||
private sealed class VendorInsightRow
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string Vendor { get; set; } = "";
|
||||
public int CompletedJobs { get; set; }
|
||||
public decimal CancellationRefusalRate { get; set; }
|
||||
public decimal OnTimeArrivalRate { get; set; }
|
||||
public decimal OnTimeCompletionRate { get; set; }
|
||||
public double? AverageCycleHours { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly VendorPortalTokenService _tokens;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly SendMessage _sendMessage;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
|
||||
public VendorPortalController(
|
||||
ApplicationDbContext db,
|
||||
VendorPortalTokenService tokens,
|
||||
IConfiguration config,
|
||||
SendMessage sendMessage,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_db = db;
|
||||
_tokens = tokens;
|
||||
_config = config;
|
||||
_sendMessage = sendMessage;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpGet("session")]
|
||||
|
|
@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Id,
|
||||
d.DispatchNumber,
|
||||
d.PONumber,
|
||||
d.InvoiceNumber,
|
||||
d.InvoiceStatus,
|
||||
d.PaymentStatus,
|
||||
d.Status,
|
||||
d.NTEAmount,
|
||||
d.ScheduledDate,
|
||||
|
|
@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var documents = await _db.VendorCompletionDocuments
|
||||
.Where(document => document.DispatchId == id && document.VendorId == vendor.Id)
|
||||
.OrderByDescending(document => document.CreatedDate)
|
||||
.Select(document => new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.ContentType,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.RejectionReason,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId,
|
||||
document.CreatedDate,
|
||||
document.ScannedAt,
|
||||
document.ReviewedAt,
|
||||
CanDownload = document.ScanStatus == "Passed"
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rawUplifts = await (from u in _db.DispatchUpliftRequests
|
||||
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
|
||||
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
|
||||
|
|
@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
dispatch.Id,
|
||||
dispatch.DispatchNumber,
|
||||
dispatch.PONumber,
|
||||
dispatch.InvoiceNumber,
|
||||
dispatch.InvoiceStatus,
|
||||
dispatch.PaymentStatus,
|
||||
dispatch.CommercialStatusUpdatedAt,
|
||||
dispatch.Status,
|
||||
dispatch.NTEAmount,
|
||||
dispatch.Description,
|
||||
|
|
@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
},
|
||||
Checklist = checklist,
|
||||
Signoffs = signoffs,
|
||||
Documents = documents,
|
||||
Comments = comments,
|
||||
UpliftRequests = upliftRequests
|
||||
}
|
||||
|
|
@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
||||
}
|
||||
|
||||
[HttpPost("dispatches/{id:int}/documents")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadCompletionDocument(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] int? replacesDocumentId = null)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" });
|
||||
if (file.Length > 10 * 1024 * 1024)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" });
|
||||
|
||||
var allowed = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["application/pdf"] = new[] { ".pdf" },
|
||||
["image/jpeg"] = new[] { ".jpg", ".jpeg" },
|
||||
["image/png"] = new[] { ".png" }
|
||||
};
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
if (!allowed.TryGetValue(file.ContentType, out var extensions)
|
||||
|| !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||
return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" });
|
||||
|
||||
await using var signatureStream = file.OpenReadStream();
|
||||
if (!await HasExpectedSignature(signatureStream, file.ContentType))
|
||||
return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" });
|
||||
|
||||
var workOrderId = dispatch.WorkOrderId
|
||||
?? await _db.DispatchWorkOrders
|
||||
.Where(link => link.DispatchId == dispatch.Id)
|
||||
.OrderBy(link => link.Id)
|
||||
.Select(link => link.WorkOrderId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (workOrderId == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" });
|
||||
|
||||
VendorCompletionDocument? replaced = null;
|
||||
if (replacesDocumentId.HasValue)
|
||||
{
|
||||
replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document =>
|
||||
document.Id == replacesDocumentId.Value
|
||||
&& document.DispatchId == id
|
||||
&& document.VendorId == vendor.Id);
|
||||
if (replaced == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" });
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var document = new VendorCompletionDocument
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
DispatchId = id,
|
||||
WorkOrderId = workOrderId,
|
||||
OriginalFileName = Path.GetFileName(file.FileName),
|
||||
StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}",
|
||||
ContentType = file.ContentType,
|
||||
SizeBytes = file.Length,
|
||||
ScanStatus = "Pending",
|
||||
ReviewStatus = "Processing",
|
||||
Version = (replaced?.Version ?? 0) + 1,
|
||||
ReplacesDocumentId = replaced?.Id,
|
||||
CreatedDate = now,
|
||||
createdby = $"vendor:{vendor.Id}"
|
||||
};
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using (var target = System.IO.File.Create(path))
|
||||
{
|
||||
await file.CopyToAsync(target);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_db.VendorCompletionDocuments.Add(document);
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = document.WorkOrderId,
|
||||
FieldName = "Completion Document",
|
||||
OldValue = replaced?.OriginalFileName,
|
||||
NewValue = document.OriginalFileName,
|
||||
Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced",
|
||||
CreatedAt = now
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Ok(new DataResponse
|
||||
{
|
||||
Status = "Success",
|
||||
Data = new
|
||||
{
|
||||
document.Id,
|
||||
document.OriginalFileName,
|
||||
document.SizeBytes,
|
||||
document.ScanStatus,
|
||||
document.ReviewStatus,
|
||||
document.Version,
|
||||
document.ReplacesDocumentId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
|
||||
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId)
|
||||
{
|
||||
var vendor = await ResolveVendorAsync();
|
||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||
var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item =>
|
||||
item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id);
|
||||
if (document == null) return NotFound();
|
||||
if (document.ScanStatus != "Passed")
|
||||
return StatusCode(StatusCodes.Status423Locked,
|
||||
new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" });
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
if (!System.IO.File.Exists(path)) return NotFound();
|
||||
return PhysicalFile(path, document.ContentType, document.OriginalFileName);
|
||||
}
|
||||
|
||||
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
|
||||
{
|
||||
try
|
||||
|
|
@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> HasExpectedSignature(Stream stream, string contentType)
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
var count = await stream.ReadAsync(bytes);
|
||||
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D;
|
||||
if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase))
|
||||
return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<Vendor?> ResolveVendorAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||
|
|
|
|||
|
|
@ -494,6 +494,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Description,
|
||||
d.Status,
|
||||
d.ScheduledDate,
|
||||
d.EstimatedArrivalAt,
|
||||
d.EtaManualOverride,
|
||||
d.CompletedDate,
|
||||
d.DispatchedAt,
|
||||
d.EmailSent,
|
||||
|
|
@ -863,7 +865,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
foreach (var vendorId in model.VendorIds)
|
||||
{
|
||||
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
|
||||
if (vendor == null) continue;
|
||||
if (vendor == null)
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} was not found" });
|
||||
if (!vendor.IsActive || (vendor.IsDeleted ?? false))
|
||||
return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} is inactive and cannot receive a new assignment" });
|
||||
|
||||
var poNumber = $"VPO-{nextPO:D5}";
|
||||
nextPO++;
|
||||
|
|
@ -882,6 +887,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Description = model.Description,
|
||||
Status = "Sent",
|
||||
ScheduledDate = model.ScheduledDate,
|
||||
EstimatedArrivalAt = model.EstimatedArrivalAt,
|
||||
EtaManualOverride = model.EtaManualOverride,
|
||||
DispatchedAt = DateTime.UtcNow,
|
||||
ReplyToAddress = replyTo,
|
||||
DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList()
|
||||
|
|
@ -961,6 +968,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
Action = AuditAction.Dispatch.ToString(),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
if (model.EstimatedArrivalAt.HasValue)
|
||||
{
|
||||
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = wo.Id,
|
||||
UserId = userId,
|
||||
FieldName = "Vendor ETA",
|
||||
OldValue = "",
|
||||
NewValue = model.EstimatedArrivalAt.Value.ToUniversalTime().ToString("O"),
|
||||
Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_set",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count });
|
||||
|
|
@ -987,6 +1007,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
d.Description,
|
||||
d.Status,
|
||||
d.ScheduledDate,
|
||||
d.EstimatedArrivalAt,
|
||||
d.EtaManualOverride,
|
||||
d.CompletedDate,
|
||||
d.DispatchedAt,
|
||||
d.EmailSent,
|
||||
|
|
@ -1013,6 +1035,8 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
x.Description,
|
||||
x.Status,
|
||||
x.ScheduledDate,
|
||||
x.EstimatedArrivalAt,
|
||||
x.EtaManualOverride,
|
||||
x.CompletedDate,
|
||||
x.DispatchedAt,
|
||||
x.EmailSent,
|
||||
|
|
@ -1101,6 +1125,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
dispatch.Status = model.Status;
|
||||
dispatch.NTEAmount = model.NTEAmount;
|
||||
dispatch.ScheduledDate = model.ScheduledDate;
|
||||
if (dispatch.EstimatedArrivalAt != model.EstimatedArrivalAt)
|
||||
{
|
||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
||||
UserId = userId,
|
||||
FieldName = "Vendor ETA",
|
||||
OldValue = dispatch.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "",
|
||||
NewValue = model.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "",
|
||||
Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_change",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
dispatch.EstimatedArrivalAt = model.EstimatedArrivalAt;
|
||||
dispatch.EtaManualOverride = model.EtaManualOverride;
|
||||
dispatch.CompletedDate = model.CompletedDate;
|
||||
dispatch.Description = model.Description;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public List<int> VendorIds { get; set; } = new();
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? TaskListTemplateId { get; set; }
|
||||
public List<string>? CustomChecklistItems { get; set; }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public string? Status { get; set; }
|
||||
public decimal? NTEAmount { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
|
|
|||
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
70
Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public enum DocumentScanResult
|
||||
{
|
||||
Passed,
|
||||
Infected,
|
||||
Unavailable
|
||||
}
|
||||
|
||||
public interface IDocumentScanner
|
||||
{
|
||||
Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class ClamAvDocumentScanner : IDocumentScanner
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public ClamAvDocumentScanner(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var host = _configuration["ClamAV:Host"];
|
||||
var port = _configuration.GetValue<int?>("ClamAV:Port") ?? 3310;
|
||||
if (string.IsNullOrWhiteSpace(host) || !File.Exists(path))
|
||||
return DocumentScanResult.Unavailable;
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellationToken);
|
||||
await using var network = client.GetStream();
|
||||
await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken);
|
||||
await using var file = File.OpenRead(path);
|
||||
var buffer = new byte[64 * 1024];
|
||||
while (true)
|
||||
{
|
||||
var count = await file.ReadAsync(buffer, cancellationToken);
|
||||
if (count == 0) break;
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, count);
|
||||
await network.WriteAsync(length, cancellationToken);
|
||||
await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
|
||||
}
|
||||
await network.WriteAsync(new byte[4], cancellationToken);
|
||||
await network.FlushAsync(cancellationToken);
|
||||
|
||||
var responseBuffer = new byte[1024];
|
||||
var responseLength = await network.ReadAsync(responseBuffer, cancellationToken);
|
||||
var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength);
|
||||
if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
return DocumentScanResult.Infected;
|
||||
return response.Contains("OK", StringComparison.OrdinalIgnoreCase)
|
||||
? DocumentScanResult.Passed
|
||||
: DocumentScanResult.Unavailable;
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return DocumentScanResult.Unavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
75
Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Helper
|
||||
{
|
||||
public class VendorDocumentScanWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<VendorDocumentScanWorker> _logger;
|
||||
|
||||
public VendorDocumentScanWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<VendorDocumentScanWorker> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await ScanPendingAsync(stoppingToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
|
||||
var pending = await db.VendorCompletionDocuments
|
||||
.Where(document => document.ScanStatus == "Pending")
|
||||
.OrderBy(document => document.CreatedDate)
|
||||
.Take(20)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var document in pending)
|
||||
{
|
||||
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
|
||||
var result = await scanner.ScanAsync(path, cancellationToken);
|
||||
if (result == DocumentScanResult.Unavailable)
|
||||
{
|
||||
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
|
||||
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
|
||||
document.RejectionReason = result == DocumentScanResult.Infected
|
||||
? "The upload failed malware scanning."
|
||||
: document.RejectionReason;
|
||||
document.ScannedAt = DateTime.UtcNow;
|
||||
document.LastModificationTime = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (result == DocumentScanResult.Infected && File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class VendorDocumentStorage
|
||||
{
|
||||
public static string Root(string contentRoot) =>
|
||||
Path.Combine(contentRoot, "App_Data", "vendor-documents");
|
||||
|
||||
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
|
||||
Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName);
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
|
|||
|
||||
builder.Services.AddScoped<SendMessage>();
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
|
||||
builder.Services.AddHostedService<VendorDocumentScanWorker>();
|
||||
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
||||
{
|
||||
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
// Production must supply a reachable ClamAV Host (environment variable ClamAV__Host).
|
||||
// While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked).
|
||||
"ClamAV": {
|
||||
"Host": "",
|
||||
"Port": 3310
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JWT": {
|
||||
"ValidAudience": "http://console.seahavenind.com",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@
|
|||
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
// Malware scanner for vendor completion document uploads. While Host is empty the
|
||||
// scanner is considered unavailable and uploaded files stay quarantined (download
|
||||
// returns 423 Locked until a scan passes). Production must supply a real Host.
|
||||
"ClamAV": {
|
||||
"Host": "",
|
||||
"Port": 3310
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"JWT": {
|
||||
"ValidAudience": "http://localhost:4200",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,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; }
|
||||
|
|
@ -81,6 +91,8 @@ namespace Data.SeaHavenIndustries
|
|||
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; }
|
||||
|
|
|
|||
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
3441
Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,230 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorRoadmapOperations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unknown");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Not Submitted");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "Unavailable");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SitePreferredVendors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LocationId = table.Column<int>(type: "int", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SitePreferredVendors", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Locations_LocationId",
|
||||
column: x => x.LocationId,
|
||||
principalTable: "Locations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_SitePreferredVendors_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCompletionDocuments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
DispatchId = table.Column<int>(type: "int", nullable: false),
|
||||
WorkOrderId = table.Column<int>(type: "int", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Version = table.Column<int>(type: "int", nullable: false),
|
||||
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
|
||||
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Dispatches_DispatchId",
|
||||
column: x => x.DispatchId,
|
||||
principalTable: "Dispatches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId",
|
||||
column: x => x.WorkOrderId,
|
||||
principalTable: "workOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_LocationId_VendorId_Trade",
|
||||
table: "SitePreferredVendors",
|
||||
columns: new[] { "LocationId", "VendorId", "Trade" },
|
||||
unique: true,
|
||||
filter: "[Trade] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SitePreferredVendors_VendorId",
|
||||
table: "SitePreferredVendors",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_DispatchId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "DispatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_VendorId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCompletionDocuments_WorkOrderId",
|
||||
table: "VendorCompletionDocuments",
|
||||
column: "WorkOrderId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SitePreferredVendors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCompletionDocuments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityStatus",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvailabilityUpdatedAt",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CommercialStatusUpdatedAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EstimatedArrivalAt",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EtaManualOverride",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceNumber",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InvoiceStatus",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PaymentStatus",
|
||||
table: "Dispatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -631,6 +631,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateTime?>("AcknowledgedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CommercialStatusUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("CompletedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -655,6 +658,19 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<bool>("EmailSent")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("EstimatedArrivalAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("EtaManualOverride")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("InvoiceNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("InvoiceStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
|
@ -670,6 +686,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PaymentStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ReplyToAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1147,6 +1167,86 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("EmployeePhones");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AllDay")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("EndTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("EventColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<int?>("RecordID")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RecordName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RecordTargetType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ReminderMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StartTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1526,6 +1626,65 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Regions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("LocationId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NULL");
|
||||
|
||||
b.HasIndex("LocationId", "VendorId", "Trade")
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NOT NULL");
|
||||
|
||||
b.ToTable("SitePreferredVendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1727,6 +1886,13 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Address")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("AvailabilityStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("AvailabilityUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1958,6 +2124,93 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("VendorCompanies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("DispatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ReplacesDocumentId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ReviewStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ReviewedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ScanStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ScannedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("VendorCompletionDocuments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -2796,6 +3049,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 +3142,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")
|
||||
|
|
@ -3060,6 +3359,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.Navigation("Comments");
|
||||
|
||||
b.Navigation("CompletionDocuments");
|
||||
|
||||
b.Navigation("DispatchWorkOrders");
|
||||
|
||||
b.Navigation("Signoffs");
|
||||
|
|
@ -3106,6 +3407,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("AuditLogs");
|
||||
|
||||
b.Navigation("Dispatches");
|
||||
|
||||
b.Navigation("SitePreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Description { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? EstimatedArrivalAt { get; set; }
|
||||
public bool EtaManualOverride { get; set; }
|
||||
public string InvoiceStatus { get; set; } = "Not Submitted";
|
||||
public string PaymentStatus { get; set; } = "Unavailable";
|
||||
public string? InvoiceNumber { get; set; }
|
||||
public DateTime? CommercialStatusUpdatedAt { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public DateTime? DispatchedAt { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
|
|
@ -35,5 +41,6 @@ namespace Data.SeaHavenIndustries
|
|||
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
|
||||
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
|
||||
public ICollection<DispatchSignoff>? Signoffs { get; set; }
|
||||
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
Data.SeaHavenIndustries/Models/SitePreferredVendor.cs
Normal file
19
Data.SeaHavenIndustries/Models/SitePreferredVendor.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class SitePreferredVendor : FullAuditEntity
|
||||
{
|
||||
public int LocationId { get; set; }
|
||||
[ForeignKey(nameof(LocationId))]
|
||||
public Locations? Location { get; set; }
|
||||
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public string? Trade { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ namespace Data.SeaHavenIndustries
|
|||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? PreferredContact { get; set; }
|
||||
public string AvailabilityStatus { get; set; } = "Unknown";
|
||||
public DateTime? AvailabilityUpdatedAt { get; set; }
|
||||
|
||||
public int? CompanyId { get; set; }
|
||||
[ForeignKey(nameof(CompanyId))]
|
||||
|
|
@ -27,5 +29,6 @@ namespace Data.SeaHavenIndustries
|
|||
public int TotalJobs { get; set; }
|
||||
public ICollection<Dispatch>? Dispatches { get; set; }
|
||||
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
|
||||
public ICollection<SitePreferredVendor>? SitePreferences { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs
Normal file
31
Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class VendorCompletionDocument : FullAuditEntity
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
[ForeignKey(nameof(VendorId))]
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public int DispatchId { get; set; }
|
||||
[ForeignKey(nameof(DispatchId))]
|
||||
public Dispatch? Dispatch { get; set; }
|
||||
|
||||
public int WorkOrderId { get; set; }
|
||||
[ForeignKey(nameof(WorkOrderId))]
|
||||
public WorkOrder? WorkOrder { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = "";
|
||||
public string StoredFileName { get; set; } = "";
|
||||
public string ContentType { get; set; } = "";
|
||||
public long SizeBytes { get; set; }
|
||||
public string ScanStatus { get; set; } = "Pending";
|
||||
public string ReviewStatus { get; set; } = "Processing";
|
||||
public string? RejectionReason { get; set; }
|
||||
public int Version { get; set; } = 1;
|
||||
public int? ReplacesDocumentId { get; set; }
|
||||
public DateTime? ScannedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ namespace Data.SeaHavenIndustries
|
|||
public string? LegacyStatus { get; set; }
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? Service { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue