mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-02 17:53:20 +00:00
Merge branch 'dev' into feat/ab/sh-329-account-owner
This commit is contained in:
commit
e7e196caba
15 changed files with 1828 additions and 44 deletions
|
|
@ -1,7 +1,12 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Implementation;
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -17,33 +22,647 @@ public class DashboardServiceTests
|
||||||
return new ApplicationDbContext(options);
|
return new ApplicationDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardService NewService(ApplicationDbContext ctx) =>
|
private static DashboardService NewService(ApplicationDbContext ctx)
|
||||||
new(new DashboardDataService(ctx));
|
{
|
||||||
|
var resolver = new WorkOrderAccountResolver(
|
||||||
|
new AccountDataService(ctx),
|
||||||
|
new LocationDataService(ctx));
|
||||||
|
return new DashboardService(new DashboardDataService(ctx), resolver);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) =>
|
private static ClaimsPrincipal AccountUser(
|
||||||
new() { Status = status, istemplate = isTemplate, AssignTo = assignTo };
|
int accountId,
|
||||||
|
string userId = "dispatcher-1",
|
||||||
|
string role = "Admin")
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||||
|
new Claim(ClaimTypes.Role, role)
|
||||||
|
};
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal OrgWideUser()
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
||||||
|
new Claim(ClaimTypes.Role, "Admin")
|
||||||
|
};
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
|
public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
using var ctx = NewContext();
|
||||||
ctx.workOrders.AddRange(
|
ctx.workOrders.AddRange(
|
||||||
Wo("Open", isTemplate: false),
|
new WorkOrder { AccountId = 1, Status = "Open", istemplate = false },
|
||||||
Wo("Open", isTemplate: false, assignTo: "u1"),
|
new WorkOrder { AccountId = 1, Status = "Open", istemplate = false, AssignTo = "u1" },
|
||||||
Wo("In Progress"),
|
new WorkOrder { AccountId = 1, Status = "In Progress" },
|
||||||
Wo("On Hold"),
|
new WorkOrder { AccountId = 1, Status = "On Hold" },
|
||||||
Wo("Done"),
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
||||||
Wo("Done"),
|
new WorkOrder { AccountId = 1, Status = "Done" },
|
||||||
Wo("Open", isTemplate: true),
|
new WorkOrder { AccountId = 1, Status = "Open", istemplate = true },
|
||||||
Wo("Cancelled", isTemplate: false)
|
new WorkOrder { AccountId = 1, Status = "Cancelled" },
|
||||||
|
new WorkOrder { AccountId = 2, Status = "Open" },
|
||||||
|
new WorkOrder { Status = "Open" }
|
||||||
);
|
);
|
||||||
ctx.SaveChanges();
|
ctx.SaveChanges();
|
||||||
|
|
||||||
var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None);
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
stats.Total.Should().Be(7);
|
stats.Total.Should().Be(7);
|
||||||
stats.Open.Should().Be(4);
|
stats.Open.Should().Be(4);
|
||||||
stats.NotDispatched.Should().Be(1);
|
stats.NotDispatched.Should().Be(1);
|
||||||
stats.Completed.Should().Be(2);
|
stats.Completed.Should().Be(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_OrgWideUserSeesAllNonTemplateOrders()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open" },
|
||||||
|
new WorkOrder { AccountId = 2, Status = "Done" },
|
||||||
|
new WorkOrder { Status = "Open", istemplate = true });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
OrgWideUser(), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Total.Should().Be(2);
|
||||||
|
stats.Open.Should().Be(1);
|
||||||
|
stats.Completed.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_MissingScopeFailsClosed()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Role, "Dispatcher")
|
||||||
|
}, "test"));
|
||||||
|
|
||||||
|
var act = () => NewService(ctx).GetStatsAsync(
|
||||||
|
user, new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
var exception = await act.Should().ThrowAsync<WorkOrderBoardValidationException>();
|
||||||
|
|
||||||
|
exception.Which.Code.Should().Be("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_UsesDashboardMetricRulesForDateRange()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
|
||||||
|
OriginalDate = today.AddDays(-6)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Emergency,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.InProgress,
|
||||||
|
ScheduledDate = today.AddDays(-3).ToDateTime(TimeOnly.MinValue)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.Reactive,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
|
||||||
|
OriginalDate = today.AddDays(-15),
|
||||||
|
CompletedDate = today.AddDays(-12).ToDateTime(TimeOnly.MinValue)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
WorkOrderType = Data.SeaHavenIndustries.Enums.WorkOrderType.PM,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Incomplete,
|
||||||
|
ScheduledDate = today.AddDays(4).ToDateTime(TimeOnly.MinValue)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardStatsQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = today.AddDays(-10),
|
||||||
|
DateTo = today.AddDays(10)
|
||||||
|
};
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Total.Should().Be(4);
|
||||||
|
stats.Breakdown.Overdue.Should().Be(1);
|
||||||
|
stats.Breakdown.Other.Should().Be(0);
|
||||||
|
stats.Breakdown.PM.Should().Be(2);
|
||||||
|
stats.Breakdown.Emergency.Should().Be(0);
|
||||||
|
stats.Breakdown.Reactive.Should().Be(1);
|
||||||
|
(stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive
|
||||||
|
+ stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total);
|
||||||
|
stats.DueCount.Should().Be(3);
|
||||||
|
stats.CompletedDueCount.Should().Be(1);
|
||||||
|
stats.CompletionRate.Should().Be(33.33m);
|
||||||
|
stats.AverageResolutionDays.Should().Be(3m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_DispatcherOnlySeesAssignedOrders()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-1" },
|
||||||
|
new WorkOrder { AccountId = 1, Status = "Open", AssignTo = "dispatcher-2" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(
|
||||||
|
AccountUser(1, role: "Dispatcher"), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Total.Should().Be(1);
|
||||||
|
stats.Open.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetWorkloadAsync_UsesRoleScopeAndExcludesTerminalOrdersFromOpenCount()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Roles.Add(new IdentityRole
|
||||||
|
{
|
||||||
|
Id = "dispatcher-role",
|
||||||
|
Name = "Dispatcher",
|
||||||
|
NormalizedName = "DISPATCHER"
|
||||||
|
});
|
||||||
|
ctx.Users.AddRange(
|
||||||
|
new ApplicationUser { Id = "dispatcher-1", FirstName = "Ada", LastName = "One" },
|
||||||
|
new ApplicationUser { Id = "dispatcher-2", FirstName = "Ben", LastName = "Two" });
|
||||||
|
ctx.UserRoles.AddRange(
|
||||||
|
new IdentityUserRole<string> { UserId = "dispatcher-1", RoleId = "dispatcher-role" },
|
||||||
|
new IdentityUserRole<string> { UserId = "dispatcher-2", RoleId = "dispatcher-role" });
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", Status = "Open" },
|
||||||
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-1", LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed },
|
||||||
|
new WorkOrder { AccountId = 1, AssignTo = "dispatcher-2", Status = "Open" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetWorkloadAsync(
|
||||||
|
AccountUser(1, "dispatcher-1", "Dispatcher"),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
1,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.TotalDispatchers.Should().Be(1);
|
||||||
|
result.Items.Should().ContainSingle();
|
||||||
|
result.Items[0].DispatcherName.Should().Be("Ada One");
|
||||||
|
result.Items[0].TotalCount.Should().Be(2);
|
||||||
|
result.Items[0].OpenCount.Should().Be(1);
|
||||||
|
result.PageSize.Should().Be(10);
|
||||||
|
|
||||||
|
var adminResult = await NewService(ctx).GetWorkloadAsync(
|
||||||
|
AccountUser(1),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
1,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
adminResult.TotalDispatchers.Should().Be(2);
|
||||||
|
adminResult.Items.Select(item => item.DispatcherName)
|
||||||
|
.Should().ContainInOrder("Ada One", "Ben Two");
|
||||||
|
|
||||||
|
var performance = await NewService(ctx).GetPerformanceAsync(
|
||||||
|
AccountUser(1),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
1,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
performance.TotalDispatchers.Should().Be(2);
|
||||||
|
performance.Items[0].DispatcherName.Should().Be("Ada One");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPerformanceAsync_UsesDueOnlyRateAndOriginalDateResolution()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Roles.Add(new IdentityRole
|
||||||
|
{
|
||||||
|
Id = "dispatcher-role",
|
||||||
|
Name = "Dispatcher",
|
||||||
|
NormalizedName = "DISPATCHER"
|
||||||
|
});
|
||||||
|
ctx.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "dispatcher-1",
|
||||||
|
FirstName = "Ada",
|
||||||
|
LastName = "One",
|
||||||
|
Color = "#123456"
|
||||||
|
});
|
||||||
|
ctx.UserRoles.Add(new IdentityUserRole<string>
|
||||||
|
{
|
||||||
|
UserId = "dispatcher-1",
|
||||||
|
RoleId = "dispatcher-role"
|
||||||
|
});
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue),
|
||||||
|
OriginalDate = today.AddDays(-5),
|
||||||
|
CompletedDate = today.AddDays(-2).ToDateTime(TimeOnly.MinValue)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = today.AddDays(3).ToDateTime(TimeOnly.MinValue)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetPerformanceAsync(
|
||||||
|
AccountUser(1),
|
||||||
|
new DashboardStatsQueryDTO(),
|
||||||
|
1,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Items.Should().ContainSingle();
|
||||||
|
result.Items[0].CompletionRate.Should().Be(100m);
|
||||||
|
result.Items[0].AverageResolutionDays.Should().Be(3m);
|
||||||
|
result.Items[0].Color.Should().Be("#123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRegionsAsync_UsesCanonicalBucketsAndUnmappedOther()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
ctx.Locations.AddRange(
|
||||||
|
new Locations { Id = 1, State = "NY" },
|
||||||
|
new Locations { Id = 2, State = "ca" },
|
||||||
|
new Locations { Id = 3, State = "XX" });
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder { AccountId = 1, LocationId = 1 },
|
||||||
|
new WorkOrder { AccountId = 1, LocationId = 2 },
|
||||||
|
new WorkOrder { AccountId = 1, LocationId = 3 },
|
||||||
|
new WorkOrder { AccountId = 1, LocationId = null });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetRegionsAsync(
|
||||||
|
AccountUser(1), new DashboardStatsQueryDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Items.Select(row => row.Region).Should().Equal(
|
||||||
|
"East", "Central", "West", "California", "Unmapped/Other");
|
||||||
|
result.Items.Select(row => row.WorkOrderCount).Should().Equal(1, 0, 0, 1, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetVendorInsightsAsync_IsCompanyWideAndUsesVendorMetrics()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
ctx.VendorCompanies.Add(new VendorCompany { Id = 10, Name = "Acme Services", IsDeleted = false });
|
||||||
|
ctx.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
Id = 20,
|
||||||
|
CompanyId = 10,
|
||||||
|
IsActive = true,
|
||||||
|
CompanyName = "Acme Services"
|
||||||
|
});
|
||||||
|
ctx.Dispatches.Add(new Dispatch { Id = 30, VendorId = 20 });
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
PrimaryDispatchId = 30,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||||
|
CompletedDate = DateTime.UtcNow.Date.AddDays(-1),
|
||||||
|
RescheduleCount = 1
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
PrimaryDispatchId = 30,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-2),
|
||||||
|
CompletedDate = DateTime.UtcNow.Date.AddDays(-1)
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetVendorInsightsAsync(
|
||||||
|
AccountUser(1), CancellationToken.None);
|
||||||
|
|
||||||
|
result.TotalVendors.Should().Be(1);
|
||||||
|
result.Items.Should().ContainSingle();
|
||||||
|
result.Items[0].TotalJobs.Should().Be(2);
|
||||||
|
result.Items[0].CompletionRate.Should().Be(100);
|
||||||
|
result.Items[0].RescheduleRate.Should().Be(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_DailyBucketsClassifyByCalendarScheduledDate()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
// ScheduledDate is a stored calendar value (board writes persist `.Date`), so a work
|
||||||
|
// order buckets on its own calendar day regardless of the time-of-day component. The
|
||||||
|
// UTC hours below were previously shifted a full day by an America/New_York conversion
|
||||||
|
// (03:00 UTC -> prior evening), so they double as a regression guard: each row must now
|
||||||
|
// stay on the calendar date it was scheduled for.
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc),
|
||||||
|
CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = new DateOnly(2026, 1, 14),
|
||||||
|
DateTo = new DateOnly(2026, 1, 16)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Granularity.Should().Be("day");
|
||||||
|
trend.Buckets.Select(bucket => bucket.Date).Should().Equal(
|
||||||
|
new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16));
|
||||||
|
trend.Buckets.Select(bucket => bucket.Label).Should().Equal(
|
||||||
|
"2026-01-14", "2026-01-15", "2026-01-16");
|
||||||
|
|
||||||
|
// 01-14: only the canceled row scheduled that calendar day.
|
||||||
|
trend.Buckets[0].Total.Should().Be(1);
|
||||||
|
trend.Buckets[0].Open.Should().Be(0);
|
||||||
|
trend.Buckets[0].Canceled.Should().Be(1);
|
||||||
|
trend.Buckets[0].Completed.Should().Be(0);
|
||||||
|
trend.Buckets[0].Overdue.Should().Be(0);
|
||||||
|
|
||||||
|
// 01-15: the 03:00 UTC scheduled row (no longer shifted to 01-14) plus the completed row.
|
||||||
|
trend.Buckets[1].Total.Should().Be(2);
|
||||||
|
trend.Buckets[1].Open.Should().Be(1);
|
||||||
|
trend.Buckets[1].Completed.Should().Be(1);
|
||||||
|
trend.Buckets[1].Canceled.Should().Be(0);
|
||||||
|
trend.Buckets[1].Overdue.Should().Be(1);
|
||||||
|
|
||||||
|
// 01-16: the 03:00 UTC scheduled row that stays on its own day.
|
||||||
|
trend.Buckets[2].Total.Should().Be(1);
|
||||||
|
trend.Buckets[2].Open.Should().Be(1);
|
||||||
|
trend.Buckets[2].Overdue.Should().Be(1);
|
||||||
|
|
||||||
|
trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_MidnightScheduledDateStaysInTodayBucketAndIsNotOverdue()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
// A board-scheduled work order for today is stored as midnight (`.Date`). It must land
|
||||||
|
// in the Today bucket and count as open, not roll back to yesterday and read as overdue.
|
||||||
|
ctx.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = today.ToDateTime(TimeOnly.MinValue)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = today.AddDays(-1),
|
||||||
|
DateTo = today.AddDays(1)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
var todayBucket = trend.Buckets.Single(bucket => bucket.Date == today);
|
||||||
|
todayBucket.Total.Should().Be(1);
|
||||||
|
todayBucket.Open.Should().Be(1);
|
||||||
|
todayBucket.Overdue.Should().Be(0);
|
||||||
|
|
||||||
|
var yesterdayBucket = trend.Buckets.Single(bucket => bucket.Date == today.AddDays(-1));
|
||||||
|
yesterdayBucket.Total.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
Range = "3m",
|
||||||
|
DateFrom = new DateOnly(2026, 6, 17),
|
||||||
|
DateTo = new DateOnly(2026, 9, 16)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Granularity.Should().Be("week");
|
||||||
|
trend.Buckets.Should().HaveCount(14);
|
||||||
|
trend.Buckets.Select(bucket => bucket.Date.DayOfWeek)
|
||||||
|
.Should().OnlyContain(day => day == DayOfWeek.Monday);
|
||||||
|
trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15));
|
||||||
|
trend.Buckets[0].Total.Should().Be(2);
|
||||||
|
trend.Buckets[0].Open.Should().Be(2);
|
||||||
|
trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14));
|
||||||
|
trend.Buckets[^1].Total.Should().Be(1);
|
||||||
|
trend.Buckets[^1].Completed.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed,
|
||||||
|
ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc),
|
||||||
|
CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
// 2027-01-01 by calendar: outside the 2026 window. Previously an
|
||||||
|
// America/New_York conversion pulled it back to 2026-12-31 and into December.
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Granularity.Should().Be("month");
|
||||||
|
trend.Buckets.Should().HaveCount(12);
|
||||||
|
trend.Buckets[0].Label.Should().Be("2026-01-01");
|
||||||
|
trend.Buckets[0].Total.Should().Be(1);
|
||||||
|
trend.Buckets[0].Open.Should().Be(1);
|
||||||
|
trend.Buckets[0].Overdue.Should().Be(1);
|
||||||
|
trend.Buckets[2].Label.Should().Be("2026-03-01");
|
||||||
|
trend.Buckets[2].Total.Should().Be(1);
|
||||||
|
trend.Buckets[2].Completed.Should().Be(1);
|
||||||
|
// December stays empty: the 2027-01-01 row is no longer pulled inside the window.
|
||||||
|
trend.Buckets[11].Label.Should().Be("2026-12-01");
|
||||||
|
trend.Buckets[11].Total.Should().Be(0);
|
||||||
|
trend.Buckets[11].Open.Should().Be(0);
|
||||||
|
trend.Today.Should().Be(DashboardBusinessTime.Today());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_MarksTodayBucketAsCurrent()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var today = DashboardBusinessTime.Today();
|
||||||
|
ctx.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0))
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = today.AddDays(-2),
|
||||||
|
DateTo = today.AddDays(2)
|
||||||
|
};
|
||||||
|
var trend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
trend.Today.Should().Be(today);
|
||||||
|
trend.Buckets.Should().HaveCount(5);
|
||||||
|
trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1);
|
||||||
|
trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today);
|
||||||
|
trend.Buckets[0].Total.Should().Be(1);
|
||||||
|
trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 1,
|
||||||
|
AssignTo = "dispatcher-2",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
AccountId = 2,
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var query = new DashboardTrendQueryDTO
|
||||||
|
{
|
||||||
|
DateFrom = new DateOnly(2026, 2, 2),
|
||||||
|
DateTo = new DateOnly(2026, 2, 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
var dispatcherTrend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None);
|
||||||
|
|
||||||
|
dispatcherTrend.Buckets.Should().HaveCount(2);
|
||||||
|
dispatcherTrend.Buckets[0].Total.Should().Be(1);
|
||||||
|
dispatcherTrend.Buckets[1].Total.Should().Be(0);
|
||||||
|
|
||||||
|
var adminTrend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(1), query, CancellationToken.None);
|
||||||
|
|
||||||
|
adminTrend.Buckets[0].Total.Should().Be(2);
|
||||||
|
|
||||||
|
var otherAccountTrend = await NewService(ctx).GetTrendAsync(
|
||||||
|
AccountUser(2), query, CancellationToken.None);
|
||||||
|
|
||||||
|
otherAccountTrend.Buckets[0].Total.Should().Be(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
106
Api.SeaHavenIndustries.Tests/WorkOrderGetByIdBindingTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SH-374: GET api/WorkOrder/GetWorkorderById?id= must read the id from the query string, while
|
||||||
|
/// GET api/WorkOrder/{id} keeps reading it from the route. Both must return the same work order,
|
||||||
|
/// and an unknown id keeps the existing "ID not found!" response.
|
||||||
|
/// </summary>
|
||||||
|
public class WorkOrderGetByIdBindingTests
|
||||||
|
{
|
||||||
|
private const int ExistingId = 374;
|
||||||
|
private const int UnknownId = 999_999;
|
||||||
|
|
||||||
|
private static ControllerActionDescriptor ActionForTemplate(string relativeTemplate)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddMvcCore().AddApplicationPart(typeof(WorkOrderController).Assembly);
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
return provider.GetRequiredService<IActionDescriptorCollectionProvider>().ActionDescriptors.Items
|
||||||
|
.OfType<ControllerActionDescriptor>()
|
||||||
|
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderController))
|
||||||
|
.Where(cad => cad.ActionConstraints!
|
||||||
|
.OfType<Microsoft.AspNetCore.Mvc.ActionConstraints.HttpMethodActionConstraint>()
|
||||||
|
.Any(c => c.HttpMethods.Contains("GET")))
|
||||||
|
.Single(cad => string.Equals(
|
||||||
|
cad.AttributeRouteInfo?.Template?.Trim('/'),
|
||||||
|
$"api/WorkOrder/{relativeTemplate}",
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("GetWorkorderById", "Query")]
|
||||||
|
[InlineData("{id:int}", "Path")]
|
||||||
|
public void Id_binds_from_the_source_its_route_provides(string relativeTemplate, string expectedSource)
|
||||||
|
{
|
||||||
|
var action = ActionForTemplate(relativeTemplate);
|
||||||
|
|
||||||
|
var id = action.Parameters.Single(p => p.Name == "id");
|
||||||
|
id.BindingInfo.Should().NotBeNull();
|
||||||
|
id.BindingInfo!.BindingSource!.Id.Should().Be(expectedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (WorkOrderController Controller, WorkOrderDetailReadModel Existing) NewController()
|
||||||
|
{
|
||||||
|
var existing = new WorkOrderDetailReadModel { Id = ExistingId, Title = "Leaking roof" };
|
||||||
|
var service = new Mock<IWorkOrderService>();
|
||||||
|
service.Setup(s => s.GetWorkOrderDetailAsync(ExistingId, It.IsAny<int?>())).ReturnsAsync(existing);
|
||||||
|
service.Setup(s => s.GetWorkOrderDetailAsync(UnknownId, It.IsAny<int?>()))
|
||||||
|
.ReturnsAsync((WorkOrderDetailReadModel?)null);
|
||||||
|
|
||||||
|
var controller = new WorkOrderController(
|
||||||
|
service.Object,
|
||||||
|
Mock.Of<IWorkOrderAccountResolver>(),
|
||||||
|
Mock.Of<ILogger<WorkOrderController>>())
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||||
|
};
|
||||||
|
return (controller, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Existing_id_returns_the_same_work_order_through_both_routes()
|
||||||
|
{
|
||||||
|
var (controller, existing) = NewController();
|
||||||
|
|
||||||
|
var byQuery = await controller.GetWorkorderById(ExistingId);
|
||||||
|
var byRoute = await controller.GetWorkorderByRouteId(ExistingId);
|
||||||
|
|
||||||
|
byQuery.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||||
|
byRoute.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeSameAs(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_id_keeps_the_not_found_response_through_both_routes()
|
||||||
|
{
|
||||||
|
var (controller, _) = NewController();
|
||||||
|
|
||||||
|
foreach (var result in new[]
|
||||||
|
{
|
||||||
|
await controller.GetWorkorderById(UnknownId),
|
||||||
|
await controller.GetWorkorderByRouteId(UnknownId),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var body = result.Should().BeOfType<BadRequestObjectResult>().Which.Value
|
||||||
|
.Should().BeOfType<Response>().Subject;
|
||||||
|
body.Status.Should().Be("Error");
|
||||||
|
body.Message.Should().Be("ID not found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,8 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
/// combination is registered more than once by different actions.
|
/// combination is registered more than once by different actions.
|
||||||
///
|
///
|
||||||
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
|
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
|
||||||
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder,
|
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder)
|
||||||
/// GetWorkorderById) and the two shared controller-level base routes.
|
/// and the two shared controller-level base routes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WorkOrderRouteContractTests
|
public class WorkOrderRouteContractTests
|
||||||
{
|
{
|
||||||
|
|
@ -39,7 +39,7 @@ public class WorkOrderRouteContractTests
|
||||||
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
|
||||||
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
|
||||||
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
|
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
|
||||||
/// come from 50 actions (Editworkorder and GetWorkorderById each bind two routes).
|
/// come from 51 actions (Editworkorder binds two routes).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
@ -17,17 +18,155 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("Stats")]
|
[HttpGet("Stats")]
|
||||||
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
|
public async Task<IActionResult> GetStats(
|
||||||
|
[FromQuery] DashboardStatsQueryDTO query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var stats = await _dashboardService.GetStatsAsync(cancellationToken);
|
var stats = await _dashboardService.GetStatsAsync(User, query, cancellationToken);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
total = stats.Total,
|
total = stats.Total,
|
||||||
open = stats.Open,
|
open = stats.Open,
|
||||||
notDispatched = stats.NotDispatched,
|
notDispatched = stats.NotDispatched,
|
||||||
completed = stats.Completed
|
completed = stats.Completed,
|
||||||
|
breakdown = stats.Breakdown,
|
||||||
|
dueCount = stats.DueCount,
|
||||||
|
completedDueCount = stats.CompletedDueCount,
|
||||||
|
completionRate = stats.CompletionRate,
|
||||||
|
averageResolutionDays = stats.AverageResolutionDays
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("Workload")]
|
||||||
|
public async Task<IActionResult> GetWorkload(
|
||||||
|
[FromQuery] DashboardStatsQueryDTO query,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await _dashboardService.GetWorkloadAsync(
|
||||||
|
User, query, page, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||||
|
when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||||
|
{
|
||||||
|
code = ex.Code,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Performance")]
|
||||||
|
public async Task<IActionResult> GetPerformance(
|
||||||
|
[FromQuery] DashboardStatsQueryDTO query,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await _dashboardService.GetPerformanceAsync(
|
||||||
|
User, query, page, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||||
|
when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||||
|
{
|
||||||
|
code = ex.Code,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Regions")]
|
||||||
|
public async Task<IActionResult> GetRegions(
|
||||||
|
[FromQuery] DashboardStatsQueryDTO query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await _dashboardService.GetRegionsAsync(User, query, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||||
|
when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||||
|
{
|
||||||
|
code = ex.Code,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Trend")]
|
||||||
|
public async Task<IActionResult> GetTrend(
|
||||||
|
[FromQuery] DashboardTrendQueryDTO query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
granularity = trend.Granularity,
|
||||||
|
today = trend.Today,
|
||||||
|
buckets = trend.Buckets.Select(bucket => new
|
||||||
|
{
|
||||||
|
date = bucket.Date,
|
||||||
|
label = bucket.Label,
|
||||||
|
total = bucket.Total,
|
||||||
|
open = bucket.Open,
|
||||||
|
completed = bucket.Completed,
|
||||||
|
canceled = bucket.Canceled,
|
||||||
|
overdue = bucket.Overdue,
|
||||||
|
isCurrent = bucket.IsCurrent
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||||
|
when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||||
|
{
|
||||||
|
code = ex.Code,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("VendorInsights")]
|
||||||
|
public async Task<IActionResult> GetVendorInsights(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Ok(await _dashboardService.GetVendorInsightsAsync(User, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex)
|
||||||
|
when (ex.Code == "Forbidden")
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||||
|
{
|
||||||
|
code = ex.Code,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,9 +208,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
// SH-374: two actions, one per route, so each binds id from the source its route provides.
|
||||||
|
// A single action carrying both routes inferred id as [FromRoute] and the query route got 0.
|
||||||
[HttpGet("GetWorkorderById")]
|
[HttpGet("GetWorkorderById")]
|
||||||
public async Task<IActionResult> GetWorkorderById(int id)
|
public Task<IActionResult> GetWorkorderById([FromQuery] int id) => GetWorkorderDetail(id);
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public Task<IActionResult> GetWorkorderByRouteId([FromRoute] int id) => GetWorkorderDetail(id);
|
||||||
|
|
||||||
|
private async Task<IActionResult> GetWorkorderDetail(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -13,9 +15,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken)
|
public async Task<DashboardCounts> GetWorkOrderCountsAsync(
|
||||||
|
int? accountId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var query = _context.workOrders.Where(w => w.istemplate != true);
|
var query = _context.workOrders.Where(w => w.istemplate != true);
|
||||||
|
if (accountId is int scopedAccountId)
|
||||||
|
{
|
||||||
|
query = query.Where(w => w.AccountId == scopedAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
var total = await query.CountAsync(cancellationToken);
|
var total = await query.CountAsync(cancellationToken);
|
||||||
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken);
|
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken);
|
||||||
|
|
@ -30,5 +38,130 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
Completed = completed
|
Completed = completed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
|
if (accountId is int scopedAccountId)
|
||||||
|
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, scopedAccountId);
|
||||||
|
|
||||||
|
if (dispatcherId != null)
|
||||||
|
query = query.Where(w => w.AssignTo == dispatcherId);
|
||||||
|
|
||||||
|
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
||||||
|
{
|
||||||
|
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
||||||
|
var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
|
||||||
|
query = query.Where(w =>
|
||||||
|
w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.Select(w => new DashboardWorkOrder(
|
||||||
|
w.Id,
|
||||||
|
w.AssignTo,
|
||||||
|
w.WorkOrderType,
|
||||||
|
w.LifecycleStatus,
|
||||||
|
w.LegacyStatus ?? w.Status,
|
||||||
|
w.ScheduledDate,
|
||||||
|
w.OriginalDate,
|
||||||
|
w.CompletedDate,
|
||||||
|
w.Locations == null ? null : w.Locations.State,
|
||||||
|
w.RescheduleCount,
|
||||||
|
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|
||||||
|
? null
|
||||||
|
: w.PrimaryDispatch.Vendor.CompanyId,
|
||||||
|
w.PrimaryDispatch == null || w.PrimaryDispatch.Vendor == null
|
||||||
|
|| w.PrimaryDispatch.Vendor.Company == null
|
||||||
|
? null
|
||||||
|
: w.PrimaryDispatch.Vendor.Company.Name,
|
||||||
|
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
&& w.PrimaryDispatch.Vendor.IsActive,
|
||||||
|
w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
&& w.PrimaryDispatch.Vendor.Company != null
|
||||||
|
&& w.PrimaryDispatch.Vendor.Company.IsDeleted == true))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dispatcherUsers = from user in _context.Users.AsNoTracking()
|
||||||
|
join userRole in _context.UserRoles
|
||||||
|
on user.Id equals userRole.UserId
|
||||||
|
join role in _context.Roles
|
||||||
|
on userRole.RoleId equals role.Id
|
||||||
|
where user.IsDeleted != true
|
||||||
|
&& (role.Name == "Dispatcher" || role.Name == "DISPATCHER")
|
||||||
|
&& (dispatcherId == null || user.Id == dispatcherId)
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
user.Id,
|
||||||
|
Name = ((user.FirstName ?? "") + " " + (user.LastName ?? "")).Trim(),
|
||||||
|
user.Color
|
||||||
|
};
|
||||||
|
|
||||||
|
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
|
if (accountId is int scopedAccountId)
|
||||||
|
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, scopedAccountId);
|
||||||
|
|
||||||
|
if (dateFrom is DateOnly from && dateTo is DateOnly to)
|
||||||
|
{
|
||||||
|
var fromDate = from.ToDateTime(TimeOnly.MinValue);
|
||||||
|
var toDateExclusive = to.AddDays(1).ToDateTime(TimeOnly.MinValue);
|
||||||
|
workOrders = workOrders.Where(w =>
|
||||||
|
w.ScheduledDate >= fromDate && w.ScheduledDate < toDateExclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
var counts = await workOrders
|
||||||
|
.Where(w => w.AssignTo != null)
|
||||||
|
.GroupBy(w => w.AssignTo!)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
DispatcherId = group.Key,
|
||||||
|
TotalCount = group.Count(),
|
||||||
|
OpenCount = group.Count(w =>
|
||||||
|
w.LifecycleStatus != LifecycleStatus.Completed
|
||||||
|
&& w.LifecycleStatus != LifecycleStatus.Canceled
|
||||||
|
&& (w.LifecycleStatus != null
|
||||||
|
|| (w.LegacyStatus ?? w.Status) != "Done"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Completed"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Complete"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Closed"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Canceled"
|
||||||
|
&& (w.LegacyStatus ?? w.Status) != "Cancelled"))
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var dispatcherList = await dispatcherUsers
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return dispatcherList
|
||||||
|
.GroupJoin(
|
||||||
|
counts,
|
||||||
|
user => user.Id,
|
||||||
|
count => count.DispatcherId,
|
||||||
|
(user, matchingCounts) =>
|
||||||
|
{
|
||||||
|
var count = matchingCounts.SingleOrDefault();
|
||||||
|
return new DashboardDispatcherWorkload(
|
||||||
|
user.Id,
|
||||||
|
user.Name,
|
||||||
|
user.Color,
|
||||||
|
count?.TotalCount ?? 0,
|
||||||
|
count?.OpenCount ?? 0);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,22 +63,39 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD
|
||||||
&& permission.PermissionKey == permissionKey,
|
&& permission.PermissionKey == permissionKey,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (existing is null)
|
if (existing is not null)
|
||||||
{
|
|
||||||
await _context.UserPermissionOverrides.AddAsync(
|
|
||||||
new UserPermissionOverride
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
PermissionKey = permissionKey,
|
|
||||||
State = state
|
|
||||||
},
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
existing.State = state;
|
existing.State = state;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
var addition = new UserPermissionOverride
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
PermissionKey = permissionKey,
|
||||||
|
State = state
|
||||||
|
};
|
||||||
|
await _context.UserPermissionOverrides.AddAsync(addition, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// A concurrent PUT inserted the same (UserId, PermissionKey) between our
|
||||||
|
// existence check and this save, violating PK_UserPermissionOverrides.
|
||||||
|
// Converge on the caller's intent by updating the persisted row.
|
||||||
|
_context.Entry(addition).State = EntityState.Detached;
|
||||||
|
|
||||||
|
var winner = await _context.UserPermissionOverrides
|
||||||
|
.SingleAsync(
|
||||||
|
permission => permission.UserId == userId
|
||||||
|
&& permission.PermissionKey == permissionKey,
|
||||||
|
cancellationToken);
|
||||||
|
winner.State = state;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,10 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.Where(role => role.UserId == id)
|
.Where(role => role.UserId == id)
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _context.UserPermissionOverrides
|
||||||
|
.Where(permissionOverride => permissionOverride.UserId == id)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
await _context.Users
|
await _context.Users
|
||||||
.Where(existingUser => existingUser.Id == id)
|
.Where(existingUser => existingUser.Id == id)
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,51 @@
|
||||||
namespace SeaHaven.DataServices.Interfaces
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
public interface IDashboardDataService
|
public interface IDashboardDataService
|
||||||
{
|
{
|
||||||
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken);
|
Task<DashboardCounts> GetWorkOrderCountsAsync(
|
||||||
|
int? accountId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<DashboardWorkOrder>> GetDashboardWorkOrdersAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<DashboardDispatcherWorkload>> GetDispatcherWorkloadAsync(
|
||||||
|
int? accountId,
|
||||||
|
string? dispatcherId,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DashboardWorkOrder(
|
||||||
|
int Id,
|
||||||
|
string? AssignTo,
|
||||||
|
WorkOrderType? WorkOrderType,
|
||||||
|
LifecycleStatus? LifecycleStatus,
|
||||||
|
string? LegacyStatus,
|
||||||
|
DateTime? ScheduledDate,
|
||||||
|
DateOnly? OriginalDate,
|
||||||
|
DateTime? CompletedDate,
|
||||||
|
string? LocationState = null,
|
||||||
|
int RescheduleCount = 0,
|
||||||
|
int? VendorCompanyId = null,
|
||||||
|
string? VendorCompanyName = null,
|
||||||
|
bool VendorIsActive = false,
|
||||||
|
bool VendorCompanyIsDeleted = false);
|
||||||
|
|
||||||
|
public sealed record DashboardDispatcherWorkload(
|
||||||
|
string DispatcherId,
|
||||||
|
string DispatcherName,
|
||||||
|
string? Color,
|
||||||
|
int TotalCount,
|
||||||
|
int OpenCount);
|
||||||
|
|
||||||
public class DashboardCounts
|
public class DashboardCounts
|
||||||
{
|
{
|
||||||
public int Total { get; set; }
|
public int Total { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,118 @@
|
||||||
namespace SeaHaven.Services.DTOs
|
namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
|
public class DashboardStatsQueryDTO
|
||||||
|
{
|
||||||
|
public DateOnly? DateFrom { get; set; }
|
||||||
|
public DateOnly? DateTo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO
|
||||||
|
{
|
||||||
|
public string? Range { get; set; }
|
||||||
|
public int? Year { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardTrendResponseDTO
|
||||||
|
{
|
||||||
|
public string Granularity { get; init; } = "day";
|
||||||
|
public DateOnly Today { get; init; }
|
||||||
|
public IReadOnlyList<DashboardTrendBucketDTO> Buckets { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardTrendBucketDTO
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; init; }
|
||||||
|
public string Label { get; init; } = string.Empty;
|
||||||
|
public int Total { get; init; }
|
||||||
|
public int Open { get; init; }
|
||||||
|
public int Completed { get; init; }
|
||||||
|
public int Canceled { get; init; }
|
||||||
|
public int Overdue { get; init; }
|
||||||
|
public bool IsCurrent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardWorkloadResponseDTO
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DashboardWorkloadRowDTO> Items { get; init; } = [];
|
||||||
|
public int Page { get; init; }
|
||||||
|
public int PageSize { get; init; }
|
||||||
|
public int TotalDispatchers { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardWorkloadRowDTO
|
||||||
|
{
|
||||||
|
public string DispatcherId { get; init; } = string.Empty;
|
||||||
|
public string DispatcherName { get; init; } = string.Empty;
|
||||||
|
public string? Color { get; init; }
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
public int OpenCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardPerformanceResponseDTO
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DashboardPerformanceRowDTO> Items { get; init; } = [];
|
||||||
|
public int Page { get; init; }
|
||||||
|
public int PageSize { get; init; }
|
||||||
|
public int TotalDispatchers { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardPerformanceRowDTO
|
||||||
|
{
|
||||||
|
public string DispatcherId { get; init; } = string.Empty;
|
||||||
|
public string DispatcherName { get; init; } = string.Empty;
|
||||||
|
public string? Color { get; init; }
|
||||||
|
public decimal CompletionRate { get; init; }
|
||||||
|
public decimal? AverageResolutionDays { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardRegionResponseDTO
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DashboardRegionRowDTO> Items { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardRegionRowDTO
|
||||||
|
{
|
||||||
|
public string Region { get; init; } = string.Empty;
|
||||||
|
public int WorkOrderCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardVendorInsightsResponseDTO
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DashboardVendorInsightsRowDTO> Items { get; init; } = [];
|
||||||
|
public int Page { get; init; }
|
||||||
|
public int PageSize { get; init; }
|
||||||
|
public int TotalVendors { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardVendorInsightsRowDTO
|
||||||
|
{
|
||||||
|
public int VendorCompanyId { get; init; }
|
||||||
|
public string VendorCompanyName { get; init; } = string.Empty;
|
||||||
|
public decimal CompletionRate { get; init; }
|
||||||
|
public decimal RescheduleRate { get; init; }
|
||||||
|
public decimal? AverageResolutionDays { get; init; }
|
||||||
|
public int TotalJobs { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public class DashboardStatsDTO
|
public class DashboardStatsDTO
|
||||||
{
|
{
|
||||||
public int Total { get; set; }
|
public int Total { get; set; }
|
||||||
public int Open { get; set; }
|
public int Open { get; set; }
|
||||||
public int NotDispatched { get; set; }
|
public int NotDispatched { get; set; }
|
||||||
public int Completed { get; set; }
|
public int Completed { get; set; }
|
||||||
|
public DashboardBreakdownDTO Breakdown { get; set; } = new();
|
||||||
|
public int DueCount { get; set; }
|
||||||
|
public int CompletedDueCount { get; set; }
|
||||||
|
public decimal CompletionRate { get; set; }
|
||||||
|
public decimal? AverageResolutionDays { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardBreakdownDTO
|
||||||
|
{
|
||||||
|
public int PM { get; set; }
|
||||||
|
public int Emergency { get; set; }
|
||||||
|
public int Reactive { get; set; }
|
||||||
|
public int Overdue { get; set; }
|
||||||
|
public int Other { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
SeaHaven.Services/Helpers/DashboardBusinessTime.cs
Normal file
13
SeaHaven.Services/Helpers/DashboardBusinessTime.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
public static class DashboardBusinessTime
|
||||||
|
{
|
||||||
|
public const string TimeZoneId = "America/New_York";
|
||||||
|
|
||||||
|
public static DateOnly Today()
|
||||||
|
{
|
||||||
|
var timezone = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId);
|
||||||
|
var businessNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timezone);
|
||||||
|
return DateOnly.FromDateTime(businessNow);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
SeaHaven.Services/Helpers/DashboardMetrics.cs
Normal file
113
SeaHaven.Services/Helpers/DashboardMetrics.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
public static class DashboardMetrics
|
||||||
|
{
|
||||||
|
public static DashboardMetricResult Calculate(
|
||||||
|
IReadOnlyList<DashboardWorkOrder> workOrders,
|
||||||
|
DateOnly today)
|
||||||
|
{
|
||||||
|
var breakdown = new DashboardBreakdownDTO();
|
||||||
|
var dueCount = 0;
|
||||||
|
var completedDueCount = 0;
|
||||||
|
var resolutionDays = new List<decimal>();
|
||||||
|
|
||||||
|
foreach (var workOrder in workOrders)
|
||||||
|
{
|
||||||
|
var status = workOrder.LifecycleStatus
|
||||||
|
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
|
||||||
|
var completed = status == LifecycleStatus.Completed;
|
||||||
|
var canceled = status == LifecycleStatus.Canceled;
|
||||||
|
var pastDue = IsPastDue(workOrder, status, today);
|
||||||
|
|
||||||
|
AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue);
|
||||||
|
|
||||||
|
var due = completed || (workOrder.ScheduledDate is DateTime scheduled
|
||||||
|
&& DateOnly.FromDateTime(scheduled.Date) <= today);
|
||||||
|
if (due)
|
||||||
|
{
|
||||||
|
dueCount++;
|
||||||
|
if (completed)
|
||||||
|
completedDueCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed && workOrder.CompletedDate is DateTime completedDate)
|
||||||
|
{
|
||||||
|
var anchor = workOrder.OriginalDate
|
||||||
|
?? (workOrder.ScheduledDate is DateTime scheduledDate
|
||||||
|
? DateOnly.FromDateTime(scheduledDate.Date)
|
||||||
|
: null);
|
||||||
|
if (anchor is DateOnly originalDate)
|
||||||
|
resolutionDays.Add((decimal)(completedDate.Date - originalDate.ToDateTime(TimeOnly.MinValue)).TotalDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DashboardMetricResult(
|
||||||
|
workOrders.Count,
|
||||||
|
workOrders.Count(workOrder =>
|
||||||
|
{
|
||||||
|
var status = workOrder.LifecycleStatus
|
||||||
|
?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus);
|
||||||
|
return status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled;
|
||||||
|
}),
|
||||||
|
workOrders.Count(workOrder => string.IsNullOrWhiteSpace(workOrder.AssignTo)),
|
||||||
|
workOrders.Count(workOrder =>
|
||||||
|
(workOrder.LifecycleStatus ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus))
|
||||||
|
== LifecycleStatus.Completed),
|
||||||
|
breakdown,
|
||||||
|
dueCount,
|
||||||
|
completedDueCount,
|
||||||
|
dueCount == 0 ? 0 : Math.Round((decimal)completedDueCount / dueCount * 100, 2),
|
||||||
|
resolutionDays.Count == 0 ? null : Math.Round(resolutionDays.Average(), 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPastDue(
|
||||||
|
DashboardWorkOrder workOrder,
|
||||||
|
LifecycleStatus? status,
|
||||||
|
DateOnly today)
|
||||||
|
=> workOrder.ScheduledDate is DateTime scheduled
|
||||||
|
&& DateOnly.FromDateTime(scheduled.Date) < today
|
||||||
|
&& status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled;
|
||||||
|
|
||||||
|
private static void AddBreakdownCount(
|
||||||
|
DashboardBreakdownDTO breakdown,
|
||||||
|
WorkOrderType? type,
|
||||||
|
bool pastDue)
|
||||||
|
{
|
||||||
|
if (pastDue)
|
||||||
|
{
|
||||||
|
breakdown.Overdue++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case WorkOrderType.PM:
|
||||||
|
breakdown.PM++;
|
||||||
|
break;
|
||||||
|
case WorkOrderType.Emergency:
|
||||||
|
breakdown.Emergency++;
|
||||||
|
break;
|
||||||
|
case WorkOrderType.Reactive:
|
||||||
|
breakdown.Reactive++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
breakdown.Other++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DashboardMetricResult(
|
||||||
|
int Total,
|
||||||
|
int Open,
|
||||||
|
int NotDispatched,
|
||||||
|
int Completed,
|
||||||
|
DashboardBreakdownDTO Breakdown,
|
||||||
|
int DueCount,
|
||||||
|
int CompletedDueCount,
|
||||||
|
decimal CompletionRate,
|
||||||
|
decimal? AverageResolutionDays);
|
||||||
84
SeaHaven.Services/Helpers/DashboardRegions.cs
Normal file
84
SeaHaven.Services/Helpers/DashboardRegions.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
public static class DashboardRegions
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> Names =
|
||||||
|
["East", "Central", "West", "California", "Unmapped/Other"];
|
||||||
|
|
||||||
|
public static string Resolve(string? state)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(state))
|
||||||
|
return "Unmapped/Other";
|
||||||
|
|
||||||
|
return StateToRegion.TryGetValue(state.Trim().ToUpperInvariant(), out var region)
|
||||||
|
? region
|
||||||
|
: "Unmapped/Other";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, int> Count(
|
||||||
|
IReadOnlyList<DashboardWorkOrder> workOrders)
|
||||||
|
{
|
||||||
|
var counts = Names.ToDictionary(name => name, _ => 0, StringComparer.Ordinal);
|
||||||
|
foreach (var workOrder in workOrders)
|
||||||
|
counts[Resolve(workOrder.LocationState)]++;
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> StateToRegion =
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["CT"] = "East",
|
||||||
|
["DE"] = "East",
|
||||||
|
["GA"] = "East",
|
||||||
|
["ME"] = "East",
|
||||||
|
["MD"] = "East",
|
||||||
|
["MA"] = "East",
|
||||||
|
["NH"] = "East",
|
||||||
|
["NJ"] = "East",
|
||||||
|
["NY"] = "East",
|
||||||
|
["NC"] = "East",
|
||||||
|
["PA"] = "East",
|
||||||
|
["RI"] = "East",
|
||||||
|
["SC"] = "East",
|
||||||
|
["VT"] = "East",
|
||||||
|
["VA"] = "East",
|
||||||
|
["WV"] = "East",
|
||||||
|
["AL"] = "Central",
|
||||||
|
["AR"] = "Central",
|
||||||
|
["FL"] = "Central",
|
||||||
|
["IL"] = "Central",
|
||||||
|
["IN"] = "Central",
|
||||||
|
["IA"] = "Central",
|
||||||
|
["KS"] = "Central",
|
||||||
|
["KY"] = "Central",
|
||||||
|
["LA"] = "Central",
|
||||||
|
["MI"] = "Central",
|
||||||
|
["MN"] = "Central",
|
||||||
|
["MS"] = "Central",
|
||||||
|
["MO"] = "Central",
|
||||||
|
["NE"] = "Central",
|
||||||
|
["ND"] = "Central",
|
||||||
|
["OH"] = "Central",
|
||||||
|
["OK"] = "Central",
|
||||||
|
["SD"] = "Central",
|
||||||
|
["TN"] = "Central",
|
||||||
|
["WI"] = "Central",
|
||||||
|
["AK"] = "West",
|
||||||
|
["AZ"] = "West",
|
||||||
|
["CO"] = "West",
|
||||||
|
["HI"] = "West",
|
||||||
|
["ID"] = "West",
|
||||||
|
["MT"] = "West",
|
||||||
|
["NV"] = "West",
|
||||||
|
["NM"] = "West",
|
||||||
|
["OR"] = "West",
|
||||||
|
["TX"] = "West",
|
||||||
|
["UT"] = "West",
|
||||||
|
["WA"] = "West",
|
||||||
|
["WY"] = "West",
|
||||||
|
["CA"] = "California"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
|
|
@ -7,22 +12,388 @@ namespace SeaHaven.Services.Implementation
|
||||||
public class DashboardService : IDashboardService
|
public class DashboardService : IDashboardService
|
||||||
{
|
{
|
||||||
private readonly IDashboardDataService _dataService;
|
private readonly IDashboardDataService _dataService;
|
||||||
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
||||||
|
|
||||||
public DashboardService(IDashboardDataService dataService)
|
public DashboardService(
|
||||||
|
IDashboardDataService dataService,
|
||||||
|
IWorkOrderAccountResolver accountResolver)
|
||||||
{
|
{
|
||||||
_dataService = dataService;
|
_dataService = dataService;
|
||||||
|
_accountResolver = accountResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken)
|
public async Task<DashboardStatsDTO> GetStatsAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardStatsQueryDTO query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var counts = await _dataService.GetWorkOrderCountsAsync(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
|
return new DashboardStatsDTO
|
||||||
{
|
{
|
||||||
Total = counts.Total,
|
Total = useMetrics ? metrics.Total : counts.Total,
|
||||||
Open = counts.Open,
|
Open = useMetrics ? metrics.Open : counts.Open,
|
||||||
NotDispatched = counts.NotDispatched,
|
NotDispatched = useMetrics ? metrics.NotDispatched : counts.NotDispatched,
|
||||||
Completed = counts.Completed
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,39 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Interfaces
|
namespace SeaHaven.Services.Interfaces
|
||||||
{
|
{
|
||||||
public interface IDashboardService
|
public interface IDashboardService
|
||||||
{
|
{
|
||||||
Task<DashboardStatsDTO> GetStatsAsync(CancellationToken cancellationToken);
|
Task<DashboardStatsDTO> GetStatsAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardStatsQueryDTO query,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardWorkloadResponseDTO> GetWorkloadAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardStatsQueryDTO query,
|
||||||
|
int page,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardPerformanceResponseDTO> GetPerformanceAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardStatsQueryDTO query,
|
||||||
|
int page,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardRegionResponseDTO> GetRegionsAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardStatsQueryDTO query,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardTrendResponseDTO> GetTrendAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
DashboardTrendQueryDTO query,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue