using System.Text; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { [Authorize] [ApiController] [Route("api/vendor-operations")] public class VendorOperationsController : ControllerBase { private readonly IVendorOperationsService _service; public VendorOperationsController(IVendorOperationsService service) { _service = service; } private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "unknown"; private IActionResult Map(VendorOperationOutcome outcome) => outcome.Kind switch { VendorOperationOutcomeKind.Ok => Ok(outcome.Value), VendorOperationOutcomeKind.NotFound => outcome.Message is null ? NotFound() : NotFound(new Response { Status = "Error", Message = outcome.Message }), VendorOperationOutcomeKind.BadRequest => BadRequest(new Response { Status = "Error", Message = outcome.Message }), VendorOperationOutcomeKind.Conflict => Conflict(new Response { Status = "Conflict", Message = outcome.Message }), _ => StatusCode(StatusCodes.Status500InternalServerError) }; [HttpGet("notifications")] public async Task GetNotifications(CancellationToken cancellationToken) { var result = await _service.GetNotificationsAsync(cancellationToken); return Ok(new { result.GeneratedAt, result.Items }); } [HttpGet("availability")] public async Task GetAvailability( [FromQuery] DateTime? start, [FromQuery] DateTime? end, [FromQuery] int? locationId, [FromQuery] string? trade, CancellationToken cancellationToken) { var outcome = await _service.GetAvailabilityAsync(start, end, locationId, trade, cancellationToken); return Map(outcome); } [HttpPut("vendors/{vendorId:int}/availability")] public async Task UpdateAvailability(int vendorId, [FromBody] VendorAvailabilityRequest body, CancellationToken cancellationToken) { var outcome = await _service.UpdateAvailabilityAsync(vendorId, body?.Status, Actor(), cancellationToken); return Map(outcome); } [HttpGet("sites/{locationId:int}/preferred-vendors")] public async Task GetSitePreferences(int locationId, CancellationToken cancellationToken) { var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken); return Ok(preferences); } [HttpPut("sites/{locationId:int}/preferred-vendors")] public async Task ReplaceSitePreferences(int locationId, [FromBody] List body, CancellationToken cancellationToken) { var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List(), Actor(), cancellationToken); return Map(outcome); } [HttpPut("documents/{documentId:int}/review")] public async Task ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body, CancellationToken cancellationToken) { var outcome = await _service.ReviewDocumentAsync(documentId, body ?? new DocumentReviewRequest(), Actor(), cancellationToken); return Map(outcome); } [HttpPut("dispatches/{dispatchId:int}/commercial-status")] public async Task UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body, CancellationToken cancellationToken) { var outcome = await _service.UpdateCommercialStatusAsync(dispatchId, body ?? new CommercialStatusRequest(), Actor(), cancellationToken); return Map(outcome); } [HttpPut("work-orders/{workOrderId:int}/assignment")] public async Task AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body, CancellationToken cancellationToken) { var outcome = await _service.AssignVendorAsync(workOrderId, body ?? new VendorAssignmentRequest(), Actor(), cancellationToken); return Map(outcome); } [HttpGet("insights")] public async Task GetInsights( [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, [FromQuery] int? vendorId, CancellationToken cancellationToken) { var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); return Ok(new { GeneratedAt = _service.UtcNow, Filters = new { from, to, locationId, trade, vendorId }, MetricDefinitions = MetricDefinitions(), Data = rows }); } [HttpGet("insights.csv")] public async Task ExportInsightsCsv( [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, [FromQuery] int? vendorId, CancellationToken cancellationToken) { var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n"); foreach (var row in rows) { csv.AppendLine(string.Join(",", row.VendorId, Csv(row.Vendor), row.CompletedJobs, row.CancellationRefusalRate.ToString("0.####"), row.OnTimeArrivalRate.ToString("0.####"), row.OnTimeCompletionRate.ToString("0.####"), row.AverageCycleHours?.ToString("0.##") ?? "")); } return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.csv"); } [HttpGet("insights.pdf")] public async Task ExportInsightsPdf( [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] int? locationId, [FromQuery] string? trade, [FromQuery] int? vendorId, CancellationToken cancellationToken) { var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken); var lines = new List { "Vendor Performance Insights", $"Generated: {_service.UtcNow:u}", $"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}", "Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours." }; lines.AddRange(rows.Take(35).Select(row => $"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h")); return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.pdf"); } private static string Csv(string value) { var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0]) ? $"'{value}" : value; return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\""; } private static object MetricDefinitions() => new { CompletedJobs = "Dispatches with Completed or Verified status.", CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.", OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.", OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.", AverageCycleHours = "Average elapsed hours from dispatch to completion." }; private static byte[] SimplePdf(IEnumerable 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 { 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()); } } }