mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
724 lines
36 KiB
C#
724 lines
36 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|