mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
GET /dashboard/stats now returns unassigned: open (not Completed or Canceled) work orders with no dispatcher in the selected period, within the caller's server-derived account scope. It uses the same filters as the Unassigned board search, so the Dashboard number equals the list it drills into. A dispatcher-scoped Dashboard has no unassigned work and returns 0.
426 lines
18 KiB
C#
426 lines
18 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 unassigned = await _dataService.CountOpenUnassignedAsync(
|
|
accountId,
|
|
dispatcherId,
|
|
query.DateFrom,
|
|
query.DateTo,
|
|
cancellationToken);
|
|
|
|
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,
|
|
Unassigned = unassigned,
|
|
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.");
|
|
}
|
|
}
|
|
}
|