mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 14:13:12 +00:00
Introduces DispatchUpliftRequest entity and tier-gated approval flow: vendors request a new NTE from the portal, dispatchers approve or deny in SHOC, and requests above a configurable threshold require a higher role. Adds DispatchController for PO-centric list/detail used by the new Vendor POs page. Seeds Dispatcher and Manager roles.
241 lines
10 KiB
C#
241 lines
10 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Security.Claims;
|
|
|
|
namespace Api.SeaHavenIndustries.Controllers
|
|
{
|
|
[Authorize]
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
[Route("api/uplifts")]
|
|
public class UpliftController : Controller
|
|
{
|
|
private readonly ApplicationDbContext _db;
|
|
private readonly IConfiguration _config;
|
|
|
|
public UpliftController(ApplicationDbContext db, IConfiguration config)
|
|
{
|
|
_db = db;
|
|
_config = config;
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25)
|
|
{
|
|
var query = from u in _db.DispatchUpliftRequests
|
|
join d in _db.Dispatches on u.DispatchId equals d.Id
|
|
join v in _db.Vendors on d.VendorId equals v.Id into vendors
|
|
from v in vendors.DefaultIfEmpty()
|
|
where (u.IsDeleted == null || u.IsDeleted == false)
|
|
select new { u, d, v };
|
|
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
query = query.Where(x => x.u.Status == status);
|
|
if (tier.HasValue)
|
|
query = query.Where(x => x.u.RequiredTier == tier.Value);
|
|
|
|
var total = await query.CountAsync();
|
|
|
|
var raw = await query
|
|
.OrderByDescending(x => x.u.CreatedDate)
|
|
.Skip((Math.Max(page, 1) - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(x => new
|
|
{
|
|
x.u.Id,
|
|
DispatchId = x.d.Id,
|
|
x.d.DispatchNumber,
|
|
x.d.PONumber,
|
|
WorkOrderId = x.d.WorkOrderId,
|
|
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
|
|
x.u.CurrentNTE,
|
|
x.u.RequestedNTE,
|
|
x.u.VendorReason,
|
|
x.u.RequiredTier,
|
|
x.u.Status,
|
|
RequestedAt = x.u.CreatedDate,
|
|
x.u.DecidedAt,
|
|
x.u.DecisionNote
|
|
})
|
|
.ToListAsync();
|
|
|
|
var items = raw.Select(r => new
|
|
{
|
|
r.Id,
|
|
r.DispatchId,
|
|
r.DispatchNumber,
|
|
r.PONumber,
|
|
r.WorkOrderId,
|
|
r.VendorCompanyName,
|
|
r.CurrentNTE,
|
|
r.RequestedNTE,
|
|
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
|
|
r.VendorReason,
|
|
r.RequiredTier,
|
|
r.Status,
|
|
r.RequestedAt,
|
|
r.DecidedAt,
|
|
r.DecisionNote,
|
|
CanDecide = UserCanApprove(r.RequiredTier)
|
|
}).ToList();
|
|
|
|
return Ok(new DataResponse
|
|
{
|
|
Status = "Success",
|
|
Data = new { total, page, pageSize, items }
|
|
});
|
|
}
|
|
|
|
[HttpGet("dispatch/{dispatchId:int}")]
|
|
public async Task<IActionResult> ListForDispatch(int dispatchId)
|
|
{
|
|
var rows = await (from u in _db.DispatchUpliftRequests
|
|
where u.DispatchId == dispatchId && (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.DispatchId,
|
|
u.CurrentNTE,
|
|
u.RequestedNTE,
|
|
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
|
|
u.VendorReason,
|
|
u.Status,
|
|
u.RequiredTier,
|
|
u.RequestedByVendorName,
|
|
RequestedAt = u.CreatedDate,
|
|
u.DecidedAt,
|
|
u.DecisionNote,
|
|
DecidedByFirstName = dec != null ? dec.FirstName : null,
|
|
DecidedByLastName = dec != null ? dec.LastName : null
|
|
}).ToListAsync();
|
|
|
|
var items = rows.Select(r => new
|
|
{
|
|
r.Id,
|
|
r.DispatchId,
|
|
r.CurrentNTE,
|
|
r.RequestedNTE,
|
|
r.Delta,
|
|
r.VendorReason,
|
|
r.Status,
|
|
r.RequiredTier,
|
|
r.RequestedByVendorName,
|
|
r.RequestedAt,
|
|
r.DecidedAt,
|
|
r.DecisionNote,
|
|
DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName }
|
|
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
|
|
CanDecide = UserCanApprove(r.RequiredTier)
|
|
}).ToList();
|
|
|
|
return Ok(new DataResponse { Status = "Success", Data = items });
|
|
}
|
|
|
|
[HttpPost("{id:int}/approve")]
|
|
public async Task<IActionResult> Approve(int id, [FromBody] DecisionRequest? body)
|
|
{
|
|
var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == 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 approve a '{req.Status}' request" });
|
|
if (!UserCanApprove(req.RequiredTier))
|
|
return StatusCode(403, new Response { Status = "Error", Message = $"Approval requires a Tier {req.RequiredTier} role" });
|
|
|
|
var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId);
|
|
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 approve uplift on a '{dispatch.Status}' dispatch" });
|
|
|
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
var now = DateTime.UtcNow;
|
|
var oldNTE = dispatch.NTEAmount ?? 0m;
|
|
|
|
dispatch.NTEAmount = req.RequestedNTE;
|
|
dispatch.LastModificationTime = now;
|
|
|
|
req.Status = "Approved";
|
|
req.DecidedAt = now;
|
|
req.DecidedByUserId = userId;
|
|
req.DecisionNote = string.IsNullOrWhiteSpace(body?.Note) ? null : body!.Note!.Trim();
|
|
req.LastModificationTime = now;
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
|
UserId = userId,
|
|
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
|
OldValue = $"${oldNTE:F2}",
|
|
NewValue = $"${req.RequestedNTE:F2}",
|
|
Action = "uplift_approved",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, dispatch.NTEAmount } });
|
|
}
|
|
|
|
[HttpPost("{id:int}/deny")]
|
|
public async Task<IActionResult> Deny(int id, [FromBody] DecisionRequest body)
|
|
{
|
|
if (body == null || string.IsNullOrWhiteSpace(body.Note))
|
|
return BadRequest(new Response { Status = "Error", Message = "A note is required when denying" });
|
|
|
|
var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == 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 deny a '{req.Status}' request" });
|
|
if (!UserCanApprove(req.RequiredTier))
|
|
return StatusCode(403, new Response { Status = "Error", Message = $"Decision requires a Tier {req.RequiredTier} role" });
|
|
|
|
var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId);
|
|
|
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
var now = DateTime.UtcNow;
|
|
|
|
req.Status = "Denied";
|
|
req.DecidedAt = now;
|
|
req.DecidedByUserId = userId;
|
|
req.DecisionNote = body.Note!.Trim();
|
|
req.LastModificationTime = now;
|
|
|
|
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
{
|
|
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
|
UserId = userId,
|
|
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
|
OldValue = "Pending",
|
|
NewValue = "Denied",
|
|
Action = "uplift_denied",
|
|
CreatedAt = now
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
|
}
|
|
|
|
[HttpGet("can-approve")]
|
|
public IActionResult CanApprove([FromQuery] int tier)
|
|
{
|
|
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = UserCanApprove(tier) } });
|
|
}
|
|
|
|
private bool UserCanApprove(int requiredTier)
|
|
{
|
|
var key = $"Approvals:Tier{requiredTier}Roles";
|
|
var roles = _config.GetSection(key).Get<string[]>() ?? Array.Empty<string>();
|
|
foreach (var r in roles)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(r) && User.IsInRole(r)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public class DecisionRequest
|
|
{
|
|
public string? Note { get; set; }
|
|
}
|
|
}
|
|
}
|