shoc-backend/SeaHaven.DataServices/Implementation/DispatchDataService.cs

804 lines
35 KiB
C#
Raw Normal View History

2026-04-28 18:55:14 -05:00
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
using SeaHaven.DataServices.Dto;
2026-04-28 18:55:14 -05:00
using SeaHaven.DataServices.Interfaces;
refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
using SeaHaven.DataServices.Models;
2026-04-28 18:55:14 -05:00
namespace SeaHaven.DataServices.Implementation
{
public class DispatchDataService : IDispatchDataService
{
private readonly ApplicationDbContext _context;
public DispatchDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<Dispatch?> GetByIdAsync(int id)
{
return await _context.Dispatches.FindAsync(id);
}
public async Task<Dispatch?> GetByIdWithDetailsAsync(int id)
{
return await _context.Dispatches
.Include(d => d.Vendor)
.FirstOrDefaultAsync(d => d.Id == id);
}
refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
public async Task<Dispatch?> GetByIdWithVendorAndWorkOrderAsync(int id)
{
return await _context.Dispatches
.Include(dispatch => dispatch.Vendor)
.Include(dispatch => dispatch.WorkOrder)
.FirstOrDefaultAsync(dispatch => dispatch.Id == id);
}
2026-04-28 18:55:14 -05:00
public async Task<IEnumerable<Dispatch>> GetAllAsync()
{
return await _context.Dispatches.ToListAsync();
}
public async Task<IEnumerable<Dispatch>> GetByVendorIdAsync(int vendorId)
{
return await _context.Dispatches
.Where(d => d.VendorId == vendorId)
.ToListAsync();
}
public async Task<IEnumerable<Dispatch>> GetByStatusAsync(string status)
{
return await _context.Dispatches
.Where(d => d.Status == status)
.ToListAsync();
}
public async Task<(IEnumerable<Dispatch> Items, int TotalCount)> GetPagedAsync(
refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
int page,
int pageSize,
string? search = null,
2026-04-28 18:55:14 -05:00
string? status = null)
{
var query = _context.Dispatches.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(d => d.DispatchNumber!.Contains(search));
}
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(d => d.Status == status);
}
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(d => d.CreatedDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, totalCount);
}
public async Task<Dispatch> AddAsync(Dispatch dispatch)
{
dispatch.CreatedDate = DateTime.Now;
await _context.Dispatches.AddAsync(dispatch);
await _context.SaveChangesAsync();
return dispatch;
}
public async Task UpdateAsync(Dispatch dispatch)
{
dispatch.LastModificationTime = DateTime.Now;
_context.Dispatches.Update(dispatch);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var entity = await GetByIdAsync(id);
if (entity != null)
{
_context.Dispatches.Remove(entity);
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int id)
{
return await _context.Dispatches.AnyAsync(d => d.Id == id);
}
public async Task<string> GenerateNextDispatchNumberAsync()
{
return await Task.FromResult($"DISP-{DateTime.Now:yyyyMMdd}-{new Random().Next(1000, 9999)}");
}
public async Task<int> CountAsync()
{
return await _context.Dispatches.CountAsync();
}
2026-05-14 11:00:12 -05:00
public async Task<IEnumerable<Dispatch>> GetByWorkOrderIdAsync(
int workOrderId,
CancellationToken cancellationToken = default)
2026-05-14 11:00:12 -05:00
{
var dispatchIds = await _context.DispatchWorkOrders
.Where(dwo => dwo.WorkOrderId == workOrderId)
.Select(dwo => dwo.DispatchId)
.Distinct()
.ToListAsync(cancellationToken);
2026-05-14 11:00:12 -05:00
var directIds = await _context.Dispatches
.Where(d => d.WorkOrderId == workOrderId)
.Select(d => d.Id)
.ToListAsync(cancellationToken);
2026-05-14 11:00:12 -05:00
var allIds = dispatchIds.Union(directIds).Distinct().ToList();
return await _context.Dispatches
.Where(d => allIds.Contains(d.Id))
.Include(d => d.Vendor)
.ToListAsync(cancellationToken);
2026-05-14 11:00:12 -05:00
}
public async Task<IEnumerable<Dispatch>> GetByWorkOrderIdsAsync(IEnumerable<int> workOrderIds)
{
return await _context.Dispatches
.Where(d => workOrderIds.Contains(d.WorkOrderId ?? 0))
.Include(d => d.Vendor)
.ToListAsync();
}
public async Task<string> GenerateNextPONumberAsync()
{
var lastPO = await _context.Dispatches
.Where(d => d.PONumber != null)
.OrderByDescending(d => d.PONumber)
.Select(d => d.PONumber)
.FirstOrDefaultAsync();
int nextPO = 1000;
if (!string.IsNullOrEmpty(lastPO) && int.TryParse(lastPO, out int lastNum))
{
nextPO = lastNum + 1;
}
return nextPO.ToString();
}
public async Task<IEnumerable<DispatchChecklistItem>> GetChecklistItemsAsync(int dispatchId)
{
return await _context.DispatchChecklistItems
.Where(ci => ci.DispatchId == dispatchId)
.OrderBy(ci => ci.SortOrder)
.ToListAsync();
}
public async Task<DispatchChecklistItem?> GetChecklistItemByIdAsync(int id)
{
return await _context.DispatchChecklistItems.FindAsync(id);
}
public async Task AddChecklistItemAsync(DispatchChecklistItem item)
{
await _context.DispatchChecklistItems.AddAsync(item);
await _context.SaveChangesAsync();
}
public async Task UpdateChecklistItemAsync(DispatchChecklistItem item)
{
_context.DispatchChecklistItems.Update(item);
await _context.SaveChangesAsync();
}
public async Task<IEnumerable<DispatchSignoff>> GetSignoffsAsync(int dispatchId)
{
return await _context.DispatchSignoffs
.Where(s => s.DispatchId == dispatchId)
.ToListAsync();
}
public async Task AddSignoffAsync(DispatchSignoff signoff)
{
signoff.SignedAt = DateTime.UtcNow;
await _context.DispatchSignoffs.AddAsync(signoff);
await _context.SaveChangesAsync();
}
public async Task<bool> HasSignoffTypeAsync(int dispatchId, string signoffType)
{
return await _context.DispatchSignoffs
.AnyAsync(s => s.DispatchId == dispatchId && s.SignoffType == signoffType);
}
public async Task<IEnumerable<DispatchWorkOrder>> GetDispatchWorkOrdersAsync(int dispatchId)
{
return await _context.DispatchWorkOrders
.Where(dwo => dwo.DispatchId == dispatchId)
.Include(dwo => dwo.WorkOrder)
.ToListAsync();
}
public async Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder)
{
await _context.DispatchWorkOrders.AddAsync(dispatchWorkOrder);
await _context.SaveChangesAsync();
}
refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
public async Task<DispatchListResult> GetListAsync(DispatchListQuery query, CancellationToken cancellationToken)
{
var q = _context.Dispatches
.Include(d => d.Vendor)
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
.AsNoTracking()
.Where(d => d.IsDeleted == null || d.IsDeleted == false);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var s = query.Search.ToLower();
q = q.Where(d =>
(d.DispatchNumber ?? "").ToLower().Contains(s) ||
(d.PONumber ?? "").ToLower().Contains(s) ||
(d.Vendor != null && (d.Vendor.CompanyName ?? "").ToLower().Contains(s)) ||
(d.WorkOrder != null && (d.WorkOrder.InternalWONumber ?? "").ToLower().Contains(s)) ||
(d.WorkOrder != null && (d.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(s)) ||
(d.WorkOrder != null && d.WorkOrder.Locations != null && (d.WorkOrder.Locations.Name ?? "").ToLower().Contains(s))
);
}
if (!string.IsNullOrWhiteSpace(query.Status))
q = q.Where(d => d.Status == query.Status);
if (query.VendorId.HasValue)
q = q.Where(d => d.VendorId == query.VendorId.Value);
if (query.DateFrom.HasValue)
q = q.Where(d => d.DispatchedAt >= query.DateFrom.Value);
if (query.DateTo.HasValue)
{
var end = query.DateTo.Value.Date.AddDays(1);
q = q.Where(d => d.DispatchedAt < end);
}
var total = await q.CountAsync(cancellationToken);
var dir = (query.SortDir ?? "desc").ToLowerInvariant() == "asc";
switch ((query.SortBy ?? "").ToLowerInvariant())
{
case "ponumber":
q = dir ? q.OrderBy(d => d.PONumber) : q.OrderByDescending(d => d.PONumber);
break;
case "dispatchnumber":
q = dir ? q.OrderBy(d => d.DispatchNumber) : q.OrderByDescending(d => d.DispatchNumber);
break;
case "vendor":
q = dir ? q.OrderBy(d => d.Vendor!.CompanyName) : q.OrderByDescending(d => d.Vendor!.CompanyName);
break;
case "nte":
case "nteamount":
q = dir ? q.OrderBy(d => d.NTEAmount) : q.OrderByDescending(d => d.NTEAmount);
break;
case "status":
q = dir ? q.OrderBy(d => d.Status) : q.OrderByDescending(d => d.Status);
break;
case "scheduleddate":
q = dir ? q.OrderBy(d => d.ScheduledDate) : q.OrderByDescending(d => d.ScheduledDate);
break;
case "completeddate":
q = dir ? q.OrderBy(d => d.CompletedDate) : q.OrderByDescending(d => d.CompletedDate);
break;
default:
q = dir ? q.OrderBy(d => d.DispatchedAt) : q.OrderByDescending(d => d.DispatchedAt);
break;
}
var rows = await q
.Skip((Math.Max(query.Page, 1) - 1) * query.PageSize)
.Take(query.PageSize)
.Select(d => new DispatchListItemDTO
{
Id = d.Id,
DispatchNumber = d.DispatchNumber,
PONumber = d.PONumber,
NTEAmount = d.NTEAmount,
Status = d.Status,
DispatchedAt = d.DispatchedAt,
ScheduledDate = d.ScheduledDate,
CompletedDate = d.CompletedDate,
AcknowledgedAt = d.AcknowledgedAt,
VerifiedAt = d.VerifiedAt,
VendorId = d.VendorId,
VendorName = d.Vendor != null ? d.Vendor.CompanyName : null,
WorkOrderId = d.WorkOrderId,
PrimaryWOInternalNumber = d.WorkOrder != null ? d.WorkOrder.InternalWONumber : null,
PrimaryWOTitle = d.WorkOrder != null ? d.WorkOrder.WorkerOrderTitle : null,
LocationName = d.WorkOrder != null && d.WorkOrder.Locations != null ? d.WorkOrder.Locations.Name : null,
WorkOrderCount = _context.DispatchWorkOrders.Count(dwo => dwo.DispatchId == d.Id),
PendingUpliftCount = _context.DispatchUpliftRequests.Count(u => u.DispatchId == d.Id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false))
})
.ToListAsync(cancellationToken);
return new DispatchListResult
{
Items = rows,
Total = total,
Page = query.Page,
PageSize = query.PageSize
};
}
public async Task<DispatchDetailDTO?> GetDetailAsync(int id, CancellationToken cancellationToken)
{
var dispatch = await _context.Dispatches
.Include(d => d.Vendor)
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
if (dispatch == null) return null;
var linked = await _context.DispatchWorkOrders
.AsNoTracking()
.Where(dwo => dwo.DispatchId == id)
.Select(dwo => new DispatchLinkedWorkOrderDTO
{
WorkOrderId = dwo.WorkOrderId,
InternalWONumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : null,
Title = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : null,
Priority = dwo.WorkOrder != null ? dwo.WorkOrder.Priority : null,
Status = dwo.WorkOrder != null ? dwo.WorkOrder.Status : null,
LocationName = dwo.WorkOrder != null && dwo.WorkOrder.Locations != null ? dwo.WorkOrder.Locations.Name : null,
Trade = dwo.WorkOrder != null ? dwo.WorkOrder.Trade : null,
SubTrade = dwo.WorkOrder != null ? dwo.WorkOrder.SubTrade : null,
Problem = dwo.WorkOrder != null ? dwo.WorkOrder.Problem : null
})
.ToListAsync(cancellationToken);
var linkedWOs = linked;
if (linkedWOs.Count == 0 && dispatch.WorkOrderId.HasValue)
{
var wo = await _context.workOrders
.Include(w => w.Locations)
.AsNoTracking()
.FirstOrDefaultAsync(w => w.Id == dispatch.WorkOrderId.Value, cancellationToken);
if (wo != null)
{
linkedWOs = new List<DispatchLinkedWorkOrderDTO>
{
new DispatchLinkedWorkOrderDTO
{
WorkOrderId = wo.Id,
InternalWONumber = wo.InternalWONumber,
Title = wo.WorkerOrderTitle,
Priority = wo.Priority,
Status = wo.Status,
LocationName = wo.Locations != null ? wo.Locations.Name : null,
Trade = wo.Trade,
SubTrade = wo.SubTrade,
Problem = wo.Problem
}
};
}
}
var checklist = await _context.DispatchChecklistItems
.AsNoTracking()
.Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false))
.OrderBy(c => c.SortOrder)
.Select(c => new DispatchChecklistDTO
{
Id = c.Id,
ItemText = c.ItemText,
IsCompleted = c.IsCompleted,
CompletedBy = c.CompletedBy,
CompletedAt = c.CompletedAt,
SortOrder = c.SortOrder,
WorkOrderId = c.WorkOrderId
})
.ToListAsync(cancellationToken);
var signoffs = await _context.DispatchSignoffs
.AsNoTracking()
.Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false))
.Select(s => new DispatchSignoffDTO
{
Id = s.Id,
SignoffType = s.SignoffType,
Name = s.Name,
Signature = s.Signature,
SignatureMethod = s.SignatureMethod,
SignedAt = s.SignedAt
})
.ToListAsync(cancellationToken);
var rawComments = await (from c in _context.Comments
where c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)
join u in _context.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.RecordType,
c.CreatedDate,
c.UserId,
UserFirstName = u != null ? u.FirstName : null,
UserLastName = u != null ? u.LastName : null
})
.ToListAsync(cancellationToken);
var comments = rawComments.Select(c => new DispatchCommentDTO
{
Id = c.Id,
Commenttext = c.Commenttext,
Commenter = c.Commenter,
CommentType = c.CommentType,
RecordType = c.RecordType,
CreatedDate = c.CreatedDate,
UserId = c.UserId,
UserName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
}).ToList();
var rawUplifts = await (from u in _context.DispatchUpliftRequests
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
join dec in _context.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,
RequestedAt = u.CreatedDate,
u.DecidedAt,
u.DecisionNote,
DecidedByFirstName = dec != null ? dec.FirstName : null,
DecidedByLastName = dec != null ? dec.LastName : null
})
.ToListAsync(cancellationToken);
var upliftRequests = rawUplifts.Select(u => new DispatchUpliftRequestDTO
{
Id = u.Id,
CurrentNTE = u.CurrentNTE,
RequestedNTE = u.RequestedNTE,
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
VendorReason = u.VendorReason,
Status = u.Status,
RequiredTier = u.RequiredTier,
RequestedByVendorName = u.RequestedByVendorName,
RequestedAt = u.RequestedAt,
DecidedAt = u.DecidedAt,
DecisionNote = u.DecisionNote,
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
}).ToList();
var dispatchNumber = dispatch.DispatchNumber ?? "";
var workOrderIds = linkedWOs.Select(l => l.WorkOrderId).Where(wid => wid > 0).ToList();
var rawAudit = await (from a in _context.WorkOrderAuditLogs
where workOrderIds.Contains(a.WorkOrderId)
&& a.FieldName != null
&& a.FieldName.Contains(dispatchNumber)
join u in _context.Users on a.UserId equals u.Id into users
from u in users.DefaultIfEmpty()
orderby a.CreatedAt descending
select new
{
a.Id,
a.Action,
a.FieldName,
a.OldValue,
a.NewValue,
a.CreatedAt,
a.UserId,
UserFirstName = u != null ? u.FirstName : null,
UserLastName = u != null ? u.LastName : null
})
.ToListAsync(cancellationToken);
var auditLog = rawAudit.Select(a => new DispatchAuditLogDTO
{
Id = a.Id,
Action = a.Action,
FieldName = a.FieldName,
OldValue = a.OldValue,
NewValue = a.NewValue,
CreatedAt = a.CreatedAt,
UserId = a.UserId,
UserName = string.Join(" ", new[] { a.UserFirstName, a.UserLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
}).ToList();
return new DispatchDetailDTO
{
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,
NTEAmount = dispatch.NTEAmount,
Description = dispatch.Description,
Status = dispatch.Status,
ScheduledDate = dispatch.ScheduledDate,
CompletedDate = dispatch.CompletedDate,
DispatchedAt = dispatch.DispatchedAt,
AcknowledgedAt = dispatch.AcknowledgedAt,
VerifiedAt = dispatch.VerifiedAt,
VerifiedBy = dispatch.VerifiedBy,
EmailSent = dispatch.EmailSent,
WorkOrderId = dispatch.WorkOrderId,
Vendor = dispatch.Vendor == null ? null : new DispatchDetailVendorDTO
{
Id = dispatch.Vendor.Id,
CompanyName = dispatch.Vendor.CompanyName,
ContactName = dispatch.Vendor.ContactName,
Email = dispatch.Vendor.Email,
Phone = dispatch.Vendor.Phone,
Address = dispatch.Vendor.Address,
City = dispatch.Vendor.City,
State = dispatch.Vendor.State,
Zip = dispatch.Vendor.Zip,
TradeSpecialties = dispatch.Vendor.TradeSpecialties
},
PrimaryLocation = dispatch.WorkOrder?.Locations == null ? null : new DispatchPrimaryLocationDTO
{
Id = dispatch.WorkOrder.Locations.Id,
Name = dispatch.WorkOrder.Locations.Name,
Address1 = dispatch.WorkOrder.Locations.Address1,
City = dispatch.WorkOrder.Locations.City,
State = dispatch.WorkOrder.Locations.State,
Zip = dispatch.WorkOrder.Locations.Zip
},
WorkOrders = linkedWOs,
Checklist = checklist,
Signoffs = signoffs,
Comments = comments,
UpliftRequests = upliftRequests,
AuditLog = auditLog
};
}
public async Task StageSignoffAsync(
DispatchSignoff signoff, CancellationToken cancellationToken)
{
await _context.DispatchSignoffs.AddAsync(signoff, cancellationToken);
}
public async Task<IReadOnlyList<VendorDispatchSummaryData>> GetVendorDispatchSummariesAsync(
int vendorId, string? status, CancellationToken cancellationToken)
{
var query = _context.Dispatches
.AsNoTracking()
.Where(dispatch => dispatch.VendorId == vendorId
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false));
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(dispatch => dispatch.Status == status);
return await query
.OrderByDescending(dispatch => dispatch.DispatchedAt ?? dispatch.CreatedDate)
.Select(dispatch => new VendorDispatchSummaryData
{
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,
Status = dispatch.Status,
NTEAmount = dispatch.NTEAmount,
ScheduledDate = dispatch.ScheduledDate,
CompletedDate = dispatch.CompletedDate,
DispatchedAt = dispatch.DispatchedAt,
AcknowledgedAt = dispatch.AcknowledgedAt,
WorkOrderTitle = dispatch.WorkOrder != null
? dispatch.WorkOrder.WorkerOrderTitle
: null,
InternalWONumber = dispatch.WorkOrder != null
? dispatch.WorkOrder.InternalWONumber
: null,
LocationName = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null
? dispatch.WorkOrder.Locations.Name
: null,
LocationCity = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null
? dispatch.WorkOrder.Locations.City
: null,
LocationState = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null
? dispatch.WorkOrder.Locations.State
: null
})
.ToListAsync(cancellationToken);
}
public async Task<Dispatch?> GetVendorDispatchDetailAsync(
int id, int vendorId, CancellationToken cancellationToken)
{
return await _context.Dispatches
.Include(dispatch => dispatch.WorkOrder)
.ThenInclude(workOrder => workOrder!.Locations)
.AsNoTracking()
.FirstOrDefaultAsync(dispatch => dispatch.Id == id
&& dispatch.VendorId == vendorId
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false),
cancellationToken);
}
public async Task<Dispatch?> GetVendorDispatchForMutationAsync(
int id, int vendorId, CancellationToken cancellationToken)
{
return await _context.Dispatches
.Include(dispatch => dispatch.WorkOrder)
.ThenInclude(workOrder => workOrder!.Locations)
.FirstOrDefaultAsync(dispatch => dispatch.Id == id
&& dispatch.VendorId == vendorId
&& (dispatch.IsDeleted == null || dispatch.IsDeleted == false),
cancellationToken);
}
public async Task<IReadOnlyList<PortalChecklistItemData>> GetPortalChecklistAsync(
int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchChecklistItems
.AsNoTracking()
.Where(item => item.DispatchId == dispatchId
&& (item.IsDeleted == null || item.IsDeleted == false))
.OrderBy(item => item.SortOrder)
.Select(item => new PortalChecklistItemData
{
Id = item.Id,
ItemText = item.ItemText,
IsCompleted = item.IsCompleted,
CompletedBy = item.CompletedBy,
CompletedAt = item.CompletedAt
})
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<PortalSignoffData>> GetPortalSignoffsAsync(
int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchSignoffs
.AsNoTracking()
.Where(signoff => signoff.DispatchId == dispatchId
&& (signoff.IsDeleted == null || signoff.IsDeleted == false))
.Select(signoff => new PortalSignoffData
{
Id = signoff.Id,
SignoffType = signoff.SignoffType,
Name = signoff.Name,
SignatureMethod = signoff.SignatureMethod,
SignedAt = signoff.SignedAt
})
.ToListAsync(cancellationToken);
}
public async Task<DispatchChecklistItem?> GetChecklistItemForDispatchAsync(
int itemId, int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchChecklistItems
.FirstOrDefaultAsync(
item => item.Id == itemId && item.DispatchId == dispatchId,
cancellationToken);
}
public async Task StageAuditLogAsync(
WorkOrderAuditLog log, CancellationToken cancellationToken)
{
await _context.WorkOrderAuditLogs.AddAsync(log, cancellationToken);
}
public async Task<string?> GetDispatchDispatcherUserIdAsync(
int workOrderId, CancellationToken cancellationToken)
{
return await _context.WorkOrderAuditLogs
.AsNoTracking()
.Where(audit => audit.WorkOrderId == workOrderId
&& audit.Action == "dispatch"
&& audit.UserId != null
&& audit.UserId != "")
.OrderByDescending(audit => audit.CreatedAt)
.Select(audit => audit.UserId)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task SaveChangesAsync(CancellationToken cancellationToken)
{
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<DispatchDetailReadModel?> GetDispatchDetailAsync(int id)
{
return await _context.Dispatches
.AsNoTracking()
.Where(dispatch => dispatch.Id == id)
.Select(dispatch => new DispatchDetailReadModel
{
Id = dispatch.Id,
DispatchNumber = dispatch.DispatchNumber,
PONumber = dispatch.PONumber,
NTEAmount = dispatch.NTEAmount,
Description = dispatch.Description,
Status = dispatch.Status,
ScheduledDate = dispatch.ScheduledDate,
CompletedDate = dispatch.CompletedDate,
DispatchedAt = dispatch.DispatchedAt,
EmailSent = dispatch.EmailSent,
WorkOrderId = dispatch.WorkOrderId,
AcknowledgedAt = dispatch.AcknowledgedAt,
VerifiedBy = dispatch.VerifiedBy,
VerifiedAt = dispatch.VerifiedAt,
WoNumber = dispatch.WorkOrder != null
? dispatch.WorkOrder.InternalWONumber
: "",
WoTitle = dispatch.WorkOrder != null
? dispatch.WorkOrder.WorkerOrderTitle
: "",
VendorName = dispatch.Vendor != null ? dispatch.Vendor.CompanyName : "",
VendorEmail = dispatch.Vendor != null ? dispatch.Vendor.Email : "",
VendorId = dispatch.VendorId,
WorkOrders = _context.DispatchWorkOrders
.Where(link => link.DispatchId == dispatch.Id)
.Select(link => new DispatchWorkOrderReadModel
{
WorkOrderId = link.WorkOrderId,
WoNumber = link.WorkOrder != null
? link.WorkOrder.InternalWONumber
: "",
WoTitle = link.WorkOrder != null
? link.WorkOrder.WorkerOrderTitle
: ""
}).ToList(),
ChecklistItems = _context.DispatchChecklistItems
.Where(item => item.DispatchId == dispatch.Id)
.OrderBy(item => item.SortOrder)
.Select(item => new ChecklistItemReadModel
{
Id = item.Id,
ItemText = item.ItemText,
IsCompleted = item.IsCompleted,
CompletedBy = item.CompletedBy,
CompletedAt = item.CompletedAt,
SortOrder = item.SortOrder,
WorkOrderId = item.WorkOrderId
}).ToList(),
Signoffs = _context.DispatchSignoffs
.Where(signoff => signoff.DispatchId == dispatch.Id)
.Select(signoff => new SignoffReadModel
{
Id = signoff.Id,
SignoffType = signoff.SignoffType,
Name = signoff.Name,
Signature = signoff.Signature,
SignatureMethod = signoff.SignatureMethod,
SignedAt = signoff.SignedAt
}).ToList(),
Comments = _context.Comments
.Where(comment => comment.DispatchId == dispatch.Id)
.OrderBy(comment => comment.CreatedDate)
.Select(comment => new DispatchCommentReadModel
{
Id = comment.Id,
CreatedDate = comment.CreatedDate,
Commenttext = comment.Commenttext,
Commenter = comment.Commenter,
CommentType = comment.CommentType,
UserName = comment.ApplicationUser != null
? comment.ApplicationUser.FirstName + " "
+ comment.ApplicationUser.LastName
: ""
}).ToList()
})
.FirstOrDefaultAsync();
}
2026-04-28 18:55:14 -05:00
}
}