diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs
new file mode 100644
index 0000000..4277475
--- /dev/null
+++ b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs
@@ -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
+{
+ /// Ops-only endpoints for manual domain job reprocessing.
+ [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;
+ }
+
+ ///
+ /// Reprocess WeekRolled for a specific operational week (Monday start).
+ /// Idempotent via WorkOrderWeekRolledLedger.
+ ///
+ [HttpPost("week-rolled")]
+ [ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)]
+ public async Task> 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);
+ }
+
+ ///
+ /// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read).
+ ///
+ [HttpPost("past-due-cache")]
+ [ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)]
+ public async Task> RunPastDueCache(CancellationToken cancellationToken)
+ {
+ var result = await _pastDueCacheService.RefreshAsync(cancellationToken);
+ return Ok(result);
+ }
+ }
+}
diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs
new file mode 100644
index 0000000..67b8cba
--- /dev/null
+++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs
@@ -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 _logger;
+ private readonly PastDueCacheJobOptions _options;
+ private readonly WorkOrderJobRunState _runState;
+ private DateOnly? _lastRunDate;
+
+ public PastDueCacheHostedService(
+ IServiceScopeFactory scopeFactory,
+ IOptions options,
+ WorkOrderJobRunState runState,
+ ILogger 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();
+ 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);
+ }
+ }
+}
diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs
new file mode 100644
index 0000000..7caf0a2
--- /dev/null
+++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs
@@ -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 _logger;
+ private readonly WeekRolledJobOptions _options;
+ private readonly WorkOrderJobRunState _runState;
+ private DateOnly? _lastRunDate;
+
+ public WorkOrderWeekRolledHostedService(
+ IServiceScopeFactory scopeFactory,
+ IOptions options,
+ WorkOrderJobRunState runState,
+ ILogger 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();
+ 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(_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);
+ }
+ }
+}
diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs
new file mode 100644
index 0000000..dd95de5
--- /dev/null
+++ b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs
@@ -0,0 +1,11 @@
+namespace Api.SeaHavenIndustries.Options
+{
+ /// In-memory last-run timestamps for hosted domain jobs (ops health).
+ public class WorkOrderJobRunState
+ {
+ public DateTime? LastWeekRolledRunUtc { get; set; }
+ public DateTime? LastPastDueCacheRunUtc { get; set; }
+ public string? LastWeekRolledError { get; set; }
+ public string? LastPastDueCacheError { get; set; }
+ }
+}
diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs
new file mode 100644
index 0000000..791638c
--- /dev/null
+++ b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs
@@ -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";
+ }
+}
diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs
index 55d5a4d..bb40d8c 100644
--- a/Api.SeaHavenIndustries/Program.cs
+++ b/Api.SeaHavenIndustries/Program.cs
@@ -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;
@@ -52,6 +54,12 @@ builder.Services.AddSingleton(sp
builder.Services.AddDataServices();
builder.Services.AddBusinessServices();
+builder.Services.Configure(
+ builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json
index 2d5dc80..f023879 100644
--- a/Api.SeaHavenIndustries/appsettings.json
+++ b/Api.SeaHavenIndustries/appsettings.json
@@ -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" }
}
}
\ No newline at end of file
diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
index 9dbef87..0599946 100644
--- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
+++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs
@@ -38,6 +38,10 @@ namespace Data.SeaHavenIndustries
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
.IsUnique();
+ builder.Entity()
+ .HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
+ .IsUnique();
+
builder.Entity()
.Property(w => w.RowVersion)
.IsRowVersion();
@@ -88,6 +92,7 @@ namespace Data.SeaHavenIndustries
public DbSet workOrderAttachments { get; set; }
public DbSet WorkOrderAuditLogs { get; set; }
public DbSet WorkOrderFieldLocks { get; set; }
+ public DbSet WorkOrderWeekRolledLedgers { get; set; }
public DbSet DropdownOptions { get; set; }
public DbSet Vendors { get; set; }
public DbSet VendorAccessTokens { get; set; }
diff --git a/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs
new file mode 100644
index 0000000..6a0307f
--- /dev/null
+++ b/Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs
@@ -0,0 +1,50 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.SeaHavenIndustries.Migrations
+{
+ ///
+ public partial class Phase5_DomainEvents : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "WorkOrderWeekRolledLedgers",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ WorkOrderId = table.Column(type: "int", nullable: false),
+ SourceWeekStart = table.Column(type: "date", nullable: false),
+ ProcessedAt = table.Column(type: "datetime2", nullable: false),
+ CorrelationId = table.Column(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);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "WorkOrderWeekRolledLedgers");
+ }
+ }
+}
diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
index ee87ae1..4f0969e 100644
--- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -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 =>
@@ -1970,13 +1970,13 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property("ScheduledStart")
.HasColumnType("datetime2");
- b.Property("Severity")
- .HasColumnType("nvarchar(max)");
-
b.Property("Service")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
+ b.Property("Severity")
+ .HasColumnType("nvarchar(max)");
+
b.Property("SignOffAttachment")
.HasColumnType("nvarchar(max)");
@@ -2094,7 +2094,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
- b.ToTable("workOrderAttachments");
+ b.ToTable("workOrderAttachments", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
@@ -2144,7 +2144,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkOrderId");
- b.ToTable("WorkOrderAuditLogs");
+ b.ToTable("WorkOrderAuditLogs", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
@@ -2188,7 +2188,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
- b.ToTable("workOrderCategories");
+ b.ToTable("workOrderCategories", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
@@ -2235,7 +2235,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("WorkorderId");
- b.ToTable("WorkOrderContacts");
+ b.ToTable("WorkOrderContacts", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
@@ -2264,7 +2264,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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CorrelationId")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ProcessedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("SourceWeekStart")
+ .HasColumnType("date");
+
+ b.Property("WorkOrderId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("WorkOrderId", "SourceWeekStart")
+ .IsUnique();
+
+ b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@@ -2833,6 +2862,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")
diff --git a/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs
new file mode 100644
index 0000000..efa661a
--- /dev/null
+++ b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs
@@ -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; } = "";
+ }
+}
diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs
new file mode 100644
index 0000000..2d8433a
--- /dev/null
+++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs
@@ -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> 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 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 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
+ };
+ }
+ }
+}
diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs
new file mode 100644
index 0000000..4c6af01
--- /dev/null
+++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs
@@ -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> GetWeekRolledCandidatesAsync(
+ DateOnly sourceWeekStart,
+ DateOnly sourceWeekEnd,
+ int batchSize = 500,
+ int skip = 0,
+ CancellationToken cancellationToken = default);
+
+ Task TryProcessWeekRolledAsync(
+ int workOrderId,
+ DateOnly sourceWeekStart,
+ string correlationId,
+ CancellationToken cancellationToken = default);
+
+ Task RefreshPastDueFlagsAsync(
+ DateTime utcNow,
+ int batchSize = 500,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs
new file mode 100644
index 0000000..885c363
--- /dev/null
+++ b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs
@@ -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; }
+ }
+}
diff --git a/SeaHaven.Services/Implementation/PastDueCacheService.cs b/SeaHaven.Services/Implementation/PastDueCacheService.cs
new file mode 100644
index 0000000..33c1646
--- /dev/null
+++ b/SeaHaven.Services/Implementation/PastDueCacheService.cs
@@ -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 _logger;
+
+ public PastDueCacheService(
+ IWorkOrderDomainJobDataService dataService,
+ ILogger logger)
+ {
+ _dataService = dataService;
+ _logger = logger;
+ }
+
+ public async Task 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;
+ }
+ }
+}
diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs
index b0e7c60..51df1cc 100644
--- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs
@@ -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 Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs
new file mode 100644
index 0000000..bf82574
--- /dev/null
+++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs
@@ -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 _logger;
+
+ public WorkOrderWeekRolledService(
+ IWorkOrderDomainJobDataService dataService,
+ IWorkOrderAuditService auditService,
+ ILogger logger)
+ {
+ _dataService = dataService;
+ _auditService = auditService;
+ _logger = logger;
+ }
+
+ public async Task 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;
+ }
+ }
+}
diff --git a/SeaHaven.Services/Interfaces/IPastDueCacheService.cs b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs
new file mode 100644
index 0000000..e87f498
--- /dev/null
+++ b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs
@@ -0,0 +1,9 @@
+using SeaHaven.Services.DTOs;
+
+namespace SeaHaven.Services.Interfaces
+{
+ public interface IPastDueCacheService
+ {
+ Task RefreshAsync(CancellationToken cancellationToken = default);
+ }
+}
diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs
index 3dc4b57..3970036 100644
--- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs
+++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs
@@ -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);
Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
diff --git a/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs
new file mode 100644
index 0000000..b8c1a6a
--- /dev/null
+++ b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs
@@ -0,0 +1,11 @@
+using SeaHaven.Services.DTOs;
+
+namespace SeaHaven.Services.Interfaces
+{
+ public interface IWorkOrderWeekRolledService
+ {
+ Task ProcessWeekRolledAsync(
+ DateOnly sourceWeekStart,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
new file mode 100644
index 0000000..9627581
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
@@ -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()
+ .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.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);
+ }
+}