Merge pull request #63 from Sea-Haven-Industries/feature/sh-188-board-pending-uplift
Some checks failed
Validate and deploy dev / Validate deployable source bundle (push) Has been cancelled
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Has been cancelled

feat(work-orders): expose pendingUpliftCount on board rows (SH-188)
This commit is contained in:
Alexandre Brandizzi 2026-08-12 12:10:17 -03:00 • committed by GitHub
commit 5f614fe893
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 279 additions and 4 deletions

View file

@ -7,6 +7,7 @@ namespace SeaHaven.DataServices.Helpers
public static class WorkOrderBoardProjection
{
public static async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
ApplicationDbContext context,
IQueryable<WorkOrder> query,
bool isUnscheduled)
{
@ -70,7 +71,17 @@ namespace SeaHaven.DataServices.Helpers
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null,
// SH-188: aggregate Pending uplifts on every live dispatch tied to this WO
// (primary WorkOrderId and multi-WO DispatchWorkOrders links).
PendingUpliftCount = context.DispatchUpliftRequests.Count(u =>
u.Status == "Pending"
&& (u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false)
&& (
u.Dispatch.WorkOrderId == w.Id
|| u.Dispatch.DispatchWorkOrders!.Any(link => link.WorkOrderId == w.Id)))
})
.ToListAsync();
@ -122,6 +133,7 @@ namespace SeaHaven.DataServices.Helpers
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
w.PendingUpliftCount,
isUnscheduled);
}).ToList();
}

View file

@ -40,7 +40,7 @@ namespace SeaHaven.DataServices.Implementation
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
}

View file

@ -47,9 +47,11 @@ namespace SeaHaven.DataServices.Implementation
}
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
_context,
scheduledBase.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber),
isUnscheduled: false);
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(
_context,
unscheduledBase.OrderBy(w => w.InternalWONumber),
isUnscheduled: true);
@ -79,7 +81,7 @@ namespace SeaHaven.DataServices.Implementation
if (accountId.HasValue)
query = WorkOrderBoardQueryFilters.ApplyAccountScope(query, accountId.Value);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, query, isUnscheduled: false);
return rows.FirstOrDefault();
}

View file

@ -52,6 +52,7 @@ namespace SeaHaven.DataServices.Interfaces
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,
int PendingUpliftCount,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(

View file

@ -62,6 +62,11 @@ namespace SeaHaven.Services.DTOs
public int? PrimaryDispatchId { get; set; }
public byte[]? RowVersion { get; set; }
public byte[]? DispatchRowVersion { get; set; }
/// <summary>
/// Count of uplift requests with status Pending across all dispatches tied to this work order (SH-188).
/// Authoritative completion-gate signal for the board/search/detail row contract.
/// </summary>
public int PendingUpliftCount { get; set; }
}
public class WorkOrderBoardPatchRequestDto

View file

@ -126,7 +126,8 @@ namespace SeaHaven.Services.Implementation
FlagColor = row.FlagColor,
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion
DispatchRowVersion = row.DispatchRowVersion,
PendingUpliftCount = row.PendingUpliftCount
};
}

View file

@ -128,6 +128,7 @@ namespace SeaHaven.Services.Implementation
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion,
PendingUpliftCount = row.PendingUpliftCount,
Description = extended?.Description,
Trade = extended?.Trade,
Problem = extended?.Problem,

View file

@ -158,6 +158,219 @@ public class WorkOrderBoardDataServiceTests
}
}
public class WorkOrderBoardPendingUpliftTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task GetBoardRows_PendingUpliftOnPrimaryDispatch_SurfacesCount()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
SiteCode = "BK5"
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
false,
null,
null));
Assert.Single(result.ScheduledRows);
Assert.Equal(1, result.ScheduledRows[0].PendingUpliftCount);
var dto = WorkOrderBoardService.MapRawRow(result.ScheduledRows[0], DateTime.UtcNow);
Assert.Equal(1, dto.PendingUpliftCount);
}
[Fact]
public async Task GetBoardRows_ApprovedUplift_DoesNotBlock()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Approved",
RequiredTier = 1,
NotificationStatus = "Sent"
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
false,
null,
null));
Assert.Single(result.ScheduledRows);
Assert.Equal(0, result.ScheduledRows[0].PendingUpliftCount);
}
[Fact]
public async Task GetBoardRows_PendingUpliftViaDispatchWorkOrderLink_SurfacesCount()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
// Secondary link only via DispatchWorkOrders (no WorkOrderId / PrimaryDispatch).
context.Dispatches.Add(new Dispatch
{
Id = 20,
VendorId = 1,
WorkOrderId = null
});
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = 20,
WorkOrderId = 2
});
context.workOrders.Add(new WorkOrder
{
Id = 2,
InternalWONumber = "10000000002",
ScheduledDate = new DateTime(2026, 6, 24),
LifecycleStatus = LifecycleStatus.Scheduled
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 200,
DispatchId = 20,
RequestedNTE = 900m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
false,
null,
null));
Assert.Single(result.ScheduledRows);
Assert.Equal(2, result.ScheduledRows[0].Id);
Assert.Equal(1, result.ScheduledRows[0].PendingUpliftCount);
}
[Fact]
public async Task GetBoardRows_PendingUpliftOnSoftDeletedDispatch_DoesNotCount()
{
await using var context = CreateContext();
var weekStart = new DateOnly(2026, 6, 22);
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0),
IsDeleted = true
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
ScheduledDate = new DateTime(2026, 6, 23),
LifecycleStatus = LifecycleStatus.Scheduled,
PrimaryDispatchId = 10,
SiteCode = "BK5"
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var service = new WorkOrderBoardDataService(context);
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
weekStart,
weekStart.AddDays(4),
null,
false,
null,
false,
null,
null));
Assert.Single(result.ScheduledRows);
Assert.Equal(0, result.ScheduledRows[0].PendingUpliftCount);
}
}
public class WorkOrderBoardServiceTests
{
private static ApplicationDbContext CreateContext()

View file

@ -170,6 +170,46 @@ public class WorkOrderDetailServiceTests
Assert.Equal("WeekRolled", detail.Audit[0].Action);
}
[Fact]
public async Task GetDetail_SurfacesPendingUpliftCountFromBoardRow()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
context.Dispatches.Add(new Dispatch
{
Id = 10,
VendorId = 1,
WorkOrderId = 1,
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
});
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "10000000001",
LifecycleStatus = LifecycleStatus.Scheduled,
ScheduledDate = DateTime.UtcNow.AddDays(1),
PrimaryDispatchId = 10,
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,
DispatchId = 10,
RequestedNTE = 1500m,
Status = "Pending",
RequiredTier = 1,
NotificationStatus = "Pending"
});
await context.SaveChangesAsync();
var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.NotNull(detail);
Assert.Equal(1, detail!.Info.PendingUpliftCount);
}
[Fact]
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
{