mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
feat(work-orders): add Phase 5 scheduled domain events (WeekRolled)
Introduce in-process WeekRolled job with idempotent ledger to increment carriedOver for the SHOC board, plus optional PastDue cache and admin reprocess endpoints while keeping isPastDue derived on-read.
This commit is contained in:
parent
bdd566de83
commit
7459dca3c6
21 changed files with 1136 additions and 39 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs
Normal file
11
Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
23
Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs
Normal file
23
Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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;
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -88,6 +92,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
|
||||
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
|
||||
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
|
||||
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
|
||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DateTime?>("ScheduledStart")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Service")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SignOffAttachment")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
15
Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs
Normal file
15
Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService
|
||||
{
|
||||
private static readonly LifecycleStatus?[] TerminalStatuses =
|
||||
{
|
||||
LifecycleStatus.Completed,
|
||||
LifecycleStatus.Canceled
|
||||
};
|
||||
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderDomainJobDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
DateOnly sourceWeekEnd,
|
||||
int batchSize = 500,
|
||||
int skip = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var weekStartDate = sourceWeekStart.ToDateTime(TimeOnly.MinValue);
|
||||
var weekEndDate = sourceWeekEnd.ToDateTime(TimeOnly.MinValue);
|
||||
|
||||
return await _context.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(w => w.IsDeleted != true)
|
||||
.Where(w => w.istemplate != true)
|
||||
.Where(w => w.ScheduledDate != null)
|
||||
.Where(w =>
|
||||
w.ScheduledDate!.Value.Date >= weekStartDate.Date
|
||||
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
|
||||
.Where(w => !TerminalStatuses.Contains(w.LifecycleStatus))
|
||||
.Where(w => !_context.WorkOrderWeekRolledLedgers.Any(l =>
|
||||
l.WorkOrderId == w.Id && l.SourceWeekStart == sourceWeekStart))
|
||||
.OrderBy(w => w.Id)
|
||||
.Skip(skip)
|
||||
.Take(batchSize)
|
||||
.Select(w => w.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
|
||||
int workOrderId,
|
||||
DateOnly sourceWeekStart,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var alreadyProcessed = await _context.WorkOrderWeekRolledLedgers
|
||||
.AnyAsync(l => l.WorkOrderId == workOrderId && l.SourceWeekStart == sourceWeekStart, cancellationToken);
|
||||
|
||||
if (alreadyProcessed)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return new WeekRolledProcessOutcome { Skipped = true };
|
||||
}
|
||||
|
||||
var workOrder = await _context.workOrders
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
|
||||
if (workOrder == null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return new WeekRolledProcessOutcome { Skipped = true };
|
||||
}
|
||||
|
||||
_context.WorkOrderWeekRolledLedgers.Add(new WorkOrderWeekRolledLedger
|
||||
{
|
||||
WorkOrderId = workOrderId,
|
||||
SourceWeekStart = sourceWeekStart,
|
||||
ProcessedAt = DateTime.UtcNow,
|
||||
CorrelationId = correlationId
|
||||
});
|
||||
|
||||
var oldCarriedOver = workOrder.CarriedOver;
|
||||
workOrder.CarriedOver = oldCarriedOver + 1;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return new WeekRolledProcessOutcome { Skipped = true };
|
||||
}
|
||||
|
||||
return new WeekRolledProcessOutcome
|
||||
{
|
||||
Processed = true,
|
||||
OldCarriedOver = oldCarriedOver,
|
||||
NewCarriedOver = workOrder.CarriedOver
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
|
||||
DateTime utcNow,
|
||||
int batchSize = 500,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var today = utcNow.Date;
|
||||
var setCount = 0;
|
||||
var clearedCount = 0;
|
||||
var examinedCount = 0;
|
||||
var skip = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var batch = await _context.workOrders
|
||||
.Where(w => w.IsDeleted != true)
|
||||
.Where(w => w.istemplate != true)
|
||||
.Where(w => w.ScheduledDate != null)
|
||||
.OrderBy(w => w.Id)
|
||||
.Skip(skip)
|
||||
.Take(batchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (batch.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var workOrder in batch)
|
||||
{
|
||||
examinedCount++;
|
||||
var shouldBePastDue = workOrder.ScheduledDate!.Value.Date < today
|
||||
&& !TerminalStatuses.Contains(workOrder.LifecycleStatus);
|
||||
var hasFlag = workOrder.OperationalFlags.HasFlag(OperationalFlags.PastDue);
|
||||
|
||||
if (shouldBePastDue && !hasFlag)
|
||||
{
|
||||
workOrder.OperationalFlags |= OperationalFlags.PastDue;
|
||||
setCount++;
|
||||
}
|
||||
else if (!shouldBePastDue && hasFlag)
|
||||
{
|
||||
workOrder.OperationalFlags &= ~OperationalFlags.PastDue;
|
||||
clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
skip += batchSize;
|
||||
|
||||
if (batch.Count < batchSize)
|
||||
break;
|
||||
}
|
||||
|
||||
return new PastDueRefreshResult
|
||||
{
|
||||
SetCount = setCount,
|
||||
ClearedCount = clearedCount,
|
||||
ExaminedCount = examinedCount
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public sealed class WeekRolledProcessOutcome
|
||||
{
|
||||
public bool Processed { get; init; }
|
||||
public bool Skipped { get; init; }
|
||||
public int OldCarriedOver { get; init; }
|
||||
public int NewCarriedOver { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PastDueRefreshResult
|
||||
{
|
||||
public int SetCount { get; init; }
|
||||
public int ClearedCount { get; init; }
|
||||
public int ExaminedCount { get; init; }
|
||||
}
|
||||
|
||||
public interface IWorkOrderDomainJobDataService
|
||||
{
|
||||
Task<IReadOnlyList<int>> GetWeekRolledCandidatesAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
DateOnly sourceWeekEnd,
|
||||
int batchSize = 500,
|
||||
int skip = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WeekRolledProcessOutcome> TryProcessWeekRolledAsync(
|
||||
int workOrderId,
|
||||
DateOnly sourceWeekStart,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PastDueRefreshResult> RefreshPastDueFlagsAsync(
|
||||
DateTime utcNow,
|
||||
int batchSize = 500,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
21
SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs
Normal file
21
SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
48
SeaHaven.Services/Implementation/PastDueCacheService.cs
Normal file
48
SeaHaven.Services/Implementation/PastDueCacheService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
111
SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs
Normal file
111
SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService
|
||||
{
|
||||
private const int BatchSize = 500;
|
||||
|
||||
private readonly IWorkOrderDomainJobDataService _dataService;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
private readonly ILogger<WorkOrderWeekRolledService> _logger;
|
||||
|
||||
public WorkOrderWeekRolledService(
|
||||
IWorkOrderDomainJobDataService dataService,
|
||||
IWorkOrderAuditService auditService,
|
||||
ILogger<WorkOrderWeekRolledService> logger)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_auditService = auditService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<WeekRolledJobResult> ProcessWeekRolledAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var sourceWeekEnd = WorkOrderOperationalWeek.GetOperationalWeekEnd(sourceWeekStart);
|
||||
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(sourceWeekStart);
|
||||
|
||||
var processed = 0;
|
||||
var skipped = 0;
|
||||
var failed = 0;
|
||||
var skip = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"WeekRolled job started. CorrelationId={CorrelationId}, SourceWeekStart={SourceWeekStart}, SourceWeekEnd={SourceWeekEnd}",
|
||||
correlationId, sourceWeekStart, sourceWeekEnd);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var candidates = await _dataService.GetWeekRolledCandidatesAsync(
|
||||
sourceWeekStart, sourceWeekEnd, BatchSize, skip, cancellationToken);
|
||||
|
||||
if (candidates.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var workOrderId in candidates)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var outcome = await _dataService.TryProcessWeekRolledAsync(
|
||||
workOrderId, sourceWeekStart, correlationId, cancellationToken);
|
||||
|
||||
if (outcome.Skipped)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outcome.Processed)
|
||||
{
|
||||
await _auditService.LogWeekRolledAsync(
|
||||
workOrderId, outcome.OldCarriedOver, outcome.NewCarriedOver, correlationId);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
_logger.LogError(ex,
|
||||
"WeekRolled failed for WorkOrderId={WorkOrderId}, CorrelationId={CorrelationId}",
|
||||
workOrderId, correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count < BatchSize)
|
||||
break;
|
||||
|
||||
skip += BatchSize;
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var result = new WeekRolledJobResult
|
||||
{
|
||||
Processed = processed,
|
||||
Skipped = skipped,
|
||||
Failed = failed,
|
||||
CorrelationId = correlationId,
|
||||
SourceWeekStart = sourceWeekStart,
|
||||
SourceWeekEnd = sourceWeekEnd,
|
||||
DurationMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"WeekRolled job completed. CorrelationId={CorrelationId}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}",
|
||||
correlationId, processed, skipped, failed, result.DurationMs);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
SeaHaven.Services/Interfaces/IPastDueCacheService.cs
Normal file
9
SeaHaven.Services/Interfaces/IPastDueCacheService.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IPastDueCacheService
|
||||
{
|
||||
Task<PastDueCacheJobResult> RefreshAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
11
SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs
Normal file
11
SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderWeekRolledService
|
||||
{
|
||||
Task<WeekRolledJobResult> ProcessWeekRolledAsync(
|
||||
DateOnly sourceWeekStart,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
311
SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
Normal file
311
SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderWeekRolledTests
|
||||
{
|
||||
private static readonly DateOnly SourceWeekStart = new(2026, 6, 16);
|
||||
private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20);
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static WorkOrderWeekRolledService CreateService(ApplicationDbContext context)
|
||||
{
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var audit = new WorkOrderAuditService(context, new WorkOrderFieldLockService(context));
|
||||
return new WorkOrderWeekRolledService(
|
||||
dataService,
|
||||
audit,
|
||||
NullLogger<WorkOrderWeekRolledService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_IncrementsCarriedOver_ForEligibleWo()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(1, result.Processed);
|
||||
Assert.Equal(0, result.Skipped);
|
||||
Assert.Equal(0, result.Failed);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_SkipsTerminalStatuses()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Completed
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Canceled
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var result = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(0, result.Processed);
|
||||
Assert.Equal(0, result.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_Idempotent_SecondRunSkips()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
var first = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
var second = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(1, first.Processed);
|
||||
Assert.Equal(0, second.Processed);
|
||||
Assert.Equal(0, second.Failed);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_WritesAuditEvent()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.InProgress,
|
||||
CarriedOver = 2
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
||||
Assert.Single(logs);
|
||||
Assert.Equal("WeekRolled", logs[0].Action);
|
||||
Assert.Equal("System", logs[0].EventType);
|
||||
Assert.Equal("System", logs[0].ActorType);
|
||||
Assert.Equal("CarriedOver", logs[0].FieldName);
|
||||
Assert.Equal("2", logs[0].OldValue);
|
||||
Assert.Equal("3", logs[0].NewValue);
|
||||
Assert.Equal("week:2026-06-16", logs[0].CorrelationId);
|
||||
|
||||
var locks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||
Assert.Empty(locks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_ConcurrentWithReschedule_DoesNotDuplicate()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
|
||||
|
||||
var first = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
|
||||
var second = await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
|
||||
|
||||
Assert.True(first.Processed);
|
||||
Assert.True(second.Skipped);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeekRolled_FailureReRun_IsSafeAfterPartialLedger()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var correlationId = WorkOrderOperationalWeek.BuildWeekCorrelationId(SourceWeekStart);
|
||||
|
||||
await dataService.TryProcessWeekRolledAsync(1, SourceWeekStart, correlationId);
|
||||
|
||||
var service = CreateService(context);
|
||||
var rerun = await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
Assert.Equal(0, rerun.Processed);
|
||||
Assert.Equal(0, rerun.Failed);
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(1, wo!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PastDueCache_SyncsOperationalFlags()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var pastDate = DateTime.UtcNow.Date.AddDays(-3);
|
||||
var futureDate = DateTime.UtcNow.Date.AddDays(3);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = pastDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
ScheduledDate = pastDate,
|
||||
LifecycleStatus = LifecycleStatus.Completed,
|
||||
OperationalFlags = OperationalFlags.PastDue
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 3,
|
||||
ScheduledDate = futureDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.PastDue
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderDomainJobDataService(context);
|
||||
var result = await dataService.RefreshPastDueFlagsAsync(DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(1, result.SetCount);
|
||||
Assert.Equal(2, result.ClearedCount);
|
||||
|
||||
var wo1 = await context.workOrders.FindAsync(1);
|
||||
var wo2 = await context.workOrders.FindAsync(2);
|
||||
var wo3 = await context.workOrders.FindAsync(3);
|
||||
|
||||
Assert.True(wo1!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
|
||||
Assert.False(wo2!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
|
||||
Assert.False(wo3!.OperationalFlags.HasFlag(OperationalFlags.PastDue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Board_ReflectsCarriedOver_AfterWeekRolled()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 18),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
CarriedOver = 0
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = CreateService(context);
|
||||
await service.ProcessWeekRolledAsync(SourceWeekStart);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal(1, row!.CarriedOver);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Board_IsPastDue_UnaffectedByCache()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var pastDate = DateTime.UtcNow.Date.AddDays(-2);
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = pastDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.None
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.True(row!.IsPastDue);
|
||||
Assert.False(WorkOrderDerivedFields.IsPastDue(pastDate, LifecycleStatus.Scheduled) == false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Board_IsPastDue_IgnoresStalePastDueFlag()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var futureDate = DateTime.UtcNow.Date.AddDays(5);
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = futureDate,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
OperationalFlags = OperationalFlags.PastDue
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var row = await boardService.GetBoardRowAsync(1);
|
||||
|
||||
Assert.NotNull(row);
|
||||
Assert.False(row!.IsPastDue);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue