mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
ScheduledDate is persisted as a midnight calendar value (board create writes request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and converted to America/New_York, so midnight became 19:00/20:00 the previous day and every board-scheduled work order fell into the prior bucket: a job scheduled today read as yesterday and overdue, and the Today bucket showed zero. Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the same rows. Rewrite the daily and yearly trend tests to assert calendar-date classification (the removed conversion had encoded the shift into their expectations) and add a regression test that a midnight-today work order stays in the Today bucket and is not overdue.
400 lines
17 KiB
C#
400 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 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,
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|