feat(work-orders): add Phase 5 scheduled domain events (WeekRolled)

Introduce in-process WeekRolled job with idempotent ledger to increment carriedOver for the SHOC board, plus optional PastDue cache and admin reprocess endpoints while keeping isPastDue derived on-read.
This commit is contained in:
Arthur Bassi 2026-07-09 14:08:48 -03:00
parent 83272e4957
commit 8c33eb7f9a
21 changed files with 1137 additions and 46 deletions

View file

@ -0,0 +1,58 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
/// <summary>Ops-only endpoints for manual domain job reprocessing.</summary>
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/workorders/jobs")]
public class WorkOrderJobsController : ControllerBase
{
private readonly IWorkOrderWeekRolledService _weekRolledService;
private readonly IPastDueCacheService _pastDueCacheService;
public WorkOrderJobsController(
IWorkOrderWeekRolledService weekRolledService,
IPastDueCacheService pastDueCacheService)
{
_weekRolledService = weekRolledService;
_pastDueCacheService = pastDueCacheService;
}
/// <summary>
/// Reprocess WeekRolled for a specific operational week (Monday start).
/// Idempotent via WorkOrderWeekRolledLedger.
/// </summary>
[HttpPost("week-rolled")]
[ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<WeekRolledJobResult>> RunWeekRolled(
[FromQuery] DateOnly? sourceWeekStart,
CancellationToken cancellationToken)
{
var weekStart = sourceWeekStart
?? WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
if (weekStart.DayOfWeek != DayOfWeek.Monday)
return BadRequest("sourceWeekStart must be a Monday (operational week start).");
var result = await _weekRolledService.ProcessWeekRolledAsync(weekStart, cancellationToken);
return Ok(result);
}
/// <summary>
/// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read).
/// </summary>
[HttpPost("past-due-cache")]
[ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)]
public async Task<ActionResult<PastDueCacheJobResult>> RunPastDueCache(CancellationToken cancellationToken)
{
var result = await _pastDueCacheService.RefreshAsync(cancellationToken);
return Ok(result);
}
}
}

View file

@ -0,0 +1,74 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.HostedServices
{
public class PastDueCacheHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PastDueCacheHostedService> _logger;
private readonly PastDueCacheJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public PastDueCacheHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<PastDueCacheHostedService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value.PastDueCache;
_runState = runState;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_options.Enabled && ShouldRunNow())
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
_lastRunDate = today;
await RunJobAsync(stoppingToken);
}
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task RunJobAsync(CancellationToken stoppingToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IPastDueCacheService>();
await service.RefreshAsync(stoppingToken);
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
_runState.LastPastDueCacheError = null;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastPastDueCacheError = ex.Message;
_logger.LogError(ex, "PastDue cache hosted job failed.");
}
}
private bool ShouldRunNow()
{
var now = DateTime.UtcNow;
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
runAt = new TimeOnly(0, 10);
var current = TimeOnly.FromDateTime(now);
return current >= runAt && current < runAt.AddMinutes(1);
}
}
}

View file

@ -0,0 +1,82 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.HostedServices
{
public class WorkOrderWeekRolledHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<WorkOrderWeekRolledHostedService> _logger;
private readonly WeekRolledJobOptions _options;
private readonly WorkOrderJobRunState _runState;
private DateOnly? _lastRunDate;
public WorkOrderWeekRolledHostedService(
IServiceScopeFactory scopeFactory,
IOptions<WorkOrderJobsOptions> options,
WorkOrderJobRunState runState,
ILogger<WorkOrderWeekRolledHostedService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value.WeekRolled;
_runState = runState;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_options.Enabled && ShouldRunNow())
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
_lastRunDate = today;
await RunJobAsync(stoppingToken);
}
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task RunJobAsync(CancellationToken stoppingToken)
{
try
{
var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow);
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderWeekRolledService>();
await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken);
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
_runState.LastWeekRolledError = null;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastWeekRolledError = ex.Message;
_logger.LogError(ex, "WeekRolled hosted job failed.");
}
}
private bool ShouldRunNow()
{
var now = DateTime.UtcNow;
if (!Enum.TryParse<DayOfWeek>(_options.DayOfWeek, ignoreCase: true, out var targetDay))
targetDay = DayOfWeek.Monday;
if (now.DayOfWeek != targetDay)
return false;
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
runAt = new TimeOnly(0, 5);
var current = TimeOnly.FromDateTime(now);
return current >= runAt && current < runAt.AddMinutes(1);
}
}
}

View file

@ -0,0 +1,11 @@
namespace Api.SeaHavenIndustries.Options
{
/// <summary>In-memory last-run timestamps for hosted domain jobs (ops health).</summary>
public class WorkOrderJobRunState
{
public DateTime? LastWeekRolledRunUtc { get; set; }
public DateTime? LastPastDueCacheRunUtc { get; set; }
public string? LastWeekRolledError { get; set; }
public string? LastPastDueCacheError { get; set; }
}
}

View file

@ -0,0 +1,23 @@
namespace Api.SeaHavenIndustries.Options
{
public class WorkOrderJobsOptions
{
public const string SectionName = "WorkOrderJobs";
public WeekRolledJobOptions WeekRolled { get; set; } = new();
public PastDueCacheJobOptions PastDueCache { get; set; } = new();
}
public class WeekRolledJobOptions
{
public bool Enabled { get; set; } = true;
public string RunAtUtc { get; set; } = "00:05";
public string DayOfWeek { get; set; } = "Monday";
}
public class PastDueCacheJobOptions
{
public bool Enabled { get; set; }
public string RunAtUtc { get; set; } = "00:10";
}
}

View file

@ -1,4 +1,6 @@
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
@ -51,6 +53,12 @@ builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp
builder.Services.AddDataServices();
builder.Services.AddBusinessServices();
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
builder.Services.AddSingleton<WorkOrderJobRunState>();
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
builder.Services.AddHostedService<PastDueCacheHostedService>();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;

View file

@ -23,5 +23,9 @@
"ValidIssuer": "http://localhost:7195",
// Provide the real value via environment variable JWT__Secret or user-secrets.
"Secret": "${JWT_SECRET}"
},
"WorkOrderJobs": {
"WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" },
"PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" }
}
}

View file

@ -38,6 +38,10 @@ namespace Data.SeaHavenIndustries
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
.IsUnique();
builder.Entity<WorkOrderWeekRolledLedger>()
.HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
.IsUnique();
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
.IsRowVersion();
@ -66,6 +70,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }

View file

@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase5_DomainEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WorkOrderWeekRolledLedgers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
SourceWeekStart = table.Column<DateOnly>(type: "date", nullable: false),
ProcessedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CorrelationId = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkOrderWeekRolledLedgers", x => x.Id);
table.ForeignKey(
name: "FK_WorkOrderWeekRolledLedgers_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_WorkOrderWeekRolledLedgers_WorkOrderId_SourceWeekStart",
table: "WorkOrderWeekRolledLedgers",
columns: new[] { "WorkOrderId", "SourceWeekStart" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WorkOrderWeekRolledLedgers");
}
}
}

View file

@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Accounts");
b.ToTable("Accounts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("Addresses");
b.ToTable("Addresses", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Assets");
b.ToTable("Assets", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
@ -371,7 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Categories");
b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
@ -438,7 +438,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkerOrderId");
b.ToTable("Comments");
b.ToTable("Comments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
@ -504,7 +504,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("ContactId");
b.ToTable("ContactDetails");
b.ToTable("ContactDetails", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
@ -596,7 +596,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Contacts");
b.ToTable("Contacts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
@ -614,7 +614,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Departments");
b.ToTable("Departments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
@ -705,7 +705,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("Dispatches");
b.ToTable("Dispatches", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
@ -764,7 +764,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchChecklistItems");
b.ToTable("DispatchChecklistItems", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
@ -818,7 +818,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("DispatchId");
b.ToTable("DispatchSignoffs");
b.ToTable("DispatchSignoffs", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
@ -888,7 +888,7 @@ namespace Data.SeaHavenIndustries.Migrations
.IsUnique()
.HasFilter("[Status] = 'Pending'");
b.ToTable("DispatchUpliftRequests");
b.ToTable("DispatchUpliftRequests", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
@ -911,7 +911,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders");
b.ToTable("DispatchWorkOrders", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
@ -941,7 +941,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("DropdownOptions");
b.ToTable("DropdownOptions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
@ -1046,7 +1046,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("RegionId");
b.ToTable("Employees");
b.ToTable("Employees", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
@ -1086,7 +1086,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId")
.IsUnique();
b.ToTable("EmployeeAddresses");
b.ToTable("EmployeeAddresses", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
@ -1114,7 +1114,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeeEmails");
b.ToTable("EmployeeEmails", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
@ -1144,7 +1144,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("EmployeeId");
b.ToTable("EmployeePhones");
b.ToTable("EmployeePhones", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
@ -1212,7 +1212,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("FollowUps");
b.ToTable("FollowUps", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
@ -1237,7 +1237,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("ForgetPasswordCodes");
b.ToTable("ForgetPasswordCodes", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
@ -1255,7 +1255,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("JobTitles");
b.ToTable("JobTitles", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
@ -1325,7 +1325,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Locations");
b.ToTable("Locations", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
@ -1383,7 +1383,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("PMSchedules");
b.ToTable("PMSchedules", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
@ -1457,7 +1457,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("Quotes");
b.ToTable("Quotes", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
@ -1505,7 +1505,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("QuoteId");
b.ToTable("QuotesLines");
b.ToTable("QuotesLines", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
@ -1523,7 +1523,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Regions");
b.ToTable("Regions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
@ -1566,7 +1566,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("TaskListTemplates");
b.ToTable("TaskListTemplates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
@ -1611,7 +1611,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("TaskListTemplateId");
b.ToTable("TaskListTemplateItems");
b.ToTable("TaskListTemplateItems", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
@ -1713,7 +1713,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.ToTable("Templates");
b.ToTable("Templates", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
@ -1777,7 +1777,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.ToTable("Vendors");
b.ToTable("Vendors", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
@ -1836,7 +1836,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("VendorId");
b.ToTable("VendorAccessTokens");
b.ToTable("VendorAccessTokens", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
@ -1969,13 +1969,13 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("ScheduledStart")
.HasColumnType("datetime2");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
b.Property<string>("Service")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
b.Property<string>("SignOffAttachment")
.HasColumnType("nvarchar(max)");
@ -2031,17 +2031,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("AssignTo");
b.HasIndex("AssignTo", "ScheduledDate");
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
b.HasIndex("ScheduledDate");
b.HasIndex("WorkOrderType");
b.ToTable("workOrders");
b.ToTable("workOrders", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -2083,7 +2077,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderAttachments");
b.ToTable("workOrderAttachments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@ -2133,7 +2127,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
b.ToTable("WorkOrderAuditLogs");
b.ToTable("WorkOrderAuditLogs", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@ -2177,7 +2171,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("workOrderCategories");
b.ToTable("workOrderCategories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@ -2224,7 +2218,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
b.ToTable("WorkOrderContacts");
b.ToTable("WorkOrderContacts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@ -2253,7 +2247,36 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks");
b.ToTable("WorkOrderFieldLocks", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@ -2822,6 +2845,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")

View file

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class WorkOrderWeekRolledLedger
{
public int Id { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public virtual WorkOrder? WorkOrder { get; set; }
public DateOnly SourceWeekStart { get; set; }
public DateTime ProcessedAt { get; set; }
public string CorrelationId { get; set; } = "";
}
}

View file

@ -0,0 +1,166 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService
{
private static readonly LifecycleStatus?[] TerminalStatuses =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
private readonly ApplicationDbContext _context;
public WorkOrderDomainJobDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
DateOnly sourceWeekStart,
DateOnly sourceWeekEnd,
int batchSize = 500,
int skip = 0,
CancellationToken cancellationToken = default)
{
var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue);
var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue);
return await _context.workOrders
.AsNoTracking()
.Where(w => w.IsDeleted != true)
.Where(w => w.istemplate != true)
.Where(w => w.ScheduledDate != null)
.Where(w =>
w.ScheduledDate!.Value.Date >= weekStartDate.Date
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
.Where(w => !TerminalStatuses.Contains(w.LifecycleStatus))
.Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l =>
l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart))
.OrderBy(w => w.Id)
.Skip(skip)
.Take(batchSize)
.Select(w => w.Id)
.ToListAsync(cancellationToken);
}
public async Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
int workOrderId,
DateOnly sourceWeekStart,
string correlationId,
CancellationToken cancellationToken = default)
{
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
var alreadyProcessed = await _context.WorkOrderWeekRolledLedgers
.AnyAsync(l => l.WorkOrderId == workOrderId && l.SourceWeekStart == sourceWeekStart, cancellationToken);
if (alreadyProcessed)
{
await transaction.RollbackAsync(cancellationToken);
return new WeekRolledProcessOutcome { Skipped = true };
}
var workOrder = await _context.workOrders
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
if (workOrder == null)
{
await transaction.RollbackAsync(cancellationToken);
return new WeekRolledProcessOutcome { Skipped = true };
}
_context.WorkOrderWeekRolledLedgers.Add(new WorkOrderWeekRolledLedger
{
WorkOrderId = workOrderId,
SourceWeekStart = sourceWeekStart,
ProcessedAt = DateTime.UtcNow,
CorrelationId = correlationId
});
var oldCarriedOver = workOrder.CarriedOver;
workOrder.CarriedOver = oldCarriedOver + 1;
try
{
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return new WeekRolledProcessOutcome { Skipped = true };
}
return new WeekRolledProcessOutcome
{
Processed = true,
OldCarriedOver = oldCarriedOver,
NewCarriedOver = workOrder.CarriedOver
};
}
public async Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
DateTime utcNow,
int batchSize = 500,
CancellationToken cancellationToken = default)
{
var today = utcNow.Date;
var setCount = 0;
var clearedCount = 0;
var examinedCount = 0;
var skip = 0;
while (true)
{
var batch = await _context.workOrders
.Where(w => w.IsDeleted != true)
.Where(w => w.istemplate != true)
.Where(w => w.ScheduledDate != null)
.OrderBy(w => w.Id)
.Skip(skip)
.Take(batchSize)
.ToListAsync(cancellationToken);
if (batch.Count == 0)
break;
foreach (var workOrder in batch)
{
examinedCount++;
var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);
if (shouldBePastDue && !hasFlag)
{
workOrder.OperationalFlags |= OperationalFlags.PastDue;
setCount++;
}
else if (!shouldBePastDue && hasFlag)
{
workOrder.OperationalFlags &= ~OperationalFlags.PastDue;
clearedCount++;
}
}
await _context.SaveChangesAsync(cancellationToken);
skip += batchSize;
if (batch.Count < batchSize)
break;
}
return new PastDueRefreshResult
{
SetCount = setCount,
ClearedCount = clearedCount,
ExaminedCount = examinedCount
};
}
}
}

View file

@ -0,0 +1,38 @@
namespace SeaHaven.DataServices.Interfaces
{
public sealed class WeekRolledProcessOutcome
{
public bool Processed { get; init; }
public bool Skipped { get; init; }
public int OldCarriedOver { get; init; }
public int NewCarriedOver { get; init; }
}
public sealed class PastDueRefreshResult
{
public int SetCount { get; init; }
public int ClearedCount { get; init; }
public int ExaminedCount { get; init; }
}
public interface IWorkOrderDomainJobDataService
{
Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
DateOnly sourceWeekStart,
DateOnly sourceWeekEnd,
int batchSize = 500,
int skip = 0,
CancellationToken cancellationToken = default);
Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
int workOrderId,
DateOnly sourceWeekStart,
string correlationId,
CancellationToken cancellationToken = default);
Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
DateTime utcNow,
int batchSize = 500,
CancellationToken cancellationToken = default);
}
}

View file

@ -0,0 +1,21 @@
namespace SeaHaven.Services.DTOs
{
public sealed class WeekRolledJobResult
{
public int Processed { get; set; }
public int Skipped { get; set; }
public int Failed { get; set; }
public string CorrelationId { get; set; } = "";
public DateOnly SourceWeekStart { get; set; }
public DateOnly SourceWeekEnd { get; set; }
public long DurationMs { get; set; }
}
public sealed class PastDueCacheJobResult
{
public int SetCount { get; set; }
public int ClearedCount { get; set; }
public int ExaminedCount { get; set; }
public long DurationMs { get; set; }
}
}

View file

@ -0,0 +1,48 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class PastDueCacheService : IPastDueCacheService
{
private readonly IWorkOrderDomainJobDataService _dataService;
private readonly ILogger<PastDueCacheService> _logger;
public PastDueCacheService(
IWorkOrderDomainJobDataService dataService,
ILogger<PastDueCacheService> logger)
{
_dataService = dataService;
_logger = logger;
}
public async Task<PastDueCacheJobResult> RefreshAsync(CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var utcNow = DateTime.UtcNow;
_logger.LogInformation("PastDue cache refresh started at {UtcNow}", utcNow);
var refreshResult = await _dataService.RefreshPastDueFlagsAsync(utcNow, cancellationToken: cancellationToken);
stopwatch.Stop();
var result = new PastDueCacheJobResult
{
SetCount = refreshResult.SetCount,
ClearedCount = refreshResult.ClearedCount,
ExaminedCount = refreshResult.ExaminedCount,
DurationMs = stopwatch.ElapsedMilliseconds
};
_logger.LogInformation(
"PastDue cache refresh completed. Set={SetCount}, Cleared={ClearedCount}, Examined={ExaminedCount}, DurationMs={DurationMs}",
result.SetCount, result.ClearedCount, result.ExaminedCount, result.DurationMs);
return result;
}
}
}

View file

@ -28,6 +28,17 @@ namespace SeaHaven.Services.Implementation
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
AuditActorType.Sync, fieldName, oldValue, newValue);
public Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId)
=> LogAsync(
workOrderId,
AuditActionType.WeekRolled,
AuditEventType.System,
AuditActorType.System,
"CarriedOver",
oldCarriedOver.ToString(),
newCarriedOver.ToString(),
correlationId: correlationId);
public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);

View file

@ -0,0 +1,111 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService
{
private const int BatchSize = 500;
private readonly IWorkOrderDomainJobDataService _dataService;
private readonly IWorkOrderAuditService _auditService;
private readonly ILogger<WorkOrderWeekRolledService> _logger;
public WorkOrderWeekRolledService(
IWorkOrderDomainJobDataService dataService,
IWorkOrderAuditService auditService,
ILogger<WorkOrderWeekRolledService> logger)
{
_dataService = dataService;
_auditService = auditService;
_logger = logger;
}
public async Task<WeekRolledJobResult> ProcessWeekRolledAsync(
DateOnly sourceWeekStart,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var sourceWeekEnd = WorkOrderOperationalWeek.GetOperationalWeekEnd(sourceWeekStart);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(sourceWeekStart);
var processed = 0;
var skipped = 0;
var failed = 0;
var skip = 0;
_logger.LogInformation(
"WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}",
correlationId, sourceWeekStart, sourceWeekEnd);
while (!cancellationToken.IsCancellationRequested)
{
var candidates = await _dataService.GetWeekRolledCandidatesAsync(
sourceWeekStart, sourceWeekEnd, BatchSize, skip, cancellationToken);
if (candidates.Count == 0)
break;
foreach (var workOrderId in candidates)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
var outcome = await _dataService.TryProcessWeekRolledAsync(
workOrderId, sourceWeekStart, correlationId, cancellationToken);
if (outcome.Skipped)
{
skipped++;
continue;
}
if (outcome.Processed)
{
await _auditService.LogWeekRolledAsync(
workOrderId, outcome.OldCarriedOver, outcome.NewCarriedOver, correlationId);
processed++;
}
}
catch (Exception ex)
{
failed++;
_logger.LogError(ex,
"WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}",
workOrderId, correlationId);
}
}
if (candidates.Count < BatchSize)
break;
skip += BatchSize;
}
stopwatch.Stop();
var result = new WeekRolledJobResult
{
Processed = processed,
Skipped = skipped,
Failed = failed,
CorrelationId = correlationId,
SourceWeekStart = sourceWeekStart,
SourceWeekEnd = sourceWeekEnd,
DurationMs = stopwatch.ElapsedMilliseconds
};
_logger.LogInformation(
"WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
correlationId, processed, skipped, failed, result.DurationMs);
return result;
}
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IPastDueCacheService
{
Task<PastDueCacheJobResult> RefreshAsync(CancellationToken cancellationToken = default);
}
}

View file

@ -19,6 +19,7 @@ namespace SeaHaven.Services.Interfaces
Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId);
void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId);

View file

@ -0,0 +1,11 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderWeekRolledService
{
Task<WeekRolledJobResult> ProcessWeekRolledAsync(
DateOnly sourceWeekStart,
CancellationToken cancellationToken = default);
}
}

View file

@ -0,0 +1,311 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class WorkOrderWeekRolledTests
{
private static readonly DateOnly SourceWeekStart = new(2026, 6, 16);
private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20);
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context)
{
var dataService = new WorkOrderDomainJobDataService(context);
var audit = new WorkOrderAuditService(context, new WorkOrderFieldLockService(context));
return new WorkOrderWeekRolledService(
dataService,
audit,
NullLogger<WorkOrderWeekRolledService>.Instance);
}
[Fact]
public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(1, result.Processed);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_SkipsTerminalStatuses()
{
await using var context = CreateContext();
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Completed
},
new WorkOrder
{
Id = 2,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Canceled
});
await context.SaveChangesAsync();
var service = CreateService(context);
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(0, result.Processed);
Assert.Equal(0, result.Failed);
}
[Fact]
public async Task WeekRolled_Idempotent_SecondRunSkips()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
var first = await service.ProcessWeekRolledAsync(SourceWeekStart);
var second = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(1, first.Processed);
Assert.Equal(0, second.Processed);
Assert.Equal(0, second.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_WritesAuditEvent()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.InProgress,
CarriedOver = 2
});
await context.SaveChangesAsync();
var service = CreateService(context);
await service.ProcessWeekRolledAsync(SourceWeekStart);
var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Single(logs);
Assert.Equal("WeekRolled", logs[0].Action);
Assert.Equal("System", logs[0].EventType);
Assert.Equal("System", logs[0].ActorType);
Assert.Equal("CarriedOver", logs[0].FieldName);
Assert.Equal("2", logs[0].OldValue);
Assert.Equal("3", logs[0].NewValue);
Assert.Equal("week:2026-06-16", logs[0].CorrelationId);
var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Empty(locks);
}
[Fact]
public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
Assert.True(first.Processed);
Assert.True(second.Skipped);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
var service = CreateService(context);
var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(0, rerun.Processed);
Assert.Equal(0, rerun.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task PastDueCache_SyncsOperationalFlags()
{
await using var context = CreateContext();
var pastDate = DateTime.UtcNow.Date.AddDays(-3);
var futureDate = DateTime.UtcNow.Date.AddDays(3);
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 2,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Completed,
OperationalFlags = OperationalFlags.PastDue
},
new WorkOrder
{
Id = 3,
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var result = await dataService.RefreshPastDueFlagsAsync(DateTime.UtcNow);
Assert.Equal(1, result.SetCount);
Assert.Equal(2, result.ClearedCount);
var wo1 = await context.workOrders.FindAsync(1);
var wo2 = await context.workOrders.FindAsync(2);
var wo3 = await context.workOrders.FindAsync(3);
Assert.True(wo1!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
Assert.False(wo2!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
Assert.False(wo3!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
}
[Fact]
public async Task Board_ReflectsCarriedOver_AfterWeekRolled()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
await service.ProcessWeekRolledAsync(SourceWeekStart);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var row = await boardService.GetBoardRowAsync(1);
Assert.NotNull(row);
Assert.Equal(1, row!.CarriedOver);
}
[Fact]
public async Task Board_IsPastDue_UnaffectedByCache()
{
await using var context = CreateContext();
var pastDate = DateTime.UtcNow.Date.AddDays(-2);
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = pastDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.None
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var row = await boardService.GetBoardRowAsync(1);
Assert.NotNull(row);
Assert.True(row!.IsPastDue);
Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false);
}
[Fact]
public async Task Board_IsPastDue_IgnoresStalePastDueFlag()
{
await using var context = CreateContext();
var futureDate = DateTime.UtcNow.Date.AddDays(5);
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = futureDate,
LifecycleStatus = LifecycleStatus.Scheduled,
OperationalFlags = OperationalFlags.PastDue
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var row = await boardService.GetBoardRowAsync(1);
Assert.NotNull(row);
Assert.False(row!.IsPastDue);
}
}