Merge pull request #29 from Sea-Haven-Industries/codex/vendor-roadmap-completion-backend

Complete vendor operations roadmap backend
This commit is contained in:
Alexandre Brandizzi 2026-07-24 13:24:22 -03:00 • committed by GitHub
commit 782d0e8ae9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 5630 additions and 2 deletions

View file

@ -0,0 +1,313 @@
using System.Collections;
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorOperationsControllerTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static VendorOperationsController NewController(ApplicationDbContext context)
{
var controller = new VendorOperationsController(
context,
new DispatchDataService(context),
TimeProvider.System);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test"))
}
};
return controller;
}
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours()
{
using var context = NewContext();
context.workOrders.Add(new WorkOrder
{
InternalWONumber = "WO-48",
WorkerOrderTitle = "Needs vendor",
ScheduledDate = DateTime.UtcNow.AddHours(47)
});
await context.SaveChangesAsync();
var result = await NewController(context).GetNotifications();
var value = ((OkObjectResult)result).Value!;
var items = ((IEnumerable)Prop(value, "Items")).Cast<object>().ToList();
items.Should().ContainSingle();
Prop(items[0], "Type").Should().Be("NoVendor");
Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service");
}
[Fact]
public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-ASSIGNED",
ScheduledDate = DateTime.UtcNow.AddHours(24)
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledDate
});
await context.SaveChangesAsync();
var result = await NewController(context).GetNotifications();
var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast<object>();
items.Should().BeEmpty();
}
[Fact]
public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst()
{
using var context = NewContext();
var site = new Locations { Name = "St Louis" };
var preferred = new Vendor
{
CompanyName = "Preferred",
ContactName = "Alex",
IsActive = true,
TradeSpecialties = "HVAC",
AvailabilityStatus = "Available",
AvailabilityUpdatedAt = DateTime.UtcNow
};
var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-BUSY",
ScheduledStart = DateTime.UtcNow.AddHours(1),
ScheduledEnd = DateTime.UtcNow.AddHours(3)
};
context.AddRange(site, preferred, busy, workOrder);
await context.SaveChangesAsync();
context.SitePreferredVendors.Add(new SitePreferredVendor
{
LocationId = site.Id,
VendorId = preferred.Id,
Trade = "HVAC",
SortOrder = 0
});
context.Dispatches.Add(new Dispatch
{
VendorId = busy.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledStart
});
await context.SaveChangesAsync();
var result = await NewController(context).GetAvailability(
DateTime.UtcNow.AddHours(2),
DateTime.UtcNow.AddHours(4),
site.Id,
"HVAC");
var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast<object>().ToList();
Prop(rows[0], "CompanyName").Should().Be("Preferred");
Prop(rows[0], "IsPreferred").Should().Be(true);
var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy");
Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable");
}
[Fact]
public async Task ReplaceSitePreferences_RejectsInactiveVendor()
{
using var context = NewContext();
var site = new Locations { Name = "St Louis" };
var inactive = new Vendor { CompanyName = "Inactive", IsActive = false };
context.AddRange(site, inactive);
await context.SaveChangesAsync();
var result = await NewController(context).ReplaceSitePreferences(site.Id,
new List<VendorOperationsController.SitePreferenceRequest>
{
new() { VendorId = inactive.Id }
});
result.Should().BeOfType<BadRequestObjectResult>();
context.SitePreferredVendors.Should().BeEmpty();
}
[Fact]
public async Task InsightsExports_ReturnStableCsvAndPdfFormats()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false };
var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed",
DispatchedAt = DateTime.UtcNow.AddHours(-4),
ScheduledDate = DateTime.UtcNow.AddHours(-2),
AcknowledgedAt = DateTime.UtcNow.AddHours(-3),
CompletedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var controller = NewController(context);
var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null);
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null);
csv.ContentType.Should().Be("text/csv");
var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents);
csvText.Should().Contain("AverageCycleHours");
csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\"");
pdf.ContentType.Should().Be("application/pdf");
System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4");
}
[Fact]
public async Task AssignVendor_RejectsInactiveVendor()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Inactive", IsActive = false };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var result = await NewController(context).AssignVendor(
workOrder.Id,
new VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id });
result.Should().BeOfType<BadRequestObjectResult>();
context.Dispatches.Should().BeEmpty();
}
[Fact]
public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts()
{
using var context = NewContext();
var start = DateTime.UtcNow.AddHours(2);
var vendor = new Vendor
{
CompanyName = "Gateway",
IsActive = true,
AvailabilityStatus = "Available",
AvailabilityUpdatedAt = DateTime.UtcNow
};
var existingWorkOrder = new WorkOrder
{
InternalWONumber = "WO-EXISTING",
ScheduledStart = start,
ScheduledEnd = start.AddHours(2)
};
var assignedWorkOrder = new WorkOrder
{
InternalWONumber = "WO-NEW",
ScheduledStart = start.AddMinutes(30),
ScheduledEnd = start.AddHours(1)
};
context.AddRange(vendor, existingWorkOrder, assignedWorkOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = existingWorkOrder.Id,
Status = "Sent",
ScheduledDate = existingWorkOrder.ScheduledStart
});
await context.SaveChangesAsync();
var eta = start.AddMinutes(45);
var result = await NewController(context).AssignVendor(
assignedWorkOrder.Id,
new VendorOperationsController.VendorAssignmentRequest
{
VendorId = vendor.Id,
ScheduledStart = assignedWorkOrder.ScheduledStart,
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
EstimatedArrivalAt = eta,
EtaManualOverride = true
});
var value = ((OkObjectResult)result).Value!;
Prop(value, "AvailabilityStatus").Should().Be("Unavailable");
((IEnumerable)Prop(value, "Conflicts")).Cast<object>().Should().ContainSingle();
var dispatch = await context.Dispatches.SingleAsync(item =>
item.WorkOrderId == assignedWorkOrder.Id);
dispatch.EstimatedArrivalAt.Should().Be(eta);
dispatch.EtaManualOverride.Should().BeTrue();
dispatch.DispatchNumber.Should().StartWith("DISP-");
context.WorkOrderAuditLogs.Should().ContainSingle(log =>
log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned");
}
[Fact]
public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin()
{
using var context = NewContext();
var originalVendor = new Vendor { CompanyName = "Original", IsActive = true };
var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-LINKED",
ScheduledStart = DateTime.UtcNow.AddHours(2),
ScheduledEnd = DateTime.UtcNow.AddHours(4)
};
context.AddRange(originalVendor, replacementVendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = originalVendor.Id,
Status = "Sent",
DispatchNumber = "DISP-LINKED"
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id
});
await context.SaveChangesAsync();
var result = await NewController(context).AssignVendor(
workOrder.Id,
new VendorOperationsController.VendorAssignmentRequest
{
VendorId = replacementVendor.Id,
ScheduledStart = workOrder.ScheduledStart,
ScheduledEnd = workOrder.ScheduledEnd
});
result.Should().BeOfType<OkObjectResult>();
context.Dispatches.Should().ContainSingle();
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
}
}

View file

@ -0,0 +1,164 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class VendorPortalDocumentTests : IDisposable
{
private const string Token = "vendor-document-test-token";
private readonly string _contentRoot = Path.Combine(
Path.GetTempPath(),
$"seahaven-vendor-documents-{Guid.NewGuid():N}");
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private VendorPortalController NewController(ApplicationDbContext context)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["VendorPortal:TokenLifetimeDays"] = "365",
["SendGrid:ApiKey"] = "not-used"
})
.Build();
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var controller = new VendorPortalController(
context,
new VendorPortalTokenService(context, configuration),
configuration,
new SendMessage(configuration),
environment.Object);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
controller.Request.Headers["X-Vendor-Token"] = Token;
return controller;
}
private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch(
ApplicationDbContext context)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(dispatch);
context.VendorAccessTokens.Add(new VendorAccessToken
{
VendorId = vendor.Id,
Token = Token,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(1)
});
await context.SaveChangesAsync();
return (vendor, dispatch);
}
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
{
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
[Fact]
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var upload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "application/pdf"));
upload.Should().BeOfType<OkObjectResult>();
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus.Should().Be("Pending");
document.ReviewStatus.Should().Be("Processing");
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
var locked = download.Should().BeOfType<ObjectResult>().Subject;
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "Application/PDF"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 };
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(png, "photo.png", "Image/PNG"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
public void Dispose()
{
if (Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}

View file

@ -0,0 +1,724 @@
using System.Security.Claims;
using System.Text;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/vendor-operations")]
public class VendorOperationsController : ControllerBase
{
private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" };
private readonly ApplicationDbContext _db;
private readonly IDispatchDataService _dispatchDataService;
private readonly TimeProvider _timeProvider;
public VendorOperationsController(
ApplicationDbContext db,
IDispatchDataService dispatchDataService,
TimeProvider timeProvider)
{
_db = db;
_dispatchDataService = dispatchDataService;
_timeProvider = timeProvider;
}
[HttpGet("notifications")]
public async Task<IActionResult> GetNotifications()
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
var cutoff = now.AddHours(48);
var directlyAssignedIds = await _db.Dispatches
.AsNoTracking()
.Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue)
.Select(d => d.WorkOrderId!.Value)
.ToListAsync();
var multiplyAssignedIds = await _db.DispatchWorkOrders
.AsNoTracking()
.Where(link => link.Dispatch != null
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? ""))
.Select(link => link.WorkOrderId)
.ToListAsync();
var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList();
var noVendor = await _db.workOrders
.AsNoTracking()
.Where(wo => wo.ScheduledDate.HasValue
&& wo.ScheduledDate.Value <= cutoff
&& wo.LifecycleStatus != LifecycleStatus.Completed
&& wo.LifecycleStatus != LifecycleStatus.Canceled
&& !assignedWorkOrderIds.Contains(wo.Id))
.OrderBy(wo => wo.ScheduledDate)
.Select(wo => new
{
Id = $"no-vendor-{wo.Id}",
Type = "NoVendor",
Severity = "Medium",
Group = "No Vendor",
Title = "No vendor assigned — 48h to service",
WorkOrderId = wo.Id,
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
ScheduledAt = wo.ScheduledDate,
Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
})
.ToListAsync();
var dispatches = await ActiveScheduledDispatches().ToListAsync();
var conflicts = BuildConflictPairs(dispatches)
.Select(pair => new
{
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
Type = "VendorConflict",
Severity = "Medium",
Group = "Vendor Conflict",
Title = "Vendor has overlapping appointments",
VendorId = pair.Left.VendorId,
VendorName = pair.Left.VendorName,
WorkOrders = new[]
{
new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End },
new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End }
},
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
Actions = new[]
{
new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
}
})
.ToList();
return Ok(new
{
GeneratedAt = now,
Items = noVendor.Cast<object>().Concat(conflicts).OrderBy(item =>
item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList()
});
}
[HttpGet("availability")]
public async Task<IActionResult> GetAvailability(
[FromQuery] DateTime? start,
[FromQuery] DateTime? end,
[FromQuery] int? locationId,
[FromQuery] string? trade)
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
var requestedStart = start ?? now;
var requestedEnd = end ?? requestedStart.AddHours(2);
if (requestedEnd <= requestedStart)
return BadRequest(new Response { Status = "Error", Message = "end must be after start" });
var vendors = await _db.Vendors.AsNoTracking()
.Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false))
.ToListAsync();
var preferences = locationId.HasValue
? await _db.SitePreferredVendors.AsNoTracking()
.Where(p => p.LocationId == locationId.Value)
.ToListAsync()
: new List<SitePreferredVendor>();
var dispatches = await ActiveScheduledDispatches().ToListAsync();
var result = vendors.Select(vendor =>
{
var matchingDispatches = dispatches
.Where(dispatch => dispatch.VendorId == vendor.Id
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
.ToList();
var matchingPreference = preferences
.Where(preference => preference.VendorId == vendor.Id
&& (string.IsNullOrWhiteSpace(preference.Trade)
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
.OrderBy(preference => preference.SortOrder)
.FirstOrDefault();
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
var status = matchingDispatches.Count > 0
? "Unavailable"
: signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus)
? vendor.AvailabilityStatus
: "Unknown";
return new
{
vendor.Id,
vendor.CompanyName,
vendor.ContactName,
vendor.TradeSpecialties,
AvailabilityStatus = status,
vendor.AvailabilityUpdatedAt,
IsPreferred = matchingPreference != null,
PreferenceRank = matchingPreference?.SortOrder,
Conflicts = matchingDispatches.Select(dispatch => new
{
dispatch.WorkOrderId,
dispatch.WorkOrderNumber,
dispatch.Start,
dispatch.End
})
};
})
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
.ThenBy(vendor => vendor.CompanyName)
.ToList();
return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result });
}
[HttpPut("vendors/{vendorId:int}/availability")]
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body)
{
var normalized = body.Status?.Trim();
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" });
var vendor = await _db.Vendors.FindAsync(vendorId);
if (vendor == null) return NotFound();
var now = _timeProvider.GetUtcNow().UtcDateTime;
var actor = Actor();
var previous = vendor.AvailabilityStatus;
vendor.AvailabilityStatus = normalized;
vendor.AvailabilityUpdatedAt = now;
vendor.LastModificationTime = now;
_db.VendorAuditLogs.Add(new VendorAuditLog
{
VendorId = vendorId,
FieldName = nameof(Vendor.AvailabilityStatus),
OldValue = previous,
NewValue = normalized,
Actor = actor,
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt });
}
[HttpGet("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> GetSitePreferences(int locationId)
{
var preferences = await _db.SitePreferredVendors
.AsNoTracking()
.Where(p => p.LocationId == locationId)
.Include(p => p.Vendor)
.OrderBy(p => p.SortOrder)
.Select(p => new
{
p.Id,
p.LocationId,
p.VendorId,
VendorName = p.Vendor!.CompanyName,
p.Trade,
p.SortOrder,
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
p.LastModificationTime,
p.UpdatedBy
})
.ToListAsync();
return Ok(preferences);
}
[HttpPut("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body)
{
if (!await _db.Locations.AnyAsync(location => location.Id == locationId))
return NotFound(new Response { Status = "Error", Message = "Location not found" });
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" });
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id);
foreach (var item in body)
{
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" });
if (!string.IsNullOrWhiteSpace(item.Trade)
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" });
}
var now = _timeProvider.GetUtcNow().UtcDateTime;
var actor = Actor();
var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync();
_db.SitePreferredVendors.RemoveRange(existing);
_db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor
{
LocationId = locationId,
VendorId = item.VendorId,
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
SortOrder = item.SortOrder ?? index,
CreatedDate = now,
LastModificationTime = now,
createdby = actor,
UpdatedBy = actor
}));
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
{
_db.VendorAuditLogs.Add(new VendorAuditLog
{
VendorId = vendorId,
FieldName = $"SitePreference:{locationId}",
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
Actor = actor,
CreatedAt = now
});
}
await _db.SaveChangesAsync();
return await GetSitePreferences(locationId);
}
[HttpPut("documents/{documentId:int}/review")]
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body)
{
if (body.ReviewStatus is not ("Approved" or "Rejected"))
return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" });
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" });
var document = await _db.VendorCompletionDocuments.FindAsync(documentId);
if (document == null) return NotFound();
if (document.ScanStatus != "Passed")
return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" });
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previous = document.ReviewStatus;
document.ReviewStatus = body.ReviewStatus;
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
document.ReviewedAt = now;
document.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = document.WorkOrderId,
UserId = Actor(),
FieldName = $"Completion Document {document.OriginalFileName}",
OldValue = previous,
NewValue = document.ReviewStatus,
Action = "completion_document_reviewed",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt });
}
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body)
{
var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" };
var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
if (!invoiceStatuses.Contains(body.InvoiceStatus))
return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" });
if (!paymentStatuses.Contains(body.PaymentStatus))
return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" });
var dispatch = await _db.Dispatches.FindAsync(dispatchId);
if (dispatch == null) return NotFound();
var now = _timeProvider.GetUtcNow().UtcDateTime;
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
dispatch.InvoiceStatus = body.InvoiceStatus;
dispatch.PaymentStatus = body.PaymentStatus;
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
dispatch.CommercialStatusUpdatedAt = now;
dispatch.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = Actor(),
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
OldValue = oldValue,
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
Action = "commercial_status_updated",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new
{
dispatch.Id,
dispatch.PONumber,
dispatch.InvoiceNumber,
dispatch.InvoiceStatus,
dispatch.PaymentStatus,
dispatch.CommercialStatusUpdatedAt
});
}
[HttpPut("work-orders/{workOrderId:int}/assignment")]
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body)
{
var workOrder = await _db.workOrders.FindAsync(workOrderId);
if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" });
var vendor = await _db.Vendors.FindAsync(body.VendorId);
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" });
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
var scheduledDispatches = start.HasValue && end.HasValue
? await ActiveScheduledDispatches()
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId)
.ToListAsync()
: new List<ScheduledDispatch>();
var conflicts = start.HasValue && end.HasValue
? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
: new List<ScheduledDispatch>();
var linkedDispatchIds = _db.DispatchWorkOrders
.Where(link => link.WorkOrderId == workOrderId)
.Select(link => link.DispatchId);
var dispatch = await _db.Dispatches
.Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id))
&& !TerminalDispatchStatuses.Contains(item.Status ?? ""))
.OrderByDescending(item => item.CreatedDate)
.FirstOrDefaultAsync();
var now = _timeProvider.GetUtcNow().UtcDateTime;
var previousVendorId = dispatch?.VendorId;
if (dispatch == null)
{
dispatch = new Dispatch
{
WorkOrderId = workOrderId,
VendorId = vendor.Id,
DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(),
PONumber = await _dispatchDataService.GenerateNextPONumberAsync(),
Status = "Sent",
ScheduledDate = start,
EstimatedArrivalAt = body.EstimatedArrivalAt,
EtaManualOverride = body.EtaManualOverride,
DispatchedAt = now,
CreatedDate = now,
createdby = Actor()
};
_db.Dispatches.Add(dispatch);
}
else
{
dispatch.VendorId = vendor.Id;
dispatch.ScheduledDate = start;
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
dispatch.EtaManualOverride = body.EtaManualOverride;
dispatch.LastModificationTime = now;
}
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
workOrder.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = Actor(),
FieldName = "Vendor Assignment",
OldValue = previousVendorId?.ToString(),
NewValue = vendor.Id.ToString(),
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new
{
dispatch.Id,
dispatch.VendorId,
VendorName = vendor.CompanyName,
dispatch.EstimatedArrivalAt,
dispatch.EtaManualOverride,
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
Conflicts = conflicts.Select(conflict => new
{
conflict.WorkOrderId,
conflict.WorkOrderNumber,
conflict.Start,
conflict.End
})
});
}
[HttpGet("insights")]
public async Task<IActionResult> GetInsights(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId)
{
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
return Ok(new
{
GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime,
Filters = new { from, to, locationId, trade, vendorId },
MetricDefinitions = MetricDefinitions(),
Data = rows
});
}
[HttpGet("insights.csv")]
public async Task<IActionResult> ExportInsightsCsv(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId)
{
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
foreach (var row in rows)
{
csv.AppendLine(string.Join(",",
row.VendorId,
Csv(row.Vendor),
row.CompletedJobs,
row.CancellationRefusalRate.ToString("0.####"),
row.OnTimeArrivalRate.ToString("0.####"),
row.OnTimeCompletionRate.ToString("0.####"),
row.AverageCycleHours?.ToString("0.##") ?? ""));
}
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
}
[HttpGet("insights.pdf")]
public async Task<IActionResult> ExportInsightsPdf(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId)
{
var rows = await BuildInsightRows(from, to, locationId, trade, vendorId);
var lines = new List<string>
{
"Vendor Performance Insights",
$"Generated: {_timeProvider.GetUtcNow():u}",
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
};
lines.AddRange(rows.Take(35).Select(row =>
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf");
}
private IQueryable<ScheduledDispatch> ActiveScheduledDispatches()
{
var direct = _db.Dispatches.AsNoTracking()
.Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "")
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false)
&& dispatch.WorkOrderId.HasValue
&& (dispatch.ScheduledDate.HasValue
|| (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue)))
.Select(dispatch => new ScheduledDispatch
{
Id = dispatch.Id,
VendorId = dispatch.VendorId,
VendorName = dispatch.Vendor!.CompanyName,
WorkOrderId = dispatch.WorkOrderId!.Value,
WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber,
Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value,
End = dispatch.WorkOrder.ScheduledEnd
?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2)
});
var linked = _db.DispatchWorkOrders.AsNoTracking()
.Where(link => link.Dispatch != null
&& link.WorkOrder != null
&& link.Dispatch.WorkOrderId != link.WorkOrderId
&& !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")
&& (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false)
&& (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue))
.Select(link => new ScheduledDispatch
{
Id = link.DispatchId,
VendorId = link.Dispatch!.VendorId,
VendorName = link.Dispatch.Vendor!.CompanyName,
WorkOrderId = link.WorkOrderId,
WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber,
Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value,
End = link.WorkOrder.ScheduledEnd
?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2)
});
return direct.Concat(linked);
}
private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List<ScheduledDispatch> dispatches)
{
for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++)
{
for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++)
{
var left = dispatches[leftIndex];
var right = dispatches[rightIndex];
if (left.VendorId == right.VendorId
&& left.WorkOrderId != right.WorkOrderId
&& WindowsOverlap(left.Start, left.End, right.Start, right.End))
yield return (left, right);
}
}
}
private async Task<List<VendorInsightRow>> BuildInsightRows(
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId)
{
var query = _db.Dispatches.AsNoTracking()
.Include(dispatch => dispatch.Vendor)
.Include(dispatch => dispatch.WorkOrder)
.Where(dispatch => dispatch.Vendor != null);
if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from);
if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to);
if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId);
if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade);
if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId);
var dispatches = await query.ToListAsync();
return dispatches.GroupBy(dispatch => dispatch.VendorId)
.Select(group =>
{
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList();
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
return new VendorInsightRow
{
VendorId = group.Key,
Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}",
CompletedJobs = completed.Count,
CancellationRefusalRate = Rate(canceled, group.Count()),
OnTimeArrivalRate = Rate(
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
acknowledgedWithSchedule.Count),
OnTimeCompletionRate = Rate(
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate),
completedWithDue.Count),
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
};
})
.OrderBy(row => row.Vendor)
.ToList();
}
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.Identity?.Name
?? "unknown";
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
=> leftStart < rightEnd && rightStart < leftEnd;
private static decimal Rate(int numerator, int denominator)
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
private static string Csv(string value)
{
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
? $"'{value}"
: value;
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
}
private static object MetricDefinitions() => new
{
CompletedJobs = "Dispatches with Completed or Verified status.",
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
AverageCycleHours = "Average elapsed hours from dispatch to completion."
};
private static byte[] SimplePdf(IEnumerable<string> lines)
{
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
+ " ET";
var objects = new[]
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
var builder = new StringBuilder("%PDF-1.4\n");
var offsets = new List<int> { 0 };
for (var index = 0; index < objects.Length; index++)
{
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
}
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
return Encoding.ASCII.GetBytes(builder.ToString());
}
public sealed class AvailabilityRequest
{
public string? Status { get; set; }
}
public sealed class SitePreferenceRequest
{
public int VendorId { get; set; }
public string? Trade { get; set; }
public int? SortOrder { get; set; }
}
public sealed class DocumentReviewRequest
{
public string ReviewStatus { get; set; } = "";
public string? RejectionReason { get; set; }
}
public sealed class CommercialStatusRequest
{
public string InvoiceStatus { get; set; } = "Not Submitted";
public string PaymentStatus { get; set; } = "Unavailable";
public string? InvoiceNumber { get; set; }
}
public sealed class VendorAssignmentRequest
{
public int VendorId { get; set; }
public DateTime? ScheduledStart { get; set; }
public DateTime? ScheduledEnd { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
}
private sealed class ScheduledDispatch
{
public int Id { get; set; }
public int VendorId { get; set; }
public string? VendorName { get; set; }
public int WorkOrderId { get; set; }
public string? WorkOrderNumber { get; set; }
public DateTime Start { get; set; }
public DateTime End { get; set; }
}
private sealed class VendorInsightRow
{
public int VendorId { get; set; }
public string Vendor { get; set; } = "";
public int CompletedJobs { get; set; }
public decimal CancellationRefusalRate { get; set; }
public decimal OnTimeArrivalRate { get; set; }
public decimal OnTimeCompletionRate { get; set; }
public double? AverageCycleHours { get; set; }
}
}
}

View file

@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly VendorPortalTokenService _tokens;
private readonly IConfiguration _config;
private readonly SendMessage _sendMessage;
private readonly IWebHostEnvironment _environment;
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
public VendorPortalController(
ApplicationDbContext db,
VendorPortalTokenService tokens,
IConfiguration config,
SendMessage sendMessage,
IWebHostEnvironment environment)
{
_db = db;
_tokens = tokens;
_config = config;
_sendMessage = sendMessage;
_environment = environment;
}
[HttpGet("session")]
@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers
d.Id,
d.DispatchNumber,
d.PONumber,
d.InvoiceNumber,
d.InvoiceStatus,
d.PaymentStatus,
d.Status,
d.NTEAmount,
d.ScheduledDate,
@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
.ToListAsync();
var documents = await _db.VendorCompletionDocuments
.Where(document => document.DispatchId == id && document.VendorId == vendor.Id)
.OrderByDescending(document => document.CreatedDate)
.Select(document => new
{
document.Id,
document.OriginalFileName,
document.ContentType,
document.SizeBytes,
document.ScanStatus,
document.ReviewStatus,
document.RejectionReason,
document.Version,
document.ReplacesDocumentId,
document.CreatedDate,
document.ScannedAt,
document.ReviewedAt,
CanDownload = document.ScanStatus == "Passed"
})
.ToListAsync();
var rawUplifts = await (from u in _db.DispatchUpliftRequests
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers
dispatch.Id,
dispatch.DispatchNumber,
dispatch.PONumber,
dispatch.InvoiceNumber,
dispatch.InvoiceStatus,
dispatch.PaymentStatus,
dispatch.CommercialStatusUpdatedAt,
dispatch.Status,
dispatch.NTEAmount,
dispatch.Description,
@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers
},
Checklist = checklist,
Signoffs = signoffs,
Documents = documents,
Comments = comments,
UpliftRequests = upliftRequests
}
@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
}
[HttpPost("dispatches/{id:int}/documents")]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> UploadCompletionDocument(
int id,
[FromForm] IFormFile file,
[FromForm] int? replacesDocumentId = null)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" });
if (file.Length > 10 * 1024 * 1024)
return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" });
var allowed = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["application/pdf"] = new[] { ".pdf" },
["image/jpeg"] = new[] { ".jpg", ".jpeg" },
["image/png"] = new[] { ".png" }
};
var extension = Path.GetExtension(file.FileName);
if (!allowed.TryGetValue(file.ContentType, out var extensions)
|| !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" });
await using var signatureStream = file.OpenReadStream();
if (!await HasExpectedSignature(signatureStream, file.ContentType))
return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" });
var workOrderId = dispatch.WorkOrderId
?? await _db.DispatchWorkOrders
.Where(link => link.DispatchId == dispatch.Id)
.OrderBy(link => link.Id)
.Select(link => link.WorkOrderId)
.FirstOrDefaultAsync();
if (workOrderId == 0)
return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" });
VendorCompletionDocument? replaced = null;
if (replacesDocumentId.HasValue)
{
replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document =>
document.Id == replacesDocumentId.Value
&& document.DispatchId == id
&& document.VendorId == vendor.Id);
if (replaced == null)
return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" });
}
var now = DateTime.UtcNow;
var document = new VendorCompletionDocument
{
VendorId = vendor.Id,
DispatchId = id,
WorkOrderId = workOrderId,
OriginalFileName = Path.GetFileName(file.FileName),
StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}",
ContentType = file.ContentType,
SizeBytes = file.Length,
ScanStatus = "Pending",
ReviewStatus = "Processing",
Version = (replaced?.Version ?? 0) + 1,
ReplacesDocumentId = replaced?.Id,
CreatedDate = now,
createdby = $"vendor:{vendor.Id}"
};
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using (var target = System.IO.File.Create(path))
{
await file.CopyToAsync(target);
}
try
{
_db.VendorCompletionDocuments.Add(document);
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = document.WorkOrderId,
FieldName = "Completion Document",
OldValue = replaced?.OriginalFileName,
NewValue = document.OriginalFileName,
Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced",
CreatedAt = now
});
await _db.SaveChangesAsync();
}
catch
{
System.IO.File.Delete(path);
throw;
}
return Ok(new DataResponse
{
Status = "Success",
Data = new
{
document.Id,
document.OriginalFileName,
document.SizeBytes,
document.ScanStatus,
document.ReviewStatus,
document.Version,
document.ReplacesDocumentId
}
});
}
[HttpGet("dispatches/{id:int}/documents/{documentId:int}")]
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item =>
item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id);
if (document == null) return NotFound();
if (document.ScanStatus != "Passed")
return StatusCode(StatusCodes.Status423Locked,
new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" });
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
if (!System.IO.File.Exists(path)) return NotFound();
return PhysicalFile(path, document.ContentType, document.OriginalFileName);
}
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
{
try
@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
private static async Task<bool> HasExpectedSignature(Stream stream, string contentType)
{
var bytes = new byte[8];
var count = await stream.ReadAsync(bytes);
if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase))
return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D;
if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase))
return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase))
return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
return false;
}
private async Task<Vendor?> ResolveVendorAsync()
{
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;

View file

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

View file

@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
public List<int> VendorIds { get; set; } = new();
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string? Description { get; set; }
public int? TaskListTemplateId { get; set; }
public List<string>? CustomChecklistItems { get; set; }

View file

@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Status { get; set; }
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public DateTime? CompletedDate { get; set; }
public string? Description { get; set; }
}

View file

@ -0,0 +1,70 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace Api.SeaHavenIndustries.Helper
{
public enum DocumentScanResult
{
Passed,
Infected,
Unavailable
}
public interface IDocumentScanner
{
Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
}
public class ClamAvDocumentScanner : IDocumentScanner
{
private readonly IConfiguration _configuration;
public ClamAvDocumentScanner(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
{
var host = _configuration["ClamAV:Host"];
var port = _configuration.GetValue<int?>("ClamAV:Port") ?? 3310;
if (string.IsNullOrWhiteSpace(host) || !File.Exists(path))
return DocumentScanResult.Unavailable;
try
{
using var client = new TcpClient();
await client.ConnectAsync(host, port, cancellationToken);
await using var network = client.GetStream();
await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken);
await using var file = File.OpenRead(path);
var buffer = new byte[64 * 1024];
while (true)
{
var count = await file.ReadAsync(buffer, cancellationToken);
if (count == 0) break;
var length = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, count);
await network.WriteAsync(length, cancellationToken);
await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
}
await network.WriteAsync(new byte[4], cancellationToken);
await network.FlushAsync(cancellationToken);
var responseBuffer = new byte[1024];
var responseLength = await network.ReadAsync(responseBuffer, cancellationToken);
var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength);
if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
return DocumentScanResult.Infected;
return response.Contains("OK", StringComparison.OrdinalIgnoreCase)
? DocumentScanResult.Passed
: DocumentScanResult.Unavailable;
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
return DocumentScanResult.Unavailable;
}
}
}
}

View file

@ -0,0 +1,75 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Helper
{
public class VendorDocumentScanWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<VendorDocumentScanWorker> _logger;
public VendorDocumentScanWorker(
IServiceScopeFactory scopeFactory,
IWebHostEnvironment environment,
ILogger<VendorDocumentScanWorker> logger)
{
_scopeFactory = scopeFactory;
_environment = environment;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ScanPendingAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
private async Task ScanPendingAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
var pending = await db.VendorCompletionDocuments
.Where(document => document.ScanStatus == "Pending")
.OrderBy(document => document.CreatedDate)
.Take(20)
.ToListAsync(cancellationToken);
foreach (var document in pending)
{
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
var result = await scanner.ScanAsync(path, cancellationToken);
if (result == DocumentScanResult.Unavailable)
{
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
continue;
}
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
document.RejectionReason = result == DocumentScanResult.Infected
? "The upload failed malware scanning."
: document.RejectionReason;
document.ScannedAt = DateTime.UtcNow;
document.LastModificationTime = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
if (result == DocumentScanResult.Infected && File.Exists(path))
File.Delete(path);
}
}
}
public static class VendorDocumentStorage
{
public static string Root(string contentRoot) =>
Path.Combine(contentRoot, "App_Data", "vendor-documents");
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName);
}
}

View file

@ -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();

View file

@ -14,6 +14,12 @@
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Production must supply a reachable ClamAV Host (environment variable ClamAV__Host).
// While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked).
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://console.seahavenind.com",

View file

@ -17,6 +17,13 @@
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Malware scanner for vendor completion document uploads. While Host is empty the
// scanner is considered unavailable and uploaded files stay quarantined (download
// returns 423 Locked until a scan passes). Production must supply a real Host.
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://localhost:4200",

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddVendorRoadmapOperations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AvailabilityStatus",
table: "Vendors",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unknown");
migrationBuilder.AddColumn<DateTime>(
name: "AvailabilityUpdatedAt",
table: "Vendors",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CommercialStatusUpdatedAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedArrivalAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "EtaManualOverride",
table: "Dispatches",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "InvoiceNumber",
table: "Dispatches",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Not Submitted");
migrationBuilder.AddColumn<string>(
name: "PaymentStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unavailable");
migrationBuilder.CreateTable(
name: "SitePreferredVendors",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
LocationId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false),
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SitePreferredVendors", x => x.Id);
table.ForeignKey(
name: "FK_SitePreferredVendors_Locations_LocationId",
column: x => x.LocationId,
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SitePreferredVendors_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "VendorCompletionDocuments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorId = table.Column<int>(type: "int", nullable: false),
DispatchId = table.Column<int>(type: "int", nullable: false),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
Version = table.Column<int>(type: "int", nullable: false),
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_Dispatches_DispatchId",
column: x => x.DispatchId,
principalTable: "Dispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_LocationId_VendorId",
table: "SitePreferredVendors",
columns: new[] { "LocationId", "VendorId" },
unique: true,
filter: "[Trade] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_LocationId_VendorId_Trade",
table: "SitePreferredVendors",
columns: new[] { "LocationId", "VendorId", "Trade" },
unique: true,
filter: "[Trade] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_VendorId",
table: "SitePreferredVendors",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_DispatchId",
table: "VendorCompletionDocuments",
column: "DispatchId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_VendorId",
table: "VendorCompletionDocuments",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_WorkOrderId",
table: "VendorCompletionDocuments",
column: "WorkOrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SitePreferredVendors");
migrationBuilder.DropTable(
name: "VendorCompletionDocuments");
migrationBuilder.DropColumn(
name: "AvailabilityStatus",
table: "Vendors");
migrationBuilder.DropColumn(
name: "AvailabilityUpdatedAt",
table: "Vendors");
migrationBuilder.DropColumn(
name: "CommercialStatusUpdatedAt",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "EstimatedArrivalAt",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "EtaManualOverride",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "InvoiceNumber",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "InvoiceStatus",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "PaymentStatus",
table: "Dispatches");
}
}
}

View file

@ -631,6 +631,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CommercialStatusUpdatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -655,6 +658,19 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<bool>("EmailSent")
.HasColumnType("bit");
b.Property<DateTime?>("EstimatedArrivalAt")
.HasColumnType("datetime2");
b.Property<bool>("EtaManualOverride")
.HasColumnType("bit");
b.Property<string>("InvoiceNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("InvoiceStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -670,6 +686,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PONumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("PaymentStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ReplyToAddress")
.HasColumnType("nvarchar(max)");
@ -1147,6 +1167,86 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("EmployeePhones");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AllDay")
.HasColumnType("bit");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<string>("EndTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("EventColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Location")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<int?>("RecordID")
.HasColumnType("int");
b.Property<string>("RecordName")
.HasColumnType("nvarchar(max)");
b.Property<string>("RecordTargetType")
.HasColumnType("nvarchar(max)");
b.Property<int>("ReminderMinutes")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<string>("StartTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Events");
});
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
{
b.Property<int>("Id")
@ -1526,6 +1626,65 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Regions");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<int>("LocationId")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("Trade")
.HasColumnType("nvarchar(450)");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("VendorId");
b.HasIndex("LocationId", "VendorId")
.IsUnique()
.HasFilter("[Trade] IS NULL");
b.HasIndex("LocationId", "VendorId", "Trade")
.IsUnique()
.HasFilter("[Trade] IS NOT NULL");
b.ToTable("SitePreferredVendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
{
b.Property<int>("Id")
@ -1727,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 =>

View file

@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries
public string? Description { get; set; }
public string? Status { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string InvoiceStatus { get; set; } = "Not Submitted";
public string PaymentStatus { get; set; } = "Unavailable";
public string? InvoiceNumber { get; set; }
public DateTime? CommercialStatusUpdatedAt { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
@ -35,5 +41,6 @@ namespace Data.SeaHavenIndustries
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
public ICollection<DispatchSignoff>? Signoffs { get; set; }
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
}
}

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class SitePreferredVendor : FullAuditEntity
{
public int LocationId { get; set; }
[ForeignKey(nameof(LocationId))]
public Locations? Location { get; set; }
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public Vendor? Vendor { get; set; }
public string? Trade { get; set; }
public int SortOrder { get; set; }
public string? UpdatedBy { get; set; }
}
}

View file

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

View file

@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class VendorCompletionDocument : FullAuditEntity
{
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public Vendor? Vendor { get; set; }
public int DispatchId { get; set; }
[ForeignKey(nameof(DispatchId))]
public Dispatch? Dispatch { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public WorkOrder? WorkOrder { get; set; }
public string OriginalFileName { get; set; } = "";
public string StoredFileName { get; set; } = "";
public string ContentType { get; set; } = "";
public long SizeBytes { get; set; }
public string ScanStatus { get; set; } = "Pending";
public string ReviewStatus { get; set; } = "Processing";
public string? RejectionReason { get; set; }
public int Version { get; set; } = 1;
public int? ReplacesDocumentId { get; set; }
public DateTime? ScannedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
}
}

View file

@ -27,6 +27,7 @@ namespace Data.SeaHavenIndustries
public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
[MaxLength(128)]
public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]