shoc-backend/SeaHaven.Services/Implementation/DashboardService.cs

408 lines
17 KiB
C#

using System.Globalization;
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class DashboardService : IDashboardService
{
private readonly IDashboardDataService _dataService;
private readonly IWorkOrderAccountResolver _accountResolver;
public DashboardService(
IDashboardDataService dataService,
IWorkOrderAccountResolver accountResolver)
{
_dataService = dataService;
_accountResolver = accountResolver;
}
public async Task<DashboardStatsDTO> GetStatsAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var counts = await _dataService.GetWorkOrderCountsAsync(accountId, cancellationToken);
var kpis = await _dataService.GetKpiCountsAsync(
accountId,
dispatcherId,
DashboardBusinessTime.Today(),
cancellationToken);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId,
dispatcherId,
query.DateFrom,
query.DateTo,
cancellationToken);
var metrics = DashboardMetrics.Calculate(workOrders, DashboardBusinessTime.Today());
var useMetrics = query.DateFrom.HasValue && query.DateTo.HasValue || dispatcherId is not null;
return new DashboardStatsDTO
{
Total = useMetrics ? metrics.Total : counts.Total,
Open = useMetrics ? metrics.Open : counts.Open,
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
Completed = useMetrics ? metrics.Completed : counts.Completed,
ScheduledTomorrow = kpis.ScheduledTomorrow,
PendingUplifts = kpis.PendingUplifts,
AvetaPending = kpis.AvetaPending,
Breakdown = metrics.Breakdown,
DueCount = metrics.DueCount,
CompletedDueCount = metrics.CompletedDueCount,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
};
}
public async Task<DashboardWorkloadResponseDTO> GetWorkloadAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
int page,
CancellationToken cancellationToken)
{
if (page < 1)
throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var rows = await _dataService.GetDispatcherWorkloadAsync(
accountId,
dispatcherId,
query.DateFrom,
query.DateTo,
cancellationToken);
var orderedRows = rows
.OrderByDescending(row => row.OpenCount)
.ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.DispatcherId, StringComparer.Ordinal)
.ToList();
const int pageSize = 10;
return new DashboardWorkloadResponseDTO
{
Items = orderedRows.Skip((page - 1) * pageSize).Take(pageSize)
.Select(row => new DashboardWorkloadRowDTO
{
DispatcherId = row.DispatcherId,
DispatcherName = row.DispatcherName,
Color = row.Color,
TotalCount = row.TotalCount,
OpenCount = row.OpenCount
}).ToList(),
Page = page,
PageSize = pageSize,
TotalDispatchers = orderedRows.Count
};
}
public async Task<DashboardPerformanceResponseDTO> GetPerformanceAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
int page,
CancellationToken cancellationToken)
{
if (page < 1)
throw new ArgumentOutOfRangeException(nameof(page));
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var directory = await _dataService.GetDispatcherWorkloadAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
var today = DashboardBusinessTime.Today();
var rows = directory.Select(dispatcher =>
{
var metrics = DashboardMetrics.Calculate(
workOrders.Where(workOrder => workOrder.AssignTo == dispatcher.DispatcherId).ToList(),
today);
return new DashboardPerformanceRowDTO
{
DispatcherId = dispatcher.DispatcherId,
DispatcherName = dispatcher.DispatcherName,
Color = dispatcher.Color,
CompletionRate = metrics.CompletionRate,
AverageResolutionDays = metrics.AverageResolutionDays
};
})
.OrderByDescending(row => row.CompletionRate)
.ThenBy(row => row.DispatcherName, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.DispatcherId, StringComparer.Ordinal)
.ToList();
const int pageSize = 10;
return new DashboardPerformanceResponseDTO
{
Items = rows.Skip((page - 1) * pageSize).Take(pageSize).ToList(),
Page = page,
PageSize = pageSize,
TotalDispatchers = rows.Count
};
}
public async Task<DashboardRegionResponseDTO> GetRegionsAsync(
ClaimsPrincipal user,
DashboardStatsQueryDTO query,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, dispatcherId, query.DateFrom, query.DateTo, cancellationToken);
var counts = DashboardRegions.Count(workOrders);
return new DashboardRegionResponseDTO
{
Items = DashboardRegions.Names
.Select(region => new DashboardRegionRowDTO
{
Region = region,
WorkOrderCount = counts[region]
})
.ToList()
};
}
public async Task<DashboardTrendResponseDTO> GetTrendAsync(
ClaimsPrincipal user,
DashboardTrendQueryDTO query,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var dispatcherId = user.IsInRole("Dispatcher")
? user.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
var today = DashboardBusinessTime.Today();
var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today);
var firstBucketStart = BucketStart(windowFrom, granularity);
var lastBucketStart = BucketStart(windowTo, granularity);
var lastBucketEnd = BucketEnd(lastBucketStart, granularity);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId,
dispatcherId,
firstBucketStart.AddDays(-1),
lastBucketEnd.AddDays(1),
cancellationToken);
var buckets = BuildBucketStarts(firstBucketStart, windowTo, granularity)
.Select(start => new TrendBucket(start))
.ToList();
var bucketStarts = buckets.Select(bucket => bucket.Start).ToList();
foreach (var workOrder in workOrders)
{
if (workOrder.ScheduledDate is not DateTime scheduled)
continue;
var businessDate = DateOnly.FromDateTime(scheduled.Date);
if (businessDate < firstBucketStart || businessDate > lastBucketEnd)
continue;
var bucket = buckets[LocateBucket(bucketStarts, businessDate)];
var status = workOrder.LifecycleStatus
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
bucket.Total++;
if (status == LifecycleStatus.Completed)
bucket.Completed++;
else if (status == LifecycleStatus.Canceled)
bucket.Canceled++;
else
{
bucket.Open++;
if (businessDate < today)
bucket.Overdue++;
}
}
DateOnly? currentStart = today < firstBucketStart || today > lastBucketEnd
? null
: buckets[LocateBucket(bucketStarts, today)].Start;
return new DashboardTrendResponseDTO
{
Granularity = granularity,
Today = today,
Buckets = buckets.Select(bucket => new DashboardTrendBucketDTO
{
Date = bucket.Start,
Label = bucket.Start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Total = bucket.Total,
Open = bucket.Open,
Completed = bucket.Completed,
Canceled = bucket.Canceled,
Overdue = bucket.Overdue,
IsCurrent = bucket.Start == currentStart
}).ToList()
};
}
private static (DateOnly From, DateOnly To, string Granularity) ResolveTrendWindow(
DashboardTrendQueryDTO query,
DateOnly today)
{
if (query.Year is int year)
{
if (year < DateOnly.MinValue.Year || year > DateOnly.MaxValue.Year)
throw new ArgumentOutOfRangeException(nameof(query));
return (new DateOnly(year, 1, 1), new DateOnly(year, 12, 31), "month");
}
if (string.Equals(query.Range, ThreeMonthRange, StringComparison.OrdinalIgnoreCase))
{
var weeklyTo = query.DateTo ?? today;
var weeklyFrom = query.DateFrom ?? weeklyTo.AddMonths(-3).AddDays(1);
if (weeklyFrom > weeklyTo)
throw new ArgumentOutOfRangeException(nameof(query));
return (weeklyFrom, weeklyTo, "week");
}
if (query.DateFrom is DateOnly dailyFrom && query.DateTo is DateOnly dailyTo)
{
if (dailyFrom > dailyTo)
throw new ArgumentOutOfRangeException(nameof(query));
return (dailyFrom, dailyTo, "day");
}
if (query.DateFrom.HasValue != query.DateTo.HasValue)
throw new ArgumentOutOfRangeException(nameof(query));
return (today, today, "day");
}
private static List<DateOnly> BuildBucketStarts(DateOnly from, DateOnly to, string granularity)
{
var starts = new List<DateOnly>();
var current = BucketStart(from, granularity);
while (current <= to)
{
starts.Add(current);
current = granularity switch
{
"week" => current.AddDays(7),
"month" => current.AddMonths(1),
_ => current.AddDays(1)
};
}
return starts;
}
private static DateOnly BucketStart(DateOnly date, string granularity) => granularity switch
{
"week" => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)),
"month" => new DateOnly(date.Year, date.Month, 1),
_ => date
};
private static DateOnly BucketEnd(DateOnly start, string granularity) => granularity switch
{
"week" => start.AddDays(6),
"month" => start.AddMonths(1).AddDays(-1),
_ => start
};
private static int LocateBucket(List<DateOnly> starts, DateOnly date)
{
var low = 0;
var high = starts.Count - 1;
while (low < high)
{
var middle = (low + high + 1) / 2;
if (starts[middle] <= date)
low = middle;
else
high = middle - 1;
}
return low;
}
private const string ThreeMonthRange = "3m";
private sealed class TrendBucket(DateOnly start)
{
public DateOnly Start { get; } = start;
public int Total { get; set; }
public int Open { get; set; }
public int Completed { get; set; }
public int Canceled { get; set; }
public int Overdue { get; set; }
}
public async Task<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var workOrders = await _dataService.GetDashboardWorkOrdersAsync(
accountId, null, null, null, cancellationToken);
var activeVendorOrders = workOrders
.Where(workOrder => workOrder.VendorCompanyId.HasValue
&& workOrder.VendorIsActive
&& !workOrder.VendorCompanyIsDeleted)
.ToList();
var today = DashboardBusinessTime.Today();
var rows = activeVendorOrders
.GroupBy(workOrder => new
{
Id = workOrder.VendorCompanyId!.Value,
Name = workOrder.VendorCompanyName ?? string.Empty
})
.Select(group =>
{
var orders = group.ToList();
var metrics = DashboardMetrics.Calculate(orders, today);
var rescheduled = orders.Count(order => order.RescheduleCount >= 1);
return new DashboardVendorInsightsRowDTO
{
VendorCompanyId = group.Key.Id,
VendorCompanyName = group.Key.Name,
CompletionRate = metrics.CompletionRate,
RescheduleRate = Math.Round((decimal)rescheduled / orders.Count * 100, 2),
AverageResolutionDays = metrics.AverageResolutionDays,
TotalJobs = orders.Count
};
})
.OrderByDescending(row => row.CompletionRate)
.ThenBy(row => row.VendorCompanyName, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.VendorCompanyId)
.ToList();
const int pageSize = 10;
return new DashboardVendorInsightsResponseDTO
{
Items = rows.Take(pageSize).ToList(),
Page = 1,
PageSize = pageSize,
TotalVendors = rows.Count
};
}
}
}