From 8c33eb7f9a194038974a252194f5121b374a82d1 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 9 Jul 2026 14:08:48 -0300 Subject: [PATCH] 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. --- .../Controllers/WorkOrderJobsController.cs | 58 ++++ .../PastDueCacheHostedService.cs | 74 +++++ .../WorkOrderWeekRolledHostedService.cs | 82 +++++ .../Options/WorkOrderJobRunState.cs | 11 + .../Options/WorkOrderJobsOptions.cs | 23 ++ Api.SeaHavenIndustries/Program.cs | 8 + Api.SeaHavenIndustries/appsettings.json | 4 + .../Auth/ApplicationDbContext.cs | 5 + .../20260709170202_Phase5_DomainEvents.cs | 50 +++ .../ApplicationDbContextModelSnapshot.cs | 126 ++++--- .../Models/WorkOrderWeekRolledLedger.cs | 15 + .../WorkOrderDomainJobDataService.cs | 166 ++++++++++ .../IWorkOrderDomainJobDataService.cs | 38 +++ SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs | 21 ++ .../Implementation/PastDueCacheService.cs | 48 +++ .../Implementation/WorkOrderAuditService.cs | 11 + .../WorkOrderWeekRolledService.cs | 111 +++++++ .../Interfaces/IPastDueCacheService.cs | 9 + .../Interfaces/IWorkOrderAuditService.cs | 1 + .../Interfaces/IWorkOrderWeekRolledService.cs | 11 + .../WorkOrderWeekRolledTests.cs | 311 ++++++++++++++++++ 21 files changed, 1137 insertions(+), 46 deletions(-) create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260709170202_Phase5_DomainEvents.cs create mode 100644 Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs create mode 100644 SeaHaven.Services/Implementation/PastDueCacheService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs create mode 100644 SeaHaven.Services/Interfaces/IPastDueCacheService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs 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 6dc0e95..922582e 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; @@ -51,6 +53,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 7cce9c8..6f2460b 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(); @@ -66,6 +70,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 061ccd8..2b54ef9 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 => @@ -1969,13 +1969,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)"); @@ -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("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 => @@ -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") 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 0646cd0..73f871f 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 void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null) => StageAudit(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 7029709..eda29f8 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); 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); 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); + } +}