mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
ResolveDispatcherScope now admits the Scheduler role, which owns the viewAllDispatchersOnDashboard permission, so it can load Stats, Workload, Performance, Regions and Trend instead of being denied. GetTrendAsync now resolves scope via the shared ResolveDispatcherScope so a picker DispatcherId selection scopes Trend consistently with the other endpoints and unauthorized roles fail closed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
419 lines
17 KiB
C#
419 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 = ResolveDispatcherScope(user, query);
|
|
|
|
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,
|
|
StatusDistribution = metrics.StatusDistribution
|
|
};
|
|
}
|
|
|
|
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 = ResolveDispatcherScope(user, query);
|
|
|
|
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 = ResolveDispatcherScope(user, query);
|
|
|
|
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,
|
|
AssignedCount = metrics.Total,
|
|
CompletedCount = metrics.Completed
|
|
};
|
|
})
|
|
.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 = ResolveDispatcherScope(user, query);
|
|
|
|
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 = ResolveDispatcherScope(user, query);
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
private string? ResolveDispatcherScope(ClaimsPrincipal user, DashboardStatsQueryDTO query)
|
|
{
|
|
if (user.IsInRole("Dispatcher"))
|
|
{
|
|
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(dispatcherId))
|
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
return dispatcherId;
|
|
}
|
|
|
|
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
|
|
{
|
|
var selected = query.DispatcherId;
|
|
if (string.IsNullOrWhiteSpace(selected))
|
|
return null;
|
|
if (selected.Equals("mine", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var identity = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(identity))
|
|
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
|
|
return identity;
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
throw new WorkOrderBoardValidationException("Forbidden", "Dashboard access requires an authorized role.");
|
|
}
|
|
}
|
|
}
|