Merge pull request #17 from Sea-Haven-Industries/feat/work-orders-phase-5

Feat/work orders Phase 5
This commit is contained in:
Alexandre Brandizzi 2026-07-24 13:55:09 -03:00 • committed by GitHub
commit 0e87817416
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 4179 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -2639,6 +2639,35 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("WorkOrderFieldLocks");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
@ -3272,6 +3301,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,110 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService
{
private const int BatchSize = 500;
private readonly IWorkOrderDomainJobDataService _dataService;
private readonly IWorkOrderAuditService _auditService;
private readonly ILogger<WorkOrderWeekRolledService> _logger;
public WorkOrderWeekRolledService(
IWorkOrderDomainJobDataService dataService,
IWorkOrderAuditService auditService,
ILogger<WorkOrderWeekRolledService> logger)
{
_dataService = dataService;
_auditService = auditService;
_logger = logger;
}
public async Task<WeekRolledJobResult> ProcessWeekRolledAsync(
DateOnly sourceWeekStart,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var sourceWeekEnd = WorkOrderOperationalWeek.GetOperationalWeekEnd(sourceWeekStart);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(sourceWeekStart);
var processed = 0;
var skipped = 0;
var failed = 0;
_logger.LogInformation(
"WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}",
correlationId, sourceWeekStart, sourceWeekEnd);
// Always fetch the first page of remaining candidates. Processed rows write ledger
// entries that exclude them from the next query; offset paging would skip unprocessed rows.
while (!cancellationToken.IsCancellationRequested)
{
var candidates = await _dataService.GetWeekRolledCandidatesAsync(
sourceWeekStart, sourceWeekEnd, BatchSize, 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;
}
stopwatch.Stop();
var result = new WeekRolledJobResult
{
Processed = processed,
Skipped = skipped,
Failed = failed,
CorrelationId = correlationId,
SourceWeekStart = sourceWeekStart,
SourceWeekEnd = sourceWeekEnd,
DurationMs = stopwatch.ElapsedMilliseconds
};
_logger.LogInformation(
"WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
correlationId, processed, skipped, failed, result.DurationMs);
return result;
}
}
}

View file

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

View file

@ -19,6 +19,7 @@ namespace SeaHaven.Services.Interfaces
Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
Task LogWeekRolledAsync(int workOrderId, int oldCarriedOver, int newCarriedOver, string correlationId);
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);

View file

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

View file

@ -0,0 +1,347 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class WorkOrderWeekRolledTests
{
private static readonly DateOnly SourceWeekStart = new(2026, 6, 16);
private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20);
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context)
{
var dataService = new WorkOrderDomainJobDataService(context);
var audit = new WorkOrderAuditService(context, new WorkOrderFieldLockService(context));
return new WorkOrderWeekRolledService(
dataService,
audit,
NullLogger<WorkOrderWeekRolledService>.Instance);
}
[Fact]
public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(1, result.Processed);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_SkipsTerminalStatuses()
{
await using var context = CreateContext();
context.workOrders.AddRange(
new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Completed
},
new WorkOrder
{
Id = 2,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Canceled
});
await context.SaveChangesAsync();
var service = CreateService(context);
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(0, result.Processed);
Assert.Equal(0, result.Failed);
}
[Fact]
public async Task WeekRolled_Idempotent_SecondRunSkips()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var service = CreateService(context);
var first = await service.ProcessWeekRolledAsync(SourceWeekStart);
var second = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(1, first.Processed);
Assert.Equal(0, second.Processed);
Assert.Equal(0, second.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_WritesAuditEvent()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.InProgress,
CarriedOver = 2
});
await context.SaveChangesAsync();
var service = CreateService(context);
await service.ProcessWeekRolledAsync(SourceWeekStart);
var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Single(logs);
Assert.Equal("WeekRolled", logs[0].Action);
Assert.Equal("System", logs[0].EventType);
Assert.Equal("System", logs[0].ActorType);
Assert.Equal("CarriedOver", logs[0].FieldName);
Assert.Equal("2", logs[0].OldValue);
Assert.Equal("3", logs[0].NewValue);
Assert.Equal("week:2026-06-16", logs[0].CorrelationId);
var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Empty(locks);
}
[Fact]
public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
Assert.True(first.Processed);
Assert.True(second.Skipped);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder
{
Id = 1,
ScheduledDate = new DateTime(2026, 6, 18),
LifecycleStatus = LifecycleStatus.Scheduled,
CarriedOver = 0
});
await context.SaveChangesAsync();
var dataService = new WorkOrderDomainJobDataService(context);
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
var service = CreateService(context);
var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart);
Assert.Equal(0, rerun.Processed);
Assert.Equal(0, rerun.Failed);
var wo = await context.workOrders.FindAsync(1);
Assert.Equal(1, wo!.CarriedOver);
}
[Fact]
public async Task WeekRolled_ProcessesAllEligible_WhenMoreThanBatchSize()
{
// WorkOrderWeekRolledService.BatchSize is 500; more than one page must all be carried over.
const int eligibleCount = 501;
await using var context = CreateContext();
for (var i = 1; i <= eligibleCount; i++)
{
context.workOrders.Add(new WorkOrder
{
Id = i,
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(eligibleCount, result.Processed);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Failed);
var carriedOverCount = await context.workOrders.CountAsync(w => w.CarriedOver == 1);
Assert.Equal(eligibleCount, carriedOverCount);
var ledgerCount = await context.WorkOrderWeekRolledLedgers.CountAsync();
Assert.Equal(eligibleCount, ledgerCount);
var auditCount = await context.WorkOrderAuditLogs.CountAsync(l => l.Action == "WeekRolled");
Assert.Equal(eligibleCount, auditCount);
}
[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);
}
}