mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
872 lines
38 KiB
C#
872 lines
38 KiB
C#
using Api.SeaHavenIndustries.Helper;
|
|
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace Api.SeaHavenIndustries.Controllers
|
|
{
|
|
[ApiController]
|
|
[Route("api/vendor-portal")]
|
|
[Route("api/vendorportal")]
|
|
public class VendorPortalController : Controller
|
|
{
|
|
private const string TokenHeader = "X-Vendor-Token";
|
|
|
|
private static readonly string[] VendorViewableStatuses =
|
|
new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" };
|
|
|
|
private readonly ApplicationDbContext _db;
|
|
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,
|
|
IWebHostEnvironment environment)
|
|
{
|
|
_db = db;
|
|
_tokens = tokens;
|
|
_config = config;
|
|
_sendMessage = sendMessage;
|
|
_environment = environment;
|
|
}
|
|
|
|
[HttpGet("session")]
|
|
public async Task<IActionResult> Session()
|
|
{
|
|
var vendor = await ResolveVendorAsync();
|
|
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
|
|
|
return Ok(new DataResponse
|
|
{
|
|
Status = "Success",
|
|
Data = new
|
|
{
|
|
vendor.Id,
|
|
vendor.CompanyName,
|
|
vendor.ContactName,
|
|
vendor.Email,
|
|
vendor.Phone
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpGet("dispatches")]
|
|
public async Task<IActionResult> ListDispatches([FromQuery] string? status = null)
|
|
{
|
|
var vendor = await ResolveVendorAsync();
|
|
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
|
|
|
var query = _db.Dispatches
|
|
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
|
.Where(d => d.VendorId == vendor.Id && (d.IsDeleted == null || d.IsDeleted == false));
|
|
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
{
|
|
query = query.Where(d => d.Status == status);
|
|
}
|
|
|
|
var dispatches = await query
|
|
.OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate)
|
|
.Select(d => new
|
|
{
|
|
d.Id,
|
|
d.DispatchNumber,
|
|
d.PONumber,
|
|
d.InvoiceNumber,
|
|
d.InvoiceStatus,
|
|
d.PaymentStatus,
|
|
d.Status,
|
|
d.NTEAmount,
|
|
d.ScheduledDate,
|
|
d.CompletedDate,
|
|
d.DispatchedAt,
|
|
d.AcknowledgedAt,
|
|
WorkOrderTitle = d.WorkOrder!.WorkerOrderTitle,
|
|
InternalWONumber = d.WorkOrder!.InternalWONumber,
|
|
LocationName = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.Name : null,
|
|
LocationCity = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.City : null,
|
|
LocationState = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.State : null
|
|
})
|
|
.ToListAsync();
|
|
|
|
return Ok(new DataResponse { Status = "Success", Data = dispatches });
|
|
}
|
|
|
|
[HttpGet("dispatches/{id:int}")]
|
|
public async Task<IActionResult> GetDispatch(int id)
|
|
{
|
|
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" });
|
|
|
|
var checklist = await _db.DispatchChecklistItems
|
|
.Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false))
|
|
.OrderBy(c => c.SortOrder)
|
|
.Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt })
|
|
.ToListAsync();
|
|
|
|
var signoffs = await _db.DispatchSignoffs
|
|
.Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false))
|
|
.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
|
|
from dec in decs.DefaultIfEmpty()
|
|
orderby u.CreatedDate descending
|
|
select new
|
|
{
|
|
u.Id,
|
|
u.CurrentNTE,
|
|
u.RequestedNTE,
|
|
u.VendorReason,
|
|
u.Status,
|
|
u.RequiredTier,
|
|
u.RequestedByVendorName,
|
|
u.CreatedDate,
|
|
u.DecidedAt,
|
|
u.DecisionNote,
|
|
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
|
DecidedByLastName = dec != null ? dec.LastName : null
|
|
})
|
|
.ToListAsync();
|
|
|
|
var upliftRequests = rawUplifts.Select(u => new
|
|
{
|
|
u.Id,
|
|
u.CurrentNTE,
|
|
u.RequestedNTE,
|
|
u.VendorReason,
|
|
u.Status,
|
|
u.RequiredTier,
|
|
u.RequestedByVendorName,
|
|
RequestedAt = u.CreatedDate,
|
|
u.DecidedAt,
|
|
u.DecisionNote,
|
|
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
|
|
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
|
}).ToList();
|
|
|
|
var rawComments = await (from c in _db.Comments
|
|
where c.DispatchId == id
|
|
&& c.CommentType != "internal"
|
|
&& (c.IsDeleted == null || c.IsDeleted == false)
|
|
join u in _db.Users on c.UserId equals u.Id into users
|
|
from u in users.DefaultIfEmpty()
|
|
orderby c.CreatedDate
|
|
select new
|
|
{
|
|
c.Id,
|
|
c.Commenttext,
|
|
c.Commenter,
|
|
c.CommentType,
|
|
c.CreatedDate,
|
|
c.UserId,
|
|
UserFirstName = u != null ? u.FirstName : null,
|
|
UserLastName = u != null ? u.LastName : null
|
|
})
|
|
.ToListAsync();
|
|
|
|
var comments = rawComments.Select(c =>
|
|
{
|
|
var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId);
|
|
var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
|
|
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
|
|
|
|
string resolvedType;
|
|
string resolvedCommenter;
|
|
if (hasShocUser)
|
|
{
|
|
resolvedType = "dispatcher";
|
|
resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName
|
|
: !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter!
|
|
: "Dispatcher";
|
|
}
|
|
else if (c.CommentType == "customer")
|
|
{
|
|
resolvedType = "customer";
|
|
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer";
|
|
}
|
|
else
|
|
{
|
|
resolvedType = "vendor";
|
|
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : vendor.CompanyName ?? "Vendor";
|
|
}
|
|
|
|
return new
|
|
{
|
|
c.Id,
|
|
c.Commenttext,
|
|
Commenter = resolvedCommenter,
|
|
CommentType = resolvedType,
|
|
c.CreatedDate
|
|
};
|
|
}).ToList();
|
|
|
|
return Ok(new DataResponse
|
|
{
|
|
Status = "Success",
|
|
Data = new
|
|
{
|
|
dispatch.Id,
|
|
dispatch.DispatchNumber,
|
|
dispatch.PONumber,
|
|
dispatch.InvoiceNumber,
|
|
dispatch.InvoiceStatus,
|
|
dispatch.PaymentStatus,
|
|
dispatch.CommercialStatusUpdatedAt,
|
|
dispatch.Status,
|
|
dispatch.NTEAmount,
|
|
dispatch.Description,
|
|
dispatch.ScheduledDate,
|
|
dispatch.CompletedDate,
|
|
dispatch.DispatchedAt,
|
|
dispatch.AcknowledgedAt,
|
|
WorkOrder = dispatch.WorkOrder == null ? null : new
|
|
{
|
|
dispatch.WorkOrder.Id,
|
|
dispatch.WorkOrder.InternalWONumber,
|
|
dispatch.WorkOrder.WorkerOrderTitle,
|
|
dispatch.WorkOrder.Description,
|
|
dispatch.WorkOrder.Priority,
|
|
dispatch.WorkOrder.DueDate,
|
|
dispatch.WorkOrder.Trade,
|
|
dispatch.WorkOrder.SubTrade,
|
|
dispatch.WorkOrder.Problem
|
|
},
|
|
Location = dispatch.WorkOrder?.Locations == null ? null : new
|
|
{
|
|
dispatch.WorkOrder.Locations.Name,
|
|
dispatch.WorkOrder.Locations.Address1,
|
|
dispatch.WorkOrder.Locations.City,
|
|
dispatch.WorkOrder.Locations.State,
|
|
dispatch.WorkOrder.Locations.Zip
|
|
},
|
|
Checklist = checklist,
|
|
Signoffs = signoffs,
|
|
Documents = documents,
|
|
Comments = comments,
|
|
UpliftRequests = upliftRequests
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/accept")]
|
|
public async Task<IActionResult> Accept(int id)
|
|
{
|
|
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 (dispatch.Status != "Sent")
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" });
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
dispatch.Status = "Acknowledged";
|
|
dispatch.AcknowledgedAt = now;
|
|
dispatch.LastModificationTime = now;
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
|
OldValue = "Sent",
|
|
NewValue = "Acknowledged",
|
|
Action = "vendor_accept",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } });
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/status")]
|
|
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body)
|
|
{
|
|
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" });
|
|
|
|
var from = dispatch.Status;
|
|
var to = body.Status;
|
|
if (!IsAllowedVendorTransition(from, to))
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" });
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
dispatch.Status = to;
|
|
dispatch.LastModificationTime = now;
|
|
if (to == "Completed") dispatch.CompletedDate = now;
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
|
OldValue = from,
|
|
NewValue = to,
|
|
Action = "vendor_status_change",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } });
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/request-cancel")]
|
|
public async Task<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body)
|
|
{
|
|
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 (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" });
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim();
|
|
|
|
_db.Comments.Add(new Comments
|
|
{
|
|
DispatchId = id,
|
|
WorkerOrderId = dispatch.WorkOrderId,
|
|
Commenter = vendor.CompanyName,
|
|
CommentType = "vendor",
|
|
RecordType = "cancel_request",
|
|
Commenttext = $"Vendor requested cancellation: {reason}",
|
|
CreatedDate = now
|
|
});
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber}",
|
|
OldValue = dispatch.Status,
|
|
NewValue = "Cancel Requested",
|
|
Action = "vendor_request_cancel",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" });
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/checklist/{itemId:int}")]
|
|
public async Task<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body)
|
|
{
|
|
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 (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" });
|
|
}
|
|
|
|
var item = await _db.DispatchChecklistItems
|
|
.FirstOrDefaultAsync(c => c.Id == itemId && c.DispatchId == id);
|
|
if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
|
|
|
|
var now = DateTime.UtcNow;
|
|
item.IsCompleted = body.IsCompleted;
|
|
item.CompletedBy = body.IsCompleted ? vendor.CompanyName : null;
|
|
item.CompletedAt = body.IsCompleted ? now : null;
|
|
item.LastModificationTime = now;
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new DataResponse
|
|
{
|
|
Status = "Success",
|
|
Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }
|
|
});
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/signoff")]
|
|
public async Task<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body)
|
|
{
|
|
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 (dispatch.Status != "In Progress" && dispatch.Status != "Completed")
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature))
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" });
|
|
}
|
|
|
|
var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant();
|
|
if (signoffType != "vendor" && signoffType != "customer")
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" });
|
|
}
|
|
|
|
var existing = await _db.DispatchSignoffs
|
|
.FirstOrDefaultAsync(s => s.DispatchId == id && s.SignoffType == signoffType);
|
|
if (existing != null)
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = $"A {signoffType} signoff already exists for this dispatch" });
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var signoff = new DispatchSignoff
|
|
{
|
|
DispatchId = id,
|
|
SignoffType = signoffType,
|
|
Name = body!.Name,
|
|
Signature = body.Signature,
|
|
SignatureMethod = body.SignatureMethod ?? "drawn",
|
|
SignedAt = now,
|
|
CreatedDate = now
|
|
};
|
|
_db.DispatchSignoffs.Add(signoff);
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff",
|
|
OldValue = null,
|
|
NewValue = $"{(signoffType == "customer" ? "Customer" : "Vendor")}: {body.Name}",
|
|
Action = signoffType == "customer" ? "customer_signoff" : "vendor_signoff",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new DataResponse { Status = "Success", Data = new { signoff.Id, signoff.SignedAt } });
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/comments")]
|
|
public async Task<IActionResult> AddComment(int id, [FromBody] CommentRequest body)
|
|
{
|
|
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 (string.IsNullOrWhiteSpace(body?.CommentText))
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" });
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var comment = new Comments
|
|
{
|
|
DispatchId = id,
|
|
WorkerOrderId = dispatch.WorkOrderId,
|
|
Commenter = vendor.CompanyName,
|
|
CommentType = "vendor",
|
|
RecordType = "comment",
|
|
Commenttext = body!.CommentText,
|
|
CreatedDate = now
|
|
};
|
|
_db.Comments.Add(comment);
|
|
await _db.SaveChangesAsync();
|
|
|
|
return Ok(new DataResponse
|
|
{
|
|
Status = "Success",
|
|
Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate }
|
|
});
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/uplift-request")]
|
|
public async Task<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body)
|
|
{
|
|
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 (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" });
|
|
}
|
|
|
|
if (body == null || body.RequestedNTE <= 0)
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" });
|
|
}
|
|
|
|
var current = dispatch.NTEAmount ?? 0m;
|
|
if (body.RequestedNTE <= current)
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" });
|
|
}
|
|
|
|
var pendingExists = await _db.DispatchUpliftRequests
|
|
.AnyAsync(u => u.DispatchId == id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false));
|
|
if (pendingExists)
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = "A pending uplift request already exists for this dispatch" });
|
|
}
|
|
|
|
var tier1Max = _config.GetValue<decimal?>("Approvals:UpliftTier1MaxUsd") ?? 2500m;
|
|
var delta = body.RequestedNTE - current;
|
|
var requiredTier = delta > tier1Max ? 2 : 1;
|
|
|
|
var now = DateTime.UtcNow;
|
|
var req = new DispatchUpliftRequest
|
|
{
|
|
DispatchId = id,
|
|
CurrentNTE = current,
|
|
RequestedNTE = body.RequestedNTE,
|
|
VendorReason = string.IsNullOrWhiteSpace(body.Reason) ? null : body.Reason!.Trim(),
|
|
Status = "Pending",
|
|
RequiredTier = requiredTier,
|
|
RequestedByVendorName = vendor.CompanyName,
|
|
CreatedDate = now
|
|
};
|
|
_db.DispatchUpliftRequests.Add(req);
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
|
OldValue = $"${current:F2}",
|
|
NewValue = $"${body.RequestedNTE:F2}",
|
|
Action = "uplift_requested",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
await NotifyDispatcherOfUpliftAsync(dispatch, req, vendor);
|
|
|
|
return Ok(new DataResponse
|
|
{
|
|
Status = "Success",
|
|
Data = new { req.Id, req.Status, req.RequiredTier, req.CurrentNTE, req.RequestedNTE }
|
|
});
|
|
}
|
|
|
|
[HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/cancel")]
|
|
public async Task<IActionResult> CancelUpliftRequest(int id, int requestId)
|
|
{
|
|
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" });
|
|
|
|
var req = await _db.DispatchUpliftRequests
|
|
.FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == id);
|
|
if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" });
|
|
|
|
if (req.Status != "Pending")
|
|
{
|
|
return BadRequest(new Response { Status = "Error", Message = $"Cannot cancel a '{req.Status}' uplift request" });
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
req.Status = "Cancelled";
|
|
req.DecidedAt = now;
|
|
req.LastModificationTime = now;
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
|
|
OldValue = "Pending",
|
|
NewValue = "Cancelled",
|
|
Action = "uplift_cancelled",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
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
|
|
{
|
|
var dispatchAudit = await _db.WorkOrderAuditLogs
|
|
.Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId))
|
|
.OrderByDescending(a => a.CreatedAt)
|
|
.FirstOrDefaultAsync();
|
|
if (dispatchAudit == null || string.IsNullOrWhiteSpace(dispatchAudit.UserId)) return;
|
|
|
|
var dispatcher = await _db.Users.FindAsync(dispatchAudit.UserId);
|
|
if (dispatcher == null || string.IsNullOrWhiteSpace(dispatcher.Email)) return;
|
|
|
|
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
|
|
var workOrderLink = dispatch.WorkOrderId.HasValue
|
|
? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}"
|
|
: frontendBase;
|
|
|
|
var vendorName = System.Net.WebUtility.HtmlEncode(vendor.CompanyName ?? "Vendor");
|
|
var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)");
|
|
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
|
|
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
|
|
|
|
var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendor.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})";
|
|
var body = $@"
|
|
<h2>Vendor Uplift Request</h2>
|
|
<p><strong>Dispatch:</strong> {dispatchNum}</p>
|
|
<p><strong>Vendor:</strong> {vendorName}</p>
|
|
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
|
|
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
|
|
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
|
|
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
|
|
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
|
|
</table>
|
|
<h3>Vendor Reason</h3>
|
|
<p>{reason}</p>
|
|
<div style='margin:20px 0;'>
|
|
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
|
|
</div>";
|
|
|
|
await _sendMessage.SendEMail(dispatcher.Email, subject, body);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
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;
|
|
var token = values.ToString();
|
|
return await _tokens.ResolveVendorAsync(token);
|
|
}
|
|
|
|
private Task<Dispatch?> LoadVendorDispatchAsync(int id, int vendorId)
|
|
{
|
|
return _db.Dispatches
|
|
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
|
.FirstOrDefaultAsync(d => d.Id == id
|
|
&& d.VendorId == vendorId
|
|
&& (d.IsDeleted == null || d.IsDeleted == false));
|
|
}
|
|
|
|
private static bool IsAllowedVendorTransition(string? from, string? to)
|
|
{
|
|
if (from == "Acknowledged" && to == "In Progress") return true;
|
|
if (from == "In Progress" && to == "Completed") return true;
|
|
return false;
|
|
}
|
|
|
|
public class ChangeStatusRequest
|
|
{
|
|
public string? Status { get; set; }
|
|
}
|
|
|
|
public class RequestCancelRequest
|
|
{
|
|
public string? Reason { get; set; }
|
|
}
|
|
|
|
public class ChecklistUpdateRequest
|
|
{
|
|
public bool IsCompleted { get; set; }
|
|
}
|
|
|
|
public class SignoffRequest
|
|
{
|
|
public string? Name { get; set; }
|
|
public string? Signature { get; set; }
|
|
public string? SignatureMethod { get; set; }
|
|
public string? SignoffType { get; set; }
|
|
}
|
|
|
|
public class CommentRequest
|
|
{
|
|
public string? CommentText { get; set; }
|
|
}
|
|
|
|
public class UpliftRequestBody
|
|
{
|
|
public decimal RequestedNTE { get; set; }
|
|
public string? Reason { get; set; }
|
|
}
|
|
}
|
|
}
|