mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-06 12:22:08 +00:00
wip: work orders phases 1-7 (isolated from phase 0 foundation)
This commit is contained in:
parent
65c1cdc7e9
commit
4f697f257a
121 changed files with 9583 additions and 0 deletions
|
|
@ -0,0 +1,47 @@
|
||||||
|
using Api.SeaHavenIndustries.Filters;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/workorders")]
|
||||||
|
public class WorkOrderIngestController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderIngestService _ingestService;
|
||||||
|
|
||||||
|
public WorkOrderIngestController(IWorkOrderIngestService ingestService)
|
||||||
|
{
|
||||||
|
_ingestService = ingestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target).
|
||||||
|
/// Auth: X-Ingest-Key header.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("ingest")]
|
||||||
|
[IngestApiKey]
|
||||||
|
[ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<WorkOrderIngestBatchResultDto>> Ingest(
|
||||||
|
[FromBody] WorkOrderIngestBatchRequestDto? batch,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = NormalizeItems(batch);
|
||||||
|
if (items.Count == 0)
|
||||||
|
return BadRequest("At least one item with externalWorkOrderId is required.");
|
||||||
|
|
||||||
|
var result = await _ingestService.UpsertBatchAsync(items, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<WorkOrderIngestPayloadDto> NormalizeItems(WorkOrderIngestBatchRequestDto? batch)
|
||||||
|
{
|
||||||
|
if (batch?.Items is { Count: > 0 })
|
||||||
|
return batch.Items;
|
||||||
|
|
||||||
|
return new List<WorkOrderIngestPayloadDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>Ops health and coexistence metrics (Fase 7).</summary>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/workorders/ops")]
|
||||||
|
public class WorkOrderOpsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderOpsHealthService _healthService;
|
||||||
|
|
||||||
|
public WorkOrderOpsController(IWorkOrderOpsHealthService healthService)
|
||||||
|
{
|
||||||
|
_healthService = healthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("health")]
|
||||||
|
[ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<WorkOrderOpsHealthDto>> GetHealth(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var health = await _healthService.GetHealthAsync(cancellationToken);
|
||||||
|
return Ok(health);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
56
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using Api.SeaHavenIndustries.Options;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Filters
|
||||||
|
{
|
||||||
|
/// <summary>Validates X-Ingest-Key for POST /api/workorders/ingest.</summary>
|
||||||
|
public class IngestApiKeyFilter : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
private readonly WorkOrderIngestOptions _options;
|
||||||
|
|
||||||
|
public IngestApiKeyFilter(IOptions<WorkOrderIngestOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(new { message = "Work order ingest is disabled." })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(new { message = "Ingest API key is not configured." })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided)
|
||||||
|
|| provided != _options.ApiKey)
|
||||||
|
{
|
||||||
|
context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public sealed class IngestApiKeyAttribute : ServiceFilterAttribute
|
||||||
|
{
|
||||||
|
public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
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 DateOnly? _lastRunDate;
|
||||||
|
|
||||||
|
public PastDueCacheHostedService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<WorkOrderJobsOptions> options,
|
||||||
|
ILogger<PastDueCacheHostedService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_options = options.Value.PastDueCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_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,75 @@
|
||||||
|
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 DateOnly? _lastRunDate;
|
||||||
|
|
||||||
|
public WorkOrderWeekRolledHostedService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<WorkOrderJobsOptions> options,
|
||||||
|
ILogger<WorkOrderWeekRolledHostedService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
_options = options.Value.WeekRolled;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
using Api.SeaHavenIndustries.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Middleware
|
||||||
|
{
|
||||||
|
/// <summary>Adds Sunset/Deprecation headers to legacy WorkOrder REST routes.</summary>
|
||||||
|
public class LegacyDeprecationMiddleware
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"GetWorkOrderList",
|
||||||
|
"AddWorkorder",
|
||||||
|
"EditWorkorder",
|
||||||
|
"ChangeStatus",
|
||||||
|
"ChangeAssignment",
|
||||||
|
"DeleteWorkorder",
|
||||||
|
"GetWorkorderById"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly LegacyEndpointOptions _options;
|
||||||
|
|
||||||
|
public LegacyDeprecationMiddleware(RequestDelegate next, IOptions<LegacyEndpointOptions> options)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
|
||||||
|
if (!_options.DeprecationEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var path = context.Request.Path.Value ?? "";
|
||||||
|
if (!path.Contains("/WorkOrder", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.Contains("/workorders", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!LegacyPathSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
context.Response.Headers["Deprecation"] = "true";
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.SunsetDate))
|
||||||
|
context.Response.Headers["Sunset"] = _options.SunsetDate;
|
||||||
|
context.Response.Headers["Link"] = "</api/workorders/board>; rel=\"successor-version\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace Api.SeaHavenIndustries.Options
|
||||||
|
{
|
||||||
|
public class LegacyEndpointOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "LegacyEndpoints";
|
||||||
|
|
||||||
|
public bool DeprecationEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ISO date returned in Sunset header (e.g. 2026-12-31).</summary>
|
||||||
|
public string? SunsetDate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace Api.SeaHavenIndustries.Options
|
||||||
|
{
|
||||||
|
public class SyncOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Sync";
|
||||||
|
|
||||||
|
/// <summary>When false, SyncController endpoints return 503 (post-Lambda cutover).</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
12
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace Api.SeaHavenIndustries.Options
|
||||||
|
{
|
||||||
|
public class WorkOrderIngestOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "WorkOrderIngest";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Shared secret for X-Ingest-Key header (Lambda / service accounts).</summary>
|
||||||
|
public string? ApiKey { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
public class AddWorkOrderTypeAndScheduledDateIndex : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL
|
||||||
|
ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
|
||||||
|
END
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM sys.indexes
|
||||||
|
WHERE name = 'IX_workOrders_ScheduledDate'
|
||||||
|
AND object_id = OBJECT_ID('workOrders'))
|
||||||
|
DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NOT NULL
|
||||||
|
ALTER TABLE workOrders DROP COLUMN WorkOrderType;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
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.AddColumn<int>(
|
||||||
|
name: "OperationalFlags",
|
||||||
|
table: "workOrders",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OperationalFlags",
|
||||||
|
table: "workOrders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Phase4_SearchIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_workOrders_SiteCode",
|
||||||
|
table: "workOrders",
|
||||||
|
column: "SiteCode",
|
||||||
|
filter: "[istemplate] = 0");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_workOrders_InternalWONumber",
|
||||||
|
table: "workOrders",
|
||||||
|
column: "InternalWONumber",
|
||||||
|
filter: "[istemplate] = 0");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
|
||||||
|
table: "workOrders",
|
||||||
|
columns: new[] { "LifecycleStatus", "ScheduledDate" },
|
||||||
|
filter: "[istemplate] = 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_workOrders_SiteCode",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_workOrders_InternalWONumber",
|
||||||
|
table: "workOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
|
||||||
|
table: "workOrders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Phase6_CompletionSlideOver : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Category",
|
||||||
|
table: "workOrderAttachments",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CompletionDocTemplates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
ServiceKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
WorkOrderType = table.Column<int>(type: "int", nullable: true),
|
||||||
|
TemplateUrl = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||||
|
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CompletionDocTemplates", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CompletionDocTemplates_ServiceKey_IsActive",
|
||||||
|
table: "CompletionDocTemplates",
|
||||||
|
columns: new[] { "ServiceKey", "IsActive" });
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE workOrders
|
||||||
|
SET DocStatus = 1
|
||||||
|
WHERE DocStatus IS NULL
|
||||||
|
AND SignOffAttachment IS NOT NULL
|
||||||
|
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CompletionDocTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Category",
|
||||||
|
table: "workOrderAttachments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs
Normal file
13
Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
public class CompletionDocTemplate : FullAuditEntity
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string ServiceKey { get; set; } = "";
|
||||||
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
|
public string TemplateUrl { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
7
SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
Normal file
7
SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace SeaHaven.DataServices.Board
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardConstants
|
||||||
|
{
|
||||||
|
public const int ClientSideThreshold = 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
Normal file
10
SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace SeaHaven.DataServices.DTOs
|
||||||
|
{
|
||||||
|
public record WorkOrderBoardQuery(
|
||||||
|
DateOnly ScheduledFrom,
|
||||||
|
DateOnly ScheduledTo,
|
||||||
|
string? Search,
|
||||||
|
IReadOnlyList<string>? AssigneeIds,
|
||||||
|
IReadOnlyList<string>? DbStatuses,
|
||||||
|
int? LocationId);
|
||||||
|
}
|
||||||
20
SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
Normal file
20
SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace SeaHaven.DataServices.DTOs
|
||||||
|
{
|
||||||
|
public record WorkOrderBoardRowData(
|
||||||
|
int Id,
|
||||||
|
string? WoNumber,
|
||||||
|
string? SiteCode,
|
||||||
|
string? LocationLabel,
|
||||||
|
DateTime? ScheduledDate,
|
||||||
|
DateTime? ScheduledStart,
|
||||||
|
DateTime? DueDate,
|
||||||
|
string? WorkOrderType,
|
||||||
|
string? ServiceType,
|
||||||
|
string? AssigneeId,
|
||||||
|
string? AssigneeName,
|
||||||
|
string? VendorCompany,
|
||||||
|
string? VendorTechnician,
|
||||||
|
string? Status,
|
||||||
|
string? PocName,
|
||||||
|
string? PocPhone);
|
||||||
|
}
|
||||||
9
SeaHaven.DataServices/Exceptions/BoardQueryException.cs
Normal file
9
SeaHaven.DataServices/Exceptions/BoardQueryException.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.DataServices.Exceptions
|
||||||
|
{
|
||||||
|
public class BoardQueryException : Exception
|
||||||
|
{
|
||||||
|
public BoardQueryException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal file
106
SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardProjection
|
||||||
|
{
|
||||||
|
public static async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool isUnscheduled)
|
||||||
|
{
|
||||||
|
var rows = await query
|
||||||
|
.Select(w => new
|
||||||
|
{
|
||||||
|
w.Id,
|
||||||
|
w.InternalWONumber,
|
||||||
|
w.RescheduleCount,
|
||||||
|
w.CarriedOver,
|
||||||
|
w.WorkOrderType,
|
||||||
|
w.SiteCode,
|
||||||
|
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||||
|
Poc = w.WorkOrderContacts!
|
||||||
|
.OrderBy(c => c.Id)
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
Name = c.POC != null
|
||||||
|
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
|
||||||
|
: null,
|
||||||
|
c.POC!.PhoneNumber,
|
||||||
|
c.Notes
|
||||||
|
})
|
||||||
|
.FirstOrDefault(),
|
||||||
|
w.LifecycleStatus,
|
||||||
|
w.LegacyStatus,
|
||||||
|
w.Status,
|
||||||
|
w.AssignTo,
|
||||||
|
w.AssignToUser!.FirstName,
|
||||||
|
w.AssignToUser!.LastName,
|
||||||
|
w.AssignToUser!.Initials,
|
||||||
|
w.AssignToUser!.Color,
|
||||||
|
w.DueDate,
|
||||||
|
w.ScheduledDate,
|
||||||
|
w.ScheduledStart,
|
||||||
|
w.ScheduledEnd,
|
||||||
|
w.TargetWeek,
|
||||||
|
w.ScheduleWeekOnly,
|
||||||
|
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
|
||||||
|
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
? w.PrimaryDispatch.Vendor.CompanyName
|
||||||
|
: null,
|
||||||
|
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
? w.PrimaryDispatch.Vendor.ContactName
|
||||||
|
: null,
|
||||||
|
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
? w.PrimaryDispatch.Vendor.Phone
|
||||||
|
: null,
|
||||||
|
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
|
||||||
|
w.Trade,
|
||||||
|
w.Problem,
|
||||||
|
w.DocStatus,
|
||||||
|
w.PrimaryDispatchId,
|
||||||
|
w.RowVersion,
|
||||||
|
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return rows.Select(w => new WorkOrderBoardRawRow(
|
||||||
|
w.Id,
|
||||||
|
w.InternalWONumber,
|
||||||
|
w.RescheduleCount,
|
||||||
|
w.CarriedOver,
|
||||||
|
w.WorkOrderType,
|
||||||
|
w.SiteCode,
|
||||||
|
w.LocationName,
|
||||||
|
w.Poc?.Name,
|
||||||
|
w.Poc?.PhoneNumber,
|
||||||
|
w.Poc?.Notes,
|
||||||
|
w.LifecycleStatus,
|
||||||
|
w.LegacyStatus ?? w.Status,
|
||||||
|
w.AssignTo,
|
||||||
|
w.FirstName,
|
||||||
|
w.LastName,
|
||||||
|
w.Initials,
|
||||||
|
w.Color,
|
||||||
|
w.DueDate,
|
||||||
|
w.ScheduledDate,
|
||||||
|
w.ScheduledStart,
|
||||||
|
w.ScheduledEnd,
|
||||||
|
w.TargetWeek,
|
||||||
|
w.ScheduleWeekOnly,
|
||||||
|
w.VendorId,
|
||||||
|
w.VendorName,
|
||||||
|
w.TechName,
|
||||||
|
w.TechPhone,
|
||||||
|
w.DispatchApptDate,
|
||||||
|
w.Trade ?? w.Problem,
|
||||||
|
w.Problem,
|
||||||
|
w.DocStatus,
|
||||||
|
w.PrimaryDispatchId,
|
||||||
|
w.RowVersion,
|
||||||
|
w.DispatchRowVersion,
|
||||||
|
isUnscheduled)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
138
SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardQueryFilters
|
||||||
|
{
|
||||||
|
public static IQueryable<WorkOrder> ApplyBaseScope(IQueryable<WorkOrder> query)
|
||||||
|
=> query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyDispatcherFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<string>? dispatchers,
|
||||||
|
bool myWorkOrders,
|
||||||
|
string? currentUserId)
|
||||||
|
{
|
||||||
|
if (myWorkOrders && !string.IsNullOrEmpty(currentUserId))
|
||||||
|
return query.Where(w => w.AssignTo == currentUserId);
|
||||||
|
|
||||||
|
if (dispatchers == null || dispatchers.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var ids = dispatchers
|
||||||
|
.Where(d => d != "__unassigned__")
|
||||||
|
.ToList();
|
||||||
|
var includeUnassigned = dispatchers.Contains("__unassigned__");
|
||||||
|
|
||||||
|
if (includeUnassigned && ids.Count == 0)
|
||||||
|
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
|
||||||
|
|
||||||
|
if (includeUnassigned && ids.Count > 0)
|
||||||
|
{
|
||||||
|
return query.Where(w =>
|
||||||
|
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyTypeFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<WorkOrderType>? types)
|
||||||
|
{
|
||||||
|
if (types == null || types.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplySiteFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<string>? sites)
|
||||||
|
{
|
||||||
|
if (sites == null || sites.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var normalized = sites
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Select(s => s.Trim())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (normalized.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyStatusFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<LifecycleStatus>? statuses)
|
||||||
|
{
|
||||||
|
if (statuses == null || statuses.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyDocStatusFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<DocStatus>? docStatuses)
|
||||||
|
{
|
||||||
|
if (docStatuses == null || docStatuses.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyVendorFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<int>? vendorIds)
|
||||||
|
{
|
||||||
|
if (vendorIds == null || vendorIds.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyPmTypeFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
IReadOnlyList<string>? pmTypes)
|
||||||
|
{
|
||||||
|
if (pmTypes == null || pmTypes.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var normalized = pmTypes
|
||||||
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||||
|
.Select(p => p.Trim().ToLower())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (normalized.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
normalized.Any(pm =>
|
||||||
|
(w.Trade ?? "").ToLower().Contains(pm)
|
||||||
|
|| (w.Problem ?? "").ToLower().Contains(pm)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> ApplyDateRangeFilter(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
DateOnly dateFrom,
|
||||||
|
DateOnly dateTo)
|
||||||
|
{
|
||||||
|
var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date;
|
||||||
|
var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date;
|
||||||
|
|
||||||
|
return query.Where(w =>
|
||||||
|
(w.ScheduledDate != null
|
||||||
|
&& w.ScheduledDate.Value.Date >= fromDate
|
||||||
|
&& w.ScheduledDate.Value.Date <= toDate)
|
||||||
|
|| (w.ScheduleWeekOnly == true
|
||||||
|
&& w.TargetWeek != null
|
||||||
|
&& w.TargetWeek >= dateFrom
|
||||||
|
&& w.TargetWeek <= dateTo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
Normal file
47
SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardSearchFilter
|
||||||
|
{
|
||||||
|
public static string? NormalizeSearch(string? search)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(search))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var trimmed = search.Trim();
|
||||||
|
return trimmed.Length < 2 ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<WorkOrder> Apply(IQueryable<WorkOrder> query, string? search)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeSearch(search);
|
||||||
|
if (normalized == null)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var s = normalized.ToLower();
|
||||||
|
return query.Where(w =>
|
||||||
|
(w.SiteCode ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|
||||||
|
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|
||||||
|
|| (w.Trade ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.Problem ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|
||||||
|
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|
||||||
|
|| (w.Status ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.InProgress && ("in progress".Contains(s) || "inprogress".Contains(s)))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.Complete && "complete".Contains(s))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s)))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.OnHold && ("on hold".Contains(s) || "onhold".Contains(s)))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.PendingVendor && ("pending vendor".Contains(s) || "pendingvendor".Contains(s)))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.PendingApproval && ("pending approval".Contains(s) || "pendingapproval".Contains(s)))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.NeedsReview && ("needs review".Contains(s) || "needsreview".Contains(s)))
|
||||||
|
|| (w.LifecycleStatus == LifecycleStatus.Closed && "closed".Contains(s)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public CompletionDocTemplateDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
|
||||||
|
{
|
||||||
|
var query = _context.CompletionDocTemplates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.IsActive && t.IsDeleted != true);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(serviceKey))
|
||||||
|
query = query.Where(t => t.ServiceKey == serviceKey);
|
||||||
|
|
||||||
|
if (workOrderType.HasValue)
|
||||||
|
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderBy(t => t.Name)
|
||||||
|
.Select(MapRow)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
|
||||||
|
{
|
||||||
|
return await _context.CompletionDocTemplates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.Id == id && t.IsDeleted != true)
|
||||||
|
.Select(MapRow)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(trade))
|
||||||
|
{
|
||||||
|
var byTrade = await _context.CompletionDocTemplates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade)
|
||||||
|
.OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1)
|
||||||
|
.Select(MapRow)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (byTrade != null)
|
||||||
|
return byTrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workOrderType.HasValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await _context.CompletionDocTemplates
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType)
|
||||||
|
.Select(MapRow)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
|
||||||
|
{
|
||||||
|
template.CreatedDate = DateTime.UtcNow;
|
||||||
|
await _context.CompletionDocTemplates.AddAsync(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(CompletionDocTemplate template)
|
||||||
|
{
|
||||||
|
template.LastModificationTime = DateTime.UtcNow;
|
||||||
|
_context.CompletionDocTemplates.Update(template);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(int id)
|
||||||
|
{
|
||||||
|
var entity = await _context.CompletionDocTemplates.FindAsync(id);
|
||||||
|
if (entity == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
entity.IsDeleted = true;
|
||||||
|
entity.DeletionTime = DateTime.UtcNow;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompletionDocTemplateRow MapRow(CompletionDocTemplate t)
|
||||||
|
=> new(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public WorkOrderAdvancedSearchDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query)
|
||||||
|
{
|
||||||
|
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDispatcherFilter(
|
||||||
|
baseQuery, query.Dispatchers, query.MyWorkOrders, query.CurrentUserId);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyStatusFilter(baseQuery, query.Statuses);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyPmTypeFilter(baseQuery, query.PmTypes);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyVendorFilter(baseQuery, query.VendorIds);
|
||||||
|
baseQuery = WorkOrderBoardQueryFilters.ApplyDocStatusFilter(baseQuery, query.DocStatuses);
|
||||||
|
|
||||||
|
var normalizedSearch = WorkOrderBoardSearchFilter.NormalizeSearch(query.Search);
|
||||||
|
if (normalizedSearch != null)
|
||||||
|
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
|
||||||
|
|
||||||
|
var totalCount = await baseQuery.CountAsync();
|
||||||
|
|
||||||
|
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
|
||||||
|
var paged = sorted
|
||||||
|
.Skip((query.Page - 1) * query.PageSize)
|
||||||
|
.Take(query.PageSize);
|
||||||
|
|
||||||
|
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);
|
||||||
|
return new WorkOrderAdvancedSearchResult(rows, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)
|
||||||
|
{
|
||||||
|
var descending = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var field = (sortBy ?? "scheduledDate").Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
return field switch
|
||||||
|
{
|
||||||
|
"wonumber" or "woNumber" => descending
|
||||||
|
? query.OrderByDescending(w => w.InternalWONumber)
|
||||||
|
: query.OrderBy(w => w.InternalWONumber),
|
||||||
|
"duedate" or "dueDate" => descending
|
||||||
|
? query.OrderByDescending(w => w.DueDate).ThenBy(w => w.InternalWONumber)
|
||||||
|
: query.OrderBy(w => w.DueDate).ThenBy(w => w.InternalWONumber),
|
||||||
|
_ => descending
|
||||||
|
? query.OrderByDescending(w => w.ScheduledDate).ThenByDescending(w => w.InternalWONumber)
|
||||||
|
: query.OrderBy(w => w.ScheduledDate).ThenBy(w => w.InternalWONumber)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardDataService : IWorkOrderBoardDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public WorkOrderBoardDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query)
|
||||||
|
{
|
||||||
|
var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue);
|
||||||
|
var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue);
|
||||||
|
|
||||||
|
var baseQuery = _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
|
baseQuery = ApplyDispatcherFilter(baseQuery, query);
|
||||||
|
baseQuery = ApplyTypeFilter(baseQuery, query);
|
||||||
|
|
||||||
|
var scheduledBase = baseQuery.Where(w =>
|
||||||
|
(w.ScheduledDate != null
|
||||||
|
&& w.ScheduledDate.Value.Date >= weekStartDate.Date
|
||||||
|
&& w.ScheduledDate.Value.Date <= weekEndDate.Date)
|
||||||
|
|| (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart));
|
||||||
|
|
||||||
|
var unscheduledBase = baseQuery.Where(w =>
|
||||||
|
w.ScheduledDate == null && !IsTerminalExpression(w));
|
||||||
|
|
||||||
|
var scheduledTotal = await scheduledBase.CountAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||||
|
{
|
||||||
|
scheduledBase = ApplySearchFilter(scheduledBase, query.Search);
|
||||||
|
unscheduledBase = ApplySearchFilter(unscheduledBase, query.Search);
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduledRows = await ProjectRows(scheduledBase, isUnscheduled: false);
|
||||||
|
var unscheduledRows = await ProjectRows(unscheduledBase, isUnscheduled: true);
|
||||||
|
|
||||||
|
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync()
|
||||||
|
{
|
||||||
|
return await _context.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => u.IsDeleted != true)
|
||||||
|
.OrderBy(u => u.FirstName)
|
||||||
|
.ThenBy(u => u.LastName)
|
||||||
|
.Select(u => new DispatcherLookupRow(
|
||||||
|
u.Id,
|
||||||
|
u.FirstName ?? "",
|
||||||
|
u.LastName,
|
||||||
|
u.Initials,
|
||||||
|
u.Color))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<WorkOrder> ApplyDispatcherFilter(IQueryable<WorkOrder> query, WorkOrderBoardQuery boardQuery)
|
||||||
|
{
|
||||||
|
if (boardQuery.MyWorkOrders && !string.IsNullOrEmpty(boardQuery.CurrentUserId))
|
||||||
|
return query.Where(w => w.AssignTo == boardQuery.CurrentUserId);
|
||||||
|
|
||||||
|
if (boardQuery.Dispatchers == null || boardQuery.Dispatchers.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
var ids = boardQuery.Dispatchers
|
||||||
|
.Where(d => d != "__unassigned__")
|
||||||
|
.ToList();
|
||||||
|
var includeUnassigned = boardQuery.Dispatchers.Contains("__unassigned__");
|
||||||
|
|
||||||
|
if (includeUnassigned && ids.Count == 0)
|
||||||
|
return query.Where(w => w.AssignTo == null || w.AssignTo == "");
|
||||||
|
|
||||||
|
if (includeUnassigned && ids.Count > 0)
|
||||||
|
{
|
||||||
|
return query.Where(w =>
|
||||||
|
w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<WorkOrder> ApplyTypeFilter(IQueryable<WorkOrder> query, WorkOrderBoardQuery boardQuery)
|
||||||
|
{
|
||||||
|
if (boardQuery.Types == null || boardQuery.Types.Count == 0)
|
||||||
|
return query;
|
||||||
|
|
||||||
|
return query.Where(w => w.WorkOrderType != null && boardQuery.Types.Contains(w.WorkOrderType.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<WorkOrder> ApplySearchFilter(IQueryable<WorkOrder> query, string search)
|
||||||
|
{
|
||||||
|
var s = search.Trim().ToLower();
|
||||||
|
return query.Where(w =>
|
||||||
|
(w.SiteCode ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|
||||||
|
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|
||||||
|
|| (w.Trade ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.Problem ?? "").ToLower().Contains(s)
|
||||||
|
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|
||||||
|
|| (w.Status ?? "").ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Linq.Expressions.Expression<Func<WorkOrder, bool>> IsTerminalExpression(WorkOrder w)
|
||||||
|
=> w => w.LifecycleStatus == LifecycleStatus.Complete
|
||||||
|
|| w.LifecycleStatus == LifecycleStatus.Canceled
|
||||||
|
|| w.LifecycleStatus == LifecycleStatus.Closed;
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRows(
|
||||||
|
IQueryable<WorkOrder> query,
|
||||||
|
bool isUnscheduled)
|
||||||
|
{
|
||||||
|
var rows = await query
|
||||||
|
.Include(w => w.AssignToUser)
|
||||||
|
.Include(w => w.Locations)
|
||||||
|
.Include(w => w.PrimaryDispatch!)
|
||||||
|
.ThenInclude(d => d!.Vendor)
|
||||||
|
.Include(w => w.WorkOrderContacts!)
|
||||||
|
.ThenInclude(c => c.POC)
|
||||||
|
.OrderBy(w => w.ScheduledDate)
|
||||||
|
.ThenBy(w => w.InternalWONumber)
|
||||||
|
.Select(w => new
|
||||||
|
{
|
||||||
|
w.Id,
|
||||||
|
w.InternalWONumber,
|
||||||
|
w.RescheduleCount,
|
||||||
|
w.CarriedOver,
|
||||||
|
w.WorkOrderType,
|
||||||
|
w.SiteCode,
|
||||||
|
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||||
|
Poc = w.WorkOrderContacts!
|
||||||
|
.OrderBy(c => c.Id)
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
Name = c.POC != null
|
||||||
|
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
|
||||||
|
: null,
|
||||||
|
c.POC!.PhoneNumber,
|
||||||
|
c.Notes
|
||||||
|
})
|
||||||
|
.FirstOrDefault(),
|
||||||
|
w.LifecycleStatus,
|
||||||
|
w.LegacyStatus,
|
||||||
|
w.Status,
|
||||||
|
w.AssignTo,
|
||||||
|
w.AssignToUser!.FirstName,
|
||||||
|
w.AssignToUser!.LastName,
|
||||||
|
w.AssignToUser!.Initials,
|
||||||
|
w.AssignToUser!.Color,
|
||||||
|
w.DueDate,
|
||||||
|
w.ScheduledDate,
|
||||||
|
w.ScheduledStart,
|
||||||
|
w.ScheduledEnd,
|
||||||
|
w.TargetWeek,
|
||||||
|
w.ScheduleWeekOnly,
|
||||||
|
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
|
||||||
|
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
? w.PrimaryDispatch.Vendor.CompanyName
|
||||||
|
: null,
|
||||||
|
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
? w.PrimaryDispatch.Vendor.ContactName
|
||||||
|
: null,
|
||||||
|
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
|
? w.PrimaryDispatch.Vendor.Phone
|
||||||
|
: null,
|
||||||
|
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
|
||||||
|
w.Trade,
|
||||||
|
w.Problem,
|
||||||
|
w.DocStatus,
|
||||||
|
w.RowVersion
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return rows.Select(w => new WorkOrderBoardRawRow(
|
||||||
|
w.Id,
|
||||||
|
w.InternalWONumber,
|
||||||
|
w.RescheduleCount,
|
||||||
|
w.CarriedOver,
|
||||||
|
w.WorkOrderType,
|
||||||
|
w.SiteCode,
|
||||||
|
w.LocationName,
|
||||||
|
w.Poc?.Name,
|
||||||
|
w.Poc?.PhoneNumber,
|
||||||
|
w.Poc?.Notes,
|
||||||
|
w.LifecycleStatus,
|
||||||
|
w.LegacyStatus ?? w.Status,
|
||||||
|
w.AssignTo,
|
||||||
|
w.FirstName,
|
||||||
|
w.LastName,
|
||||||
|
w.Initials,
|
||||||
|
w.Color,
|
||||||
|
w.DueDate,
|
||||||
|
w.ScheduledDate,
|
||||||
|
w.ScheduledStart,
|
||||||
|
w.ScheduledEnd,
|
||||||
|
w.TargetWeek,
|
||||||
|
w.ScheduleWeekOnly,
|
||||||
|
w.VendorId,
|
||||||
|
w.VendorName,
|
||||||
|
w.TechName,
|
||||||
|
w.TechPhone,
|
||||||
|
w.DispatchApptDate,
|
||||||
|
w.Trade ?? w.Problem,
|
||||||
|
w.Problem,
|
||||||
|
w.DocStatus,
|
||||||
|
w.RowVersion,
|
||||||
|
isUnscheduled)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderDetailDataService : IWorkOrderDetailDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public WorkOrderDetailDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> ExistsAsync(int workOrderId)
|
||||||
|
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||||
|
.AnyAsync(w => w.Id == workOrderId);
|
||||||
|
|
||||||
|
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||||
|
.Where(w => w.Id == workOrderId)
|
||||||
|
.Select(w => new WorkOrderDetailExtendedFields(
|
||||||
|
w.Description,
|
||||||
|
w.Trade,
|
||||||
|
w.Problem,
|
||||||
|
w.SubTrade,
|
||||||
|
w.OriginalWeek,
|
||||||
|
w.OriginalDate,
|
||||||
|
w.CompletedDate,
|
||||||
|
w.SignOffName,
|
||||||
|
w.SignOffAttachment,
|
||||||
|
w.SignOffSignature))
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
return await _context.Comments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true)
|
||||||
|
.OrderByDescending(c => c.CreatedDate)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null)
|
||||||
|
{
|
||||||
|
var query = _context.WorkOrderAuditLogs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.WorkOrderId == workOrderId)
|
||||||
|
.OrderByDescending(a => a.CreatedAt);
|
||||||
|
|
||||||
|
if (limit.HasValue)
|
||||||
|
return await query.Take(limit.Value).ToListAsync();
|
||||||
|
|
||||||
|
return await query.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
return await _context.workOrderAttachments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
|
||||||
|
.OrderByDescending(a => a.CreatedDate)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
return await _context.DispatchSignoffs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true)
|
||||||
|
.OrderByDescending(s => s.SignedAt)
|
||||||
|
.Select(s => new DispatchSignoffRow(
|
||||||
|
s.DispatchId,
|
||||||
|
s.SignoffType,
|
||||||
|
s.Name,
|
||||||
|
s.SignedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
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.Complete,
|
||||||
|
LifecycleStatus.Canceled,
|
||||||
|
LifecycleStatus.Closed
|
||||||
|
};
|
||||||
|
|
||||||
|
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,9 @@
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderAdvancedSearchDataService
|
||||||
|
{
|
||||||
|
Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardDataService
|
||||||
|
{
|
||||||
|
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
|
||||||
|
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderDetailDataService
|
||||||
|
{
|
||||||
|
Task<bool> ExistsAsync(int workOrderId);
|
||||||
|
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
|
||||||
|
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
|
||||||
|
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
|
||||||
|
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
|
||||||
|
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
|
||||||
|
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICompletionDocTemplateDataService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||||
|
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
|
||||||
|
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
|
||||||
|
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
|
||||||
|
Task UpdateAsync(CompletionDocTemplate template);
|
||||||
|
Task<bool> DeleteAsync(int id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public record PrimaryDispatchProjection(
|
||||||
|
int WorkOrderId,
|
||||||
|
int? DispatchId,
|
||||||
|
string? VendorName,
|
||||||
|
DateTime? ApptDate,
|
||||||
|
string? DispatchStatus);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public record WorkOrderAdvancedSearchQuery(
|
||||||
|
string? Search,
|
||||||
|
DateOnly DateFrom,
|
||||||
|
DateOnly DateTo,
|
||||||
|
IReadOnlyList<string>? Sites,
|
||||||
|
IReadOnlyList<WorkOrderType>? Types,
|
||||||
|
IReadOnlyList<string>? Dispatchers,
|
||||||
|
IReadOnlyList<LifecycleStatus>? Statuses,
|
||||||
|
IReadOnlyList<string>? PmTypes,
|
||||||
|
IReadOnlyList<int>? VendorIds,
|
||||||
|
IReadOnlyList<DocStatus>? DocStatuses,
|
||||||
|
bool MyWorkOrders,
|
||||||
|
string? CurrentUserId,
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
string SortBy,
|
||||||
|
string SortDir);
|
||||||
|
|
||||||
|
public record WorkOrderAdvancedSearchResult(
|
||||||
|
IReadOnlyList<WorkOrderBoardRawRow> Rows,
|
||||||
|
int TotalCount);
|
||||||
|
}
|
||||||
60
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
60
SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public record WorkOrderBoardQuery(
|
||||||
|
DateOnly WeekStart,
|
||||||
|
DateOnly WeekEnd,
|
||||||
|
IReadOnlyList<string>? Dispatchers,
|
||||||
|
bool MyWorkOrders,
|
||||||
|
IReadOnlyList<WorkOrderType>? Types,
|
||||||
|
string? Search,
|
||||||
|
string? CurrentUserId);
|
||||||
|
|
||||||
|
public record WorkOrderBoardRawRow(
|
||||||
|
int Id,
|
||||||
|
string? InternalWONumber,
|
||||||
|
int RescheduleCount,
|
||||||
|
int CarriedOver,
|
||||||
|
WorkOrderType? WorkOrderType,
|
||||||
|
string? SiteCode,
|
||||||
|
string? LocationName,
|
||||||
|
string? PocName,
|
||||||
|
string? PocPhone,
|
||||||
|
string? PocNotes,
|
||||||
|
LifecycleStatus? LifecycleStatus,
|
||||||
|
string? LegacyStatus,
|
||||||
|
string? AssignTo,
|
||||||
|
string? DispatcherFirstName,
|
||||||
|
string? DispatcherLastName,
|
||||||
|
string? Initials,
|
||||||
|
string? Color,
|
||||||
|
DateTime? DueDate,
|
||||||
|
DateTime? ScheduledDate,
|
||||||
|
DateTime? ScheduledStart,
|
||||||
|
DateTime? ScheduledEnd,
|
||||||
|
DateOnly? TargetWeek,
|
||||||
|
bool? ScheduleWeekOnly,
|
||||||
|
int? VendorId,
|
||||||
|
string? VendorName,
|
||||||
|
string? TechName,
|
||||||
|
string? TechPhone,
|
||||||
|
DateTime? DispatchApptDate,
|
||||||
|
string? Trade,
|
||||||
|
string? Problem,
|
||||||
|
DocStatus? DocStatus,
|
||||||
|
byte[]? RowVersion,
|
||||||
|
bool IsUnscheduled);
|
||||||
|
|
||||||
|
public record WorkOrderBoardQueryResult(
|
||||||
|
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
|
||||||
|
IReadOnlyList<WorkOrderBoardRawRow> UnscheduledRows,
|
||||||
|
int ScheduledTotalBeforeSearch);
|
||||||
|
|
||||||
|
public record DispatcherLookupRow(
|
||||||
|
string Id,
|
||||||
|
string FirstName,
|
||||||
|
string? LastName,
|
||||||
|
string? Initials,
|
||||||
|
string? Color);
|
||||||
|
}
|
||||||
30
SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
Normal file
30
SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public record WorkOrderDetailExtendedFields(
|
||||||
|
string? Description,
|
||||||
|
string? Trade,
|
||||||
|
string? Problem,
|
||||||
|
string? SubTrade,
|
||||||
|
DateOnly? OriginalWeek,
|
||||||
|
DateOnly? OriginalDate,
|
||||||
|
DateTime? CompletedDate,
|
||||||
|
string? SignOffName,
|
||||||
|
string? SignOffAttachment,
|
||||||
|
string? SignOffSignature);
|
||||||
|
|
||||||
|
public record DispatchSignoffRow(
|
||||||
|
int DispatchId,
|
||||||
|
string? SignoffType,
|
||||||
|
string? Name,
|
||||||
|
DateTime? SignedAt);
|
||||||
|
|
||||||
|
public record CompletionDocTemplateRow(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string ServiceKey,
|
||||||
|
WorkOrderType? WorkOrderType,
|
||||||
|
string TemplateUrl,
|
||||||
|
bool IsActive);
|
||||||
|
}
|
||||||
8
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
8
SeaHaven.Services/Board/WorkOrderBoardConstants.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace SeaHaven.Services.Board
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardConstants
|
||||||
|
{
|
||||||
|
public const int MaxWindowDays = 90;
|
||||||
|
public const int ClientSideThreshold = 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs
Normal file
25
SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Board
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handoff helper for GetWorkOrdersBoard — expands canonical status[] query params to DB values.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkOrderBoardStatusFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Example usage in GetWorkOrdersBoardAsync:
|
||||||
|
/// <code>
|
||||||
|
/// var dbStatuses = WorkOrderBoardStatusFilter.Resolve(statusFilter, statusMapper);
|
||||||
|
/// if (dbStatuses.Count > 0)
|
||||||
|
/// query = query.Where(w => dbStatuses.Contains(w.Status));
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string> Resolve(
|
||||||
|
IEnumerable<string>? canonicalStatuses,
|
||||||
|
IWorkOrderStatusMapper statusMapper)
|
||||||
|
{
|
||||||
|
return statusMapper.ExpandCanonicalFilters(canonicalStatuses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
Normal file
73
SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.DTOs
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardQueryDto
|
||||||
|
{
|
||||||
|
public DateOnly WeekStart { get; set; }
|
||||||
|
public DateOnly? WeekEnd { get; set; }
|
||||||
|
public List<string>? Dispatchers { get; set; }
|
||||||
|
public bool MyWorkOrders { get; set; }
|
||||||
|
public List<WorkOrderType>? Types { get; set; }
|
||||||
|
public string? Search { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderBoardCountsDto
|
||||||
|
{
|
||||||
|
public int Returned { get; set; }
|
||||||
|
public int Total { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderBoardRowDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? WoNumber { get; set; }
|
||||||
|
public int RescheduleCount { get; set; }
|
||||||
|
public int CarriedOver { get; set; }
|
||||||
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
|
public bool IsPastDue { get; set; }
|
||||||
|
public string? SiteCode { get; set; }
|
||||||
|
public string? LocationName { get; set; }
|
||||||
|
public string? PocName { get; set; }
|
||||||
|
public string? PocPhone { get; set; }
|
||||||
|
public string? PocNotes { get; set; }
|
||||||
|
public LifecycleStatus? LifecycleStatus { get; set; }
|
||||||
|
public string? LifecycleStatusLabel { get; set; }
|
||||||
|
public string? LegacyStatus { get; set; }
|
||||||
|
public string? DispatcherId { get; set; }
|
||||||
|
public string? DispatcherName { get; set; }
|
||||||
|
public string? Initials { get; set; }
|
||||||
|
public string? Color { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public DateTime? ScheduledDate { get; set; }
|
||||||
|
public DateOnly? TargetWeek { get; set; }
|
||||||
|
public bool? ScheduleWeekOnly { get; set; }
|
||||||
|
public string? DayGroup { get; set; }
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? TechName { get; set; }
|
||||||
|
public string? TechPhone { get; set; }
|
||||||
|
public DateTime? ApptDate { get; set; }
|
||||||
|
public string? ApptTime { get; set; }
|
||||||
|
public string? Pm { get; set; }
|
||||||
|
public DocStatus? DocStatus { get; set; }
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderBoardResponseDto
|
||||||
|
{
|
||||||
|
public DateOnly WeekStart { get; set; }
|
||||||
|
public DateOnly WeekEnd { get; set; }
|
||||||
|
public WorkOrderBoardCountsDto Counts { get; set; } = new();
|
||||||
|
public List<WorkOrderBoardRowDto> Unscheduled { get; set; } = new();
|
||||||
|
public List<WorkOrderBoardRowDto> Scheduled { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatcherLookupDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string? Initials { get; set; }
|
||||||
|
public string? Color { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
104
SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs
Normal file
104
SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.DTOs
|
||||||
|
{
|
||||||
|
public class WorkOrderDetailDto
|
||||||
|
{
|
||||||
|
public WorkOrderDetailInfoDto Info { get; set; } = new();
|
||||||
|
public WorkOrderCompletionDto Completion { get; set; } = new();
|
||||||
|
public List<WorkOrderCommentDto> Comments { get; set; } = new();
|
||||||
|
public List<WorkOrderAuditEntryDto> Audit { get; set; } = new();
|
||||||
|
public List<WorkOrderMediaFileDto> Media { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderDetailInfoDto : WorkOrderBoardRowDto
|
||||||
|
{
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Trade { get; set; }
|
||||||
|
public string? Problem { get; set; }
|
||||||
|
public string? SubTrade { get; set; }
|
||||||
|
public DateOnly? OriginalWeek { get; set; }
|
||||||
|
public DateOnly? OriginalDate { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompletionDocTemplateDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string ServiceKey { get; set; } = "";
|
||||||
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
|
public string TemplateUrl { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompletionDocTemplateCreateDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string ServiceKey { get; set; } = "";
|
||||||
|
public WorkOrderType? WorkOrderType { get; set; }
|
||||||
|
public string TemplateUrl { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderCompletionDto
|
||||||
|
{
|
||||||
|
public DocStatus? DocStatus { get; set; }
|
||||||
|
public CompletionDocTemplateDto? Template { get; set; }
|
||||||
|
public string? SignOffName { get; set; }
|
||||||
|
public string? SignOffAttachment { get; set; }
|
||||||
|
public string? SignOffSignature { get; set; }
|
||||||
|
public List<DispatchSignoffDto> DispatchSignoffs { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchSignoffDto
|
||||||
|
{
|
||||||
|
public int DispatchId { get; set; }
|
||||||
|
public string? SignoffType { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? SignedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderCommentDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? AuthorId { get; set; }
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
public string Time { get; set; } = "";
|
||||||
|
public string? Documents { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderCommentCreateDto
|
||||||
|
{
|
||||||
|
public string Text { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderAuditEntryDto
|
||||||
|
{
|
||||||
|
public string Type { get; set; } = "manual";
|
||||||
|
public string? DispatcherId { get; set; }
|
||||||
|
public string Action { get; set; } = "";
|
||||||
|
public string? FieldName { get; set; }
|
||||||
|
public string OldValue { get; set; } = "";
|
||||||
|
public string NewValue { get; set; } = "";
|
||||||
|
public string Time { get; set; } = "";
|
||||||
|
public int? DispatchId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public WorkOrderMediaCategory Category { get; set; }
|
||||||
|
public string Url { get; set; } = "";
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public string? UploadedAt { get; set; }
|
||||||
|
public bool IsLegacy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderCompletionDocUploadDto
|
||||||
|
{
|
||||||
|
public string? SignOffName { get; set; }
|
||||||
|
public string? SignOffSignature { get; set; }
|
||||||
|
public string? WorkOrderVersion { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
53
SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs
Normal file
53
SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
namespace SeaHaven.Services.DTOs
|
||||||
|
{
|
||||||
|
public sealed class WorkOrderIngestPayloadDto
|
||||||
|
{
|
||||||
|
public required string ExternalWorkOrderId { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? WoStatus { get; set; }
|
||||||
|
public string? Severity { get; set; }
|
||||||
|
public string? Customer { get; set; }
|
||||||
|
public string? SiteCode { get; set; }
|
||||||
|
public string? Building { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public DateTime? DateReported { get; set; }
|
||||||
|
public DateTime? ScheduledStart { get; set; }
|
||||||
|
public string? SourceEmailS3Key { get; set; }
|
||||||
|
public DateTime? CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderIngestBatchRequestDto
|
||||||
|
{
|
||||||
|
public List<WorkOrderIngestPayloadDto>? Items { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderIngestItemResultDto
|
||||||
|
{
|
||||||
|
public required string ExternalWorkOrderId { get; set; }
|
||||||
|
public int WorkOrderId { get; set; }
|
||||||
|
public bool Created { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderIngestBatchResultDto
|
||||||
|
{
|
||||||
|
public int Created { get; set; }
|
||||||
|
public int Updated { get; set; }
|
||||||
|
public List<WorkOrderIngestItemResultDto> Results { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkOrderOpsHealthDto
|
||||||
|
{
|
||||||
|
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||||
|
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||||
|
public string? LastWeekRolledError { get; set; }
|
||||||
|
public string? LastPastDueCacheError { get; set; }
|
||||||
|
public int SyncRejectedLast24h { get; set; }
|
||||||
|
public int FieldLockCount { get; set; }
|
||||||
|
public bool SyncEnabled { get; set; }
|
||||||
|
public bool IngestEnabled { get; set; }
|
||||||
|
public bool LegacyDeprecationEnabled { get; set; }
|
||||||
|
public string? LegacySunsetDate { get; set; }
|
||||||
|
public DateTime CheckedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
SeaHaven.Services/Exceptions/BoardQueryException.cs
Normal file
9
SeaHaven.Services/Exceptions/BoardQueryException.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace SeaHaven.Services.Exceptions
|
||||||
|
{
|
||||||
|
public class BoardQueryException : Exception
|
||||||
|
{
|
||||||
|
public BoardQueryException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs
Normal file
25
SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Exceptions
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardValidationException : Exception
|
||||||
|
{
|
||||||
|
public WorkOrderBoardValidationException(string code, string message) : base(message)
|
||||||
|
{
|
||||||
|
Code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Code { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderBoardConcurrencyException : Exception
|
||||||
|
{
|
||||||
|
public WorkOrderBoardConcurrencyException(WorkOrderBoardRowDto? currentState)
|
||||||
|
: base("The record was modified by another user. Refresh and retry.")
|
||||||
|
{
|
||||||
|
CurrentState = currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkOrderBoardRowDto? CurrentState { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
41
SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs
Normal file
41
SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderAuditProjection
|
||||||
|
{
|
||||||
|
public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log)
|
||||||
|
{
|
||||||
|
return new WorkOrderAuditEntryDto
|
||||||
|
{
|
||||||
|
Type = MapEventType(log.EventType),
|
||||||
|
DispatcherId = log.UserId,
|
||||||
|
Action = log.Action ?? "",
|
||||||
|
FieldName = log.FieldName,
|
||||||
|
OldValue = log.OldValue ?? "",
|
||||||
|
NewValue = log.NewValue ?? "",
|
||||||
|
Time = log.CreatedAt.ToString("o"),
|
||||||
|
DispatchId = log.DispatchId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<WorkOrderAuditEntryDto> ToDtos(IEnumerable<WorkOrderAuditLog> logs)
|
||||||
|
=> logs.Select(ToDto).ToList();
|
||||||
|
|
||||||
|
private static string MapEventType(string? eventType)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(eventType))
|
||||||
|
return "manual";
|
||||||
|
|
||||||
|
return eventType.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"manual" => "manual",
|
||||||
|
"system" => "system",
|
||||||
|
"sync" => "sync",
|
||||||
|
"vendor" => "vendor",
|
||||||
|
_ => eventType.ToLowerInvariant()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
Normal file
57
SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardApptTimeParser
|
||||||
|
{
|
||||||
|
public static bool TryParse(string? value, out TimeSpan? start, out TimeSpan? end, out string? error)
|
||||||
|
{
|
||||||
|
start = null;
|
||||||
|
end = null;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var trimmed = value.Trim();
|
||||||
|
var parts = trimmed.Split('–', '—', '-', StringComparison.Ordinal)
|
||||||
|
.Select(p => p.Trim())
|
||||||
|
.Where(p => !string.IsNullOrEmpty(p))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (parts.Length == 1)
|
||||||
|
{
|
||||||
|
if (!TimeSpan.TryParse(parts[0], out var single))
|
||||||
|
{
|
||||||
|
error = "Invalid appointment time format.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
start = single;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.Length == 2)
|
||||||
|
{
|
||||||
|
if (!TimeSpan.TryParse(parts[0], out var startTime) || !TimeSpan.TryParse(parts[1], out var endTime))
|
||||||
|
{
|
||||||
|
error = "Invalid appointment time range format.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
start = startTime;
|
||||||
|
end = endTime;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = "Invalid appointment time format.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTime? CombineDateAndTime(DateTime? date, TimeSpan? time)
|
||||||
|
{
|
||||||
|
if (!date.HasValue || !time.HasValue)
|
||||||
|
return date;
|
||||||
|
|
||||||
|
return date.Value.Date.Add(time.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs
Normal file
132
SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardFieldMutations
|
||||||
|
{
|
||||||
|
public static List<BoardFieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField = "AssignTo")
|
||||||
|
{
|
||||||
|
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
var old = workOrder.AssignTo;
|
||||||
|
var changes = new List<BoardFieldChange>();
|
||||||
|
|
||||||
|
if (old != normalized)
|
||||||
|
{
|
||||||
|
workOrder.AssignTo = normalized;
|
||||||
|
changes.Add(BoardFieldChange.AssignmentChanged(auditField, old, normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<BoardFieldChange> ApplyScheduledDate(
|
||||||
|
WorkOrder workOrder,
|
||||||
|
DateTime? parsed,
|
||||||
|
string auditField = "ScheduledDate",
|
||||||
|
bool incrementRescheduleOnChange = true)
|
||||||
|
{
|
||||||
|
var previousDate = workOrder.ScheduledDate;
|
||||||
|
var changes = new List<BoardFieldChange>();
|
||||||
|
var oldFormatted = FormatDate(previousDate);
|
||||||
|
var newFormatted = FormatDate(parsed);
|
||||||
|
|
||||||
|
if (oldFormatted == newFormatted)
|
||||||
|
return changes;
|
||||||
|
|
||||||
|
workOrder.ScheduledDate = parsed;
|
||||||
|
changes.Add(BoardFieldChange.Changed(auditField, oldFormatted, newFormatted));
|
||||||
|
|
||||||
|
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
|
||||||
|
{
|
||||||
|
workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value);
|
||||||
|
var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed);
|
||||||
|
if (originalWeek.HasValue)
|
||||||
|
workOrder.OriginalWeek = originalWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incrementRescheduleOnChange && WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed))
|
||||||
|
{
|
||||||
|
var oldCount = workOrder.RescheduleCount.ToString();
|
||||||
|
workOrder.RescheduleCount++;
|
||||||
|
changes.Add(BoardFieldChange.Changed(
|
||||||
|
WorkOrderBoardFieldNames.RescheduleCount,
|
||||||
|
oldCount,
|
||||||
|
workOrder.RescheduleCount.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||||
|
{
|
||||||
|
var changes = new List<BoardFieldChange>();
|
||||||
|
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo))
|
||||||
|
return changes;
|
||||||
|
|
||||||
|
var old = workOrder.LifecycleStatus?.ToString();
|
||||||
|
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||||
|
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
|
||||||
|
changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
|
||||||
|
{
|
||||||
|
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
|
||||||
|
|
||||||
|
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
|
||||||
|
var oldStart = FormatDateTime(workOrder.ScheduledStart);
|
||||||
|
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
|
|
||||||
|
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
|
||||||
|
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
|
||||||
|
|
||||||
|
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
||||||
|
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
|
var oldCombined = FormatApptRange(oldStart, oldEnd);
|
||||||
|
var newCombined = FormatApptRange(newStart, newEnd);
|
||||||
|
|
||||||
|
if (oldCombined == newCombined)
|
||||||
|
return BoardFieldChange.Unchanged("ApptTime", dispatch.Id);
|
||||||
|
|
||||||
|
return BoardFieldChange.Changed("ApptTime", oldCombined, newCombined, dispatch.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
|
||||||
|
{
|
||||||
|
workOrder.LifecycleStatus = LifecycleStatus.Incomplete;
|
||||||
|
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Incomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
|
||||||
|
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
|
||||||
|
private static string? FormatApptRange(string? start, string? end)
|
||||||
|
=> start == null && end == null ? null : $"{start}|{end}";
|
||||||
|
|
||||||
|
public sealed record BoardFieldChange(
|
||||||
|
string FieldName,
|
||||||
|
string? OldValue,
|
||||||
|
string? NewValue,
|
||||||
|
AuditActionType Action,
|
||||||
|
int? DispatchId,
|
||||||
|
bool HasChanged)
|
||||||
|
{
|
||||||
|
public static BoardFieldChange Unchanged(string fieldName, int? dispatchId = null)
|
||||||
|
=> new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false);
|
||||||
|
|
||||||
|
public static BoardFieldChange Changed(string fieldName, string? oldValue, string? newValue, int? dispatchId = null)
|
||||||
|
=> new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true);
|
||||||
|
|
||||||
|
public static BoardFieldChange StatusChanged(string fieldName, string? oldValue, string? newValue)
|
||||||
|
=> new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true);
|
||||||
|
|
||||||
|
public static BoardFieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue)
|
||||||
|
=> new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
Normal file
66
SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardFieldNames
|
||||||
|
{
|
||||||
|
public const string WoNumber = "woNumber";
|
||||||
|
public const string WorkOrderType = "workOrderType";
|
||||||
|
public const string SiteCode = "siteCode";
|
||||||
|
public const string LifecycleStatus = "lifecycleStatus";
|
||||||
|
public const string AssignTo = "assignTo";
|
||||||
|
public const string DueDate = "dueDate";
|
||||||
|
public const string ScheduledDate = "scheduledDate";
|
||||||
|
public const string TargetWeek = "targetWeek";
|
||||||
|
public const string ScheduleWeekOnly = "scheduleWeekOnly";
|
||||||
|
public const string VendorId = "vendorId";
|
||||||
|
public const string ApptDate = "apptDate";
|
||||||
|
public const string ApptTime = "apptTime";
|
||||||
|
public const string DocStatus = "docStatus";
|
||||||
|
public const string RescheduleCount = "RescheduleCount";
|
||||||
|
|
||||||
|
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
VendorId,
|
||||||
|
ApptDate,
|
||||||
|
ApptTime
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AllEditable = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
WoNumber,
|
||||||
|
WorkOrderType,
|
||||||
|
SiteCode,
|
||||||
|
LifecycleStatus,
|
||||||
|
AssignTo,
|
||||||
|
DueDate,
|
||||||
|
ScheduledDate,
|
||||||
|
TargetWeek,
|
||||||
|
ScheduleWeekOnly,
|
||||||
|
VendorId,
|
||||||
|
ApptDate,
|
||||||
|
ApptTime,
|
||||||
|
DocStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsEditable(string field) => AllEditable.Contains(field);
|
||||||
|
|
||||||
|
public static bool IsDispatchField(string field) => DispatchFields.Contains(field);
|
||||||
|
|
||||||
|
public static string ToAuditFieldName(string field) => field switch
|
||||||
|
{
|
||||||
|
WoNumber => "InternalWONumber",
|
||||||
|
WorkOrderType => "WorkOrderType",
|
||||||
|
SiteCode => "SiteCode",
|
||||||
|
LifecycleStatus => "LifecycleStatus",
|
||||||
|
AssignTo => "AssignTo",
|
||||||
|
DueDate => "DueDate",
|
||||||
|
ScheduledDate => "ScheduledDate",
|
||||||
|
TargetWeek => "TargetWeek",
|
||||||
|
ScheduleWeekOnly => "ScheduleWeekOnly",
|
||||||
|
VendorId => "VendorId",
|
||||||
|
ApptDate => "ApptDate",
|
||||||
|
ApptTime => "ApptTime",
|
||||||
|
DocStatus => "DocStatus",
|
||||||
|
_ => field
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
43
SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
Normal file
43
SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderBoardMutationRules
|
||||||
|
{
|
||||||
|
private static readonly HashSet<LifecycleStatus?> ReadOnlyStatuses = new()
|
||||||
|
{
|
||||||
|
LifecycleStatus.Canceled,
|
||||||
|
LifecycleStatus.Closed,
|
||||||
|
LifecycleStatus.Complete
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status);
|
||||||
|
|
||||||
|
public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue)
|
||||||
|
=> field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue;
|
||||||
|
|
||||||
|
public static bool ShouldAutoSchedule(
|
||||||
|
LifecycleStatus? status,
|
||||||
|
DateTime? scheduledDate,
|
||||||
|
string? assignTo)
|
||||||
|
=> status == LifecycleStatus.Incomplete
|
||||||
|
&& scheduledDate.HasValue
|
||||||
|
&& !string.IsNullOrWhiteSpace(assignTo);
|
||||||
|
|
||||||
|
public static bool IsReschedule(DateTime? previousDate, DateTime? newDate)
|
||||||
|
=> previousDate.HasValue
|
||||||
|
&& newDate.HasValue
|
||||||
|
&& previousDate.Value.Date != newDate.Value.Date;
|
||||||
|
|
||||||
|
public static DateOnly? ComputeOriginalWeek(DateTime? scheduledDate)
|
||||||
|
=> scheduledDate.HasValue
|
||||||
|
? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static DateTime GetWeekStartMonday(DateTime date)
|
||||||
|
{
|
||||||
|
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
||||||
|
return date.Date.AddDays(-diff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs
Normal file
23
SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderCommentProjection
|
||||||
|
{
|
||||||
|
public static WorkOrderCommentDto ToDto(Comments comment)
|
||||||
|
{
|
||||||
|
return new WorkOrderCommentDto
|
||||||
|
{
|
||||||
|
Id = comment.Id,
|
||||||
|
AuthorId = comment.UserId,
|
||||||
|
Text = comment.Commenttext ?? "",
|
||||||
|
Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"),
|
||||||
|
Documents = comment.Documents
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<WorkOrderCommentDto> ToDtos(IEnumerable<Comments> comments)
|
||||||
|
=> comments.Select(ToDto).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
Normal file
80
SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderDerivedFields
|
||||||
|
{
|
||||||
|
private static readonly HashSet<LifecycleStatus?> TerminalStatuses = new()
|
||||||
|
{
|
||||||
|
LifecycleStatus.Complete,
|
||||||
|
LifecycleStatus.Canceled,
|
||||||
|
LifecycleStatus.Closed
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsPastDue(
|
||||||
|
DateTime? scheduledDate,
|
||||||
|
LifecycleStatus? lifecycleStatus,
|
||||||
|
DateTime? utcNow = null)
|
||||||
|
{
|
||||||
|
if (!scheduledDate.HasValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (TerminalStatuses.Contains(lifecycleStatus))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var today = (utcNow ?? DateTime.UtcNow).Date;
|
||||||
|
return scheduledDate.Value.Date < today;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsTerminal(LifecycleStatus? lifecycleStatus)
|
||||||
|
=> TerminalStatuses.Contains(lifecycleStatus);
|
||||||
|
|
||||||
|
public static string? GetDayGroup(DateTime? scheduledDate)
|
||||||
|
{
|
||||||
|
if (!scheduledDate.HasValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return scheduledDate.Value.DayOfWeek switch
|
||||||
|
{
|
||||||
|
DayOfWeek.Monday => "monday",
|
||||||
|
DayOfWeek.Tuesday => "tuesday",
|
||||||
|
DayOfWeek.Wednesday => "wednesday",
|
||||||
|
DayOfWeek.Thursday => "thursday",
|
||||||
|
DayOfWeek.Friday => "friday",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? FormatApptTime(DateTime? start, DateTime? end)
|
||||||
|
{
|
||||||
|
if (!start.HasValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (end.HasValue)
|
||||||
|
return $"{start.Value:HH:mm} – {end.Value:HH:mm}";
|
||||||
|
|
||||||
|
return start.Value.ToString("HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetLifecycleStatusLabel(LifecycleStatus? status)
|
||||||
|
{
|
||||||
|
if (!status.HasValue)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
return status.Value switch
|
||||||
|
{
|
||||||
|
LifecycleStatus.Incomplete => "Incomplete",
|
||||||
|
LifecycleStatus.Scheduled => "Scheduled",
|
||||||
|
LifecycleStatus.InProgress => "In Progress",
|
||||||
|
LifecycleStatus.Complete => "Complete",
|
||||||
|
LifecycleStatus.Canceled => "Canceled",
|
||||||
|
LifecycleStatus.OnHold => "On Hold",
|
||||||
|
LifecycleStatus.PendingVendor => "Pending Vendor",
|
||||||
|
LifecycleStatus.PendingApproval => "Pending Approval",
|
||||||
|
LifecycleStatus.NeedsReview => "Needs Review",
|
||||||
|
LifecycleStatus.Closed => "Closed",
|
||||||
|
_ => status.Value.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs
Normal file
27
SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>DynamoDB / ingest field mapping shared by Sync and direct ingest.</summary>
|
||||||
|
public static class WorkOrderIngestFieldMapper
|
||||||
|
{
|
||||||
|
public static string? MapStatus(string? dynamoStatus)
|
||||||
|
{
|
||||||
|
return dynamoStatus switch
|
||||||
|
{
|
||||||
|
"new" => "Open",
|
||||||
|
"assigned" => "Open",
|
||||||
|
"in_progress" => "In Progress",
|
||||||
|
"on_hold" => "On Hold",
|
||||||
|
"completed" => "Done",
|
||||||
|
"cancelled" => "Cancelled",
|
||||||
|
"unknown" => "Open",
|
||||||
|
_ => string.IsNullOrWhiteSpace(dynamoStatus) ? null : "Open"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? MapSeverityToPriority(string? severity)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(severity)) return null;
|
||||||
|
return $"Sev {severity}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs
Normal file
82
SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderMediaProjection
|
||||||
|
{
|
||||||
|
public static List<WorkOrderMediaFileDto> ProjectAll(
|
||||||
|
WorkOrder workOrder,
|
||||||
|
IEnumerable<WorkOrderAttachments> attachments)
|
||||||
|
{
|
||||||
|
var media = new List<WorkOrderMediaFileDto>();
|
||||||
|
var attachmentId = -1;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(workOrder.BeforPhotoAttachment))
|
||||||
|
{
|
||||||
|
media.Add(new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = attachmentId--,
|
||||||
|
Category = WorkOrderMediaCategory.Before,
|
||||||
|
Url = workOrder.BeforPhotoAttachment,
|
||||||
|
FileName = ExtractFileName(workOrder.BeforPhotoAttachment),
|
||||||
|
IsLegacy = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(workOrder.AfterPhotoAttachment))
|
||||||
|
{
|
||||||
|
media.Add(new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = attachmentId--,
|
||||||
|
Category = WorkOrderMediaCategory.After,
|
||||||
|
Url = workOrder.AfterPhotoAttachment,
|
||||||
|
FileName = ExtractFileName(workOrder.AfterPhotoAttachment),
|
||||||
|
IsLegacy = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(workOrder.SignOffAttachment))
|
||||||
|
{
|
||||||
|
media.Add(new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = attachmentId--,
|
||||||
|
Category = WorkOrderMediaCategory.Completion,
|
||||||
|
Url = workOrder.SignOffAttachment,
|
||||||
|
FileName = ExtractFileName(workOrder.SignOffAttachment),
|
||||||
|
IsLegacy = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var attachment in attachments.Where(a => a.IsDeleted != true))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(attachment.Attachments))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
media.Add(new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = attachment.Id,
|
||||||
|
Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
|
||||||
|
Url = attachment.Attachments,
|
||||||
|
FileName = ExtractFileName(attachment.Attachments),
|
||||||
|
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
|
||||||
|
IsLegacy = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractFileName(string url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var lastSlash = url.LastIndexOf('/');
|
||||||
|
return lastSlash >= 0 && lastSlash < url.Length - 1
|
||||||
|
? Uri.UnescapeDataString(url[(lastSlash + 1)..])
|
||||||
|
: url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
20
SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public static class WorkOrderOperationalWeek
|
||||||
|
{
|
||||||
|
public static DateOnly GetMondayOfWeek(DateOnly date)
|
||||||
|
{
|
||||||
|
var daysFromMonday = ((int)date.DayOfWeek + 6) % 7;
|
||||||
|
return date.AddDays(-daysFromMonday);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow)
|
||||||
|
=> GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7);
|
||||||
|
|
||||||
|
public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday)
|
||||||
|
=> weekStartMonday.AddDays(4);
|
||||||
|
|
||||||
|
public static string BuildWeekCorrelationId(DateOnly sourceWeekStart)
|
||||||
|
=> $"week:{sourceWeekStart:yyyy-MM-dd}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
public enum WorkOrderAdvancedSearchDatePreset
|
||||||
|
{
|
||||||
|
ThisWeek,
|
||||||
|
LastWeek,
|
||||||
|
ThisMonth,
|
||||||
|
Last3Months,
|
||||||
|
NextWeek,
|
||||||
|
NextMonth,
|
||||||
|
Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorkOrderSearchDateRange(DateOnly From, DateOnly To);
|
||||||
|
|
||||||
|
public static class WorkOrderSearchDateRangeResolver
|
||||||
|
{
|
||||||
|
public static WorkOrderSearchDateRange Resolve(
|
||||||
|
WorkOrderAdvancedSearchDatePreset? preset,
|
||||||
|
DateOnly? dateFrom,
|
||||||
|
DateOnly? dateTo,
|
||||||
|
DateOnly? referenceDate = null)
|
||||||
|
{
|
||||||
|
var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var resolvedPreset = preset ?? WorkOrderAdvancedSearchDatePreset.ThisWeek;
|
||||||
|
|
||||||
|
if (resolvedPreset == WorkOrderAdvancedSearchDatePreset.Custom)
|
||||||
|
{
|
||||||
|
if (!dateFrom.HasValue || !dateTo.HasValue)
|
||||||
|
throw new ArgumentException("dateFrom and dateTo are required when datePreset is custom.");
|
||||||
|
|
||||||
|
if (dateTo.Value < dateFrom.Value)
|
||||||
|
throw new ArgumentException("dateTo must be on or after dateFrom.");
|
||||||
|
|
||||||
|
return new WorkOrderSearchDateRange(dateFrom.Value, dateTo.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedPreset switch
|
||||||
|
{
|
||||||
|
WorkOrderAdvancedSearchDatePreset.ThisWeek => ThisIsoWeek(today),
|
||||||
|
WorkOrderAdvancedSearchDatePreset.LastWeek => LastIsoWeek(today),
|
||||||
|
WorkOrderAdvancedSearchDatePreset.ThisMonth => ThisMonth(today),
|
||||||
|
WorkOrderAdvancedSearchDatePreset.Last3Months => Last3Months(today),
|
||||||
|
WorkOrderAdvancedSearchDatePreset.NextWeek => NextIsoWeek(today),
|
||||||
|
WorkOrderAdvancedSearchDatePreset.NextMonth => NextMonth(today),
|
||||||
|
_ => ThisIsoWeek(today)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateOnly GetIsoWeekStart(DateOnly date)
|
||||||
|
{
|
||||||
|
var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
|
||||||
|
return date.AddDays(-diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today)
|
||||||
|
{
|
||||||
|
var start = GetIsoWeekStart(today);
|
||||||
|
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today)
|
||||||
|
{
|
||||||
|
var start = GetIsoWeekStart(today).AddDays(-7);
|
||||||
|
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today)
|
||||||
|
{
|
||||||
|
var start = GetIsoWeekStart(today).AddDays(7);
|
||||||
|
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderSearchDateRange ThisMonth(DateOnly today)
|
||||||
|
{
|
||||||
|
var start = new DateOnly(today.Year, today.Month, 1);
|
||||||
|
var end = start.AddMonths(1).AddDays(-1);
|
||||||
|
return new WorkOrderSearchDateRange(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderSearchDateRange NextMonth(DateOnly today)
|
||||||
|
{
|
||||||
|
var start = new DateOnly(today.Year, today.Month, 1).AddMonths(1);
|
||||||
|
var end = start.AddMonths(1).AddDays(-1);
|
||||||
|
return new WorkOrderSearchDateRange(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderSearchDateRange Last3Months(DateOnly today)
|
||||||
|
{
|
||||||
|
var end = today;
|
||||||
|
var start = today.AddMonths(-3);
|
||||||
|
return new WorkOrderSearchDateRange(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService
|
||||||
|
{
|
||||||
|
private const int DefaultPageSize = 50;
|
||||||
|
private const int MaxPageSize = 100;
|
||||||
|
|
||||||
|
private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
|
||||||
|
|
||||||
|
public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService)
|
||||||
|
{
|
||||||
|
_searchDataService = searchDataService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
|
||||||
|
WorkOrderAdvancedSearchQueryDto query,
|
||||||
|
string? currentUserId)
|
||||||
|
{
|
||||||
|
var page = query.Page < 1 ? 1 : query.Page;
|
||||||
|
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
|
||||||
|
|
||||||
|
var dateRange = WorkOrderSearchDateRangeResolver.Resolve(
|
||||||
|
query.DatePreset,
|
||||||
|
query.DateFrom,
|
||||||
|
query.DateTo);
|
||||||
|
|
||||||
|
var dataQuery = new WorkOrderAdvancedSearchQuery(
|
||||||
|
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
|
||||||
|
dateRange.From,
|
||||||
|
dateRange.To,
|
||||||
|
query.Sites,
|
||||||
|
query.Types,
|
||||||
|
query.Dispatchers,
|
||||||
|
query.Statuses,
|
||||||
|
query.PmTypes,
|
||||||
|
query.VendorIds,
|
||||||
|
query.DocStatuses,
|
||||||
|
query.MyWorkOrders,
|
||||||
|
currentUserId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
query.SortBy ?? "scheduledDate",
|
||||||
|
query.SortDir ?? "asc");
|
||||||
|
|
||||||
|
var result = await _searchDataService.SearchAsync(dataQuery);
|
||||||
|
var utcNow = DateTime.UtcNow;
|
||||||
|
|
||||||
|
return new PagedResult<WorkOrderBoardRowDto>
|
||||||
|
{
|
||||||
|
Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(),
|
||||||
|
TotalCount = result.TotalCount,
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardCancelService : IWorkOrderBoardCancelService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly IWorkOrderBoardService _boardService;
|
||||||
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
|
||||||
|
public WorkOrderBoardCancelService(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
IWorkOrderBoardService boardService,
|
||||||
|
IWorkOrderAuditService auditService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_boardService = boardService;
|
||||||
|
_auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId)
|
||||||
|
{
|
||||||
|
var workOrder = await _context.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
|
if (workOrder == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
|
||||||
|
{
|
||||||
|
var existing = await _boardService.GetBoardRowAsync(workOrderId);
|
||||||
|
return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workOrder.LifecycleStatus is LifecycleStatus.Complete or LifecycleStatus.Closed)
|
||||||
|
throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status.");
|
||||||
|
|
||||||
|
var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? "";
|
||||||
|
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
|
||||||
|
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled);
|
||||||
|
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||||
|
workOrder.LegacyStatus = workOrder.Status;
|
||||||
|
|
||||||
|
_auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var row = await _boardService.GetBoardRowAsync(workOrderId);
|
||||||
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
Normal file
210
SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardCreateService : IWorkOrderBoardCreateService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||||
|
private readonly IWorkOrderBoardService _boardService;
|
||||||
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
private readonly IWorkOrderBoardCreateValidation _validator;
|
||||||
|
|
||||||
|
public WorkOrderBoardCreateService(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
IWorkOrderBoardDataService boardDataService,
|
||||||
|
IWorkOrderBoardService boardService,
|
||||||
|
IWorkOrderAuditService auditService,
|
||||||
|
IWorkOrderBoardCreateValidation validator)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_boardDataService = boardDataService;
|
||||||
|
_boardService = boardService;
|
||||||
|
_auditService = auditService;
|
||||||
|
_validator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId)
|
||||||
|
{
|
||||||
|
var validationResult = await _validator.ValidateAsync(request);
|
||||||
|
if (!validationResult.IsValid)
|
||||||
|
throw new ValidationException(validationResult.Errors);
|
||||||
|
|
||||||
|
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
||||||
|
var siteCode = request.SiteCode!.Trim();
|
||||||
|
|
||||||
|
var workOrder = new WorkOrder
|
||||||
|
{
|
||||||
|
InternalWONumber = woNumber,
|
||||||
|
WorkerOrderNumber = woNumber,
|
||||||
|
WorkOrderType = request.WorkOrderType,
|
||||||
|
SiteCode = siteCode,
|
||||||
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
|
Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(),
|
||||||
|
LocationId = request.LocationId,
|
||||||
|
DueDate = request.DueDate?.Date,
|
||||||
|
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
|
||||||
|
TargetWeek = request.TargetWeek,
|
||||||
|
ScheduleWeekOnly = request.ScheduleWeekOnly,
|
||||||
|
DocStatus = request.DocStatus,
|
||||||
|
createdby = actorId,
|
||||||
|
CreatedDate = DateTime.UtcNow,
|
||||||
|
istemplate = false
|
||||||
|
};
|
||||||
|
|
||||||
|
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
||||||
|
|
||||||
|
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
||||||
|
{
|
||||||
|
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),
|
||||||
|
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("WorkOrderType", "", request.WorkOrderType.ToString()),
|
||||||
|
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("SiteCode", "", siteCode),
|
||||||
|
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LifecycleStatus", "", LifecycleStatus.Incomplete.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
if (workOrder.Description != null)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||||
|
if (workOrder.Trade != null)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
||||||
|
if (workOrder.LocationId.HasValue)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
|
||||||
|
if (workOrder.DueDate.HasValue)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DueDate", "", workOrder.DueDate.Value.ToString("yyyy-MM-dd")));
|
||||||
|
if (workOrder.TargetWeek.HasValue)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TargetWeek", "", workOrder.TargetWeek.Value.ToString("yyyy-MM-dd")));
|
||||||
|
if (workOrder.ScheduleWeekOnly.HasValue)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ScheduleWeekOnly", "", workOrder.ScheduleWeekOnly.Value.ToString()));
|
||||||
|
if (workOrder.DocStatus.HasValue)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DocStatus", "", workOrder.DocStatus.Value.ToString()));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.AssignTo))
|
||||||
|
changes.AddRange(WorkOrderBoardFieldMutations.ApplyAssignTo(workOrder, request.AssignTo));
|
||||||
|
else if (!string.IsNullOrWhiteSpace(workOrder.AssignTo))
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.AssignmentChanged("AssignTo", "", workOrder.AssignTo));
|
||||||
|
|
||||||
|
if (request.ScheduledDate.HasValue)
|
||||||
|
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
|
||||||
|
|
||||||
|
Dispatch? dispatch = null;
|
||||||
|
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
|
||||||
|
{
|
||||||
|
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
|
||||||
|
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
|
||||||
|
|
||||||
|
dispatch = new Dispatch
|
||||||
|
{
|
||||||
|
VendorId = request.VendorId.Value,
|
||||||
|
Status = "Pending",
|
||||||
|
CreatedDate = DateTime.UtcNow,
|
||||||
|
ScheduledDate = request.ApptDate?.Date
|
||||||
|
};
|
||||||
|
_context.Dispatches.Add(dispatch);
|
||||||
|
workOrder.PrimaryDispatch = dispatch;
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorId", "", request.VendorId.Value.ToString()));
|
||||||
|
|
||||||
|
if (request.ApptDate.HasValue)
|
||||||
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ApptDate", "", request.ApptDate.Value.ToString("yyyy-MM-dd")));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ApptTime))
|
||||||
|
{
|
||||||
|
var apptChange = WorkOrderBoardFieldMutations.ApplyApptTime(workOrder, dispatch, request.ApptTime);
|
||||||
|
if (apptChange.HasChanged)
|
||||||
|
changes.Add(apptChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.workOrders.Add(workOrder);
|
||||||
|
|
||||||
|
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
|
||||||
|
StageChanges(workOrder.Id, changes, actorId, dispatch);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (dispatch != null && workOrder.PrimaryDispatchId == null)
|
||||||
|
{
|
||||||
|
workOrder.PrimaryDispatchId = dispatch.Id;
|
||||||
|
dispatch.WorkOrderId = workOrder.Id;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.PocContactId.HasValue)
|
||||||
|
{
|
||||||
|
_context.WorkOrderContacts.Add(new WorkOrderContacts
|
||||||
|
{
|
||||||
|
WorkorderId = workOrder.Id,
|
||||||
|
ContactId = request.PocContactId,
|
||||||
|
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
|
||||||
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveWoNumberAsync(string? requested)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(requested))
|
||||||
|
{
|
||||||
|
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||||
|
|
||||||
|
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0))
|
||||||
|
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 20; attempt++)
|
||||||
|
{
|
||||||
|
var candidate = await GenerateNextSequentialWoNumberAsync();
|
||||||
|
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WorkOrderBoardValidationException("WoNumberGenerationFailed", "Could not generate a unique WO number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateNextSequentialWoNumberAsync()
|
||||||
|
{
|
||||||
|
var maxId = await _context.workOrders.MaxAsync(w => (int?)w.Id) ?? 0;
|
||||||
|
var seed = Math.Max(maxId + 1, 1);
|
||||||
|
if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _))
|
||||||
|
normalized = seed.ToString().PadLeft(11, '0');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StageChanges(
|
||||||
|
int workOrderId,
|
||||||
|
IEnumerable<WorkOrderBoardFieldMutations.BoardFieldChange> changes,
|
||||||
|
string? actorId,
|
||||||
|
Dispatch? dispatch)
|
||||||
|
{
|
||||||
|
foreach (var change in changes.Where(c => c.HasChanged))
|
||||||
|
{
|
||||||
|
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
|
||||||
|
switch (change.Action)
|
||||||
|
{
|
||||||
|
case AuditActionType.StatusChanged:
|
||||||
|
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||||
|
break;
|
||||||
|
case AuditActionType.AssignmentChanged:
|
||||||
|
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs
Normal file
41
SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardMapper : IWorkOrderBoardMapper
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderStatusMapper _statusMapper;
|
||||||
|
|
||||||
|
public WorkOrderBoardMapper(IWorkOrderStatusMapper statusMapper)
|
||||||
|
{
|
||||||
|
_statusMapper = statusMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkOrderBoardItemDto MapToBoardItem(WorkOrderBoardRowDto row, DateOnly? today = null)
|
||||||
|
{
|
||||||
|
var effectiveToday = today ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var isUnassigned = string.IsNullOrWhiteSpace(row.AssigneeId);
|
||||||
|
var isPastDue = ComputeIsPastDue(row, effectiveToday);
|
||||||
|
|
||||||
|
var presentation = new BoardRowPresentation(
|
||||||
|
StatusDisplay: _statusMapper.ToDisplay(row.Status, isUnassigned),
|
||||||
|
IsPastDue: isPastDue,
|
||||||
|
WorkOrderTypeDisplay: string.IsNullOrWhiteSpace(row.WorkOrderType) ? null : row.WorkOrderType.Trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return new WorkOrderBoardItemDto(row, presentation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ComputeIsPastDue(WorkOrderBoardRowDto row, DateOnly today)
|
||||||
|
{
|
||||||
|
if (!row.ScheduledDate.HasValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_statusMapper.IsTerminal(row.Status))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return row.ScheduledDate.Value < today;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal file
120
SeaHaven.Services/Implementation/WorkOrderBoardService.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardService : IWorkOrderBoardService
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||||
|
|
||||||
|
public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService)
|
||||||
|
{
|
||||||
|
_boardDataService = boardDataService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderBoardResponseDto> GetBoardAsync(
|
||||||
|
WorkOrderBoardQueryDto query,
|
||||||
|
string? currentUserId)
|
||||||
|
{
|
||||||
|
var weekStart = query.WeekStart;
|
||||||
|
var weekEnd = query.WeekEnd ?? weekStart.AddDays(4);
|
||||||
|
|
||||||
|
ValidateWeekWindow(weekStart, weekEnd);
|
||||||
|
|
||||||
|
var dataQuery = new WorkOrderBoardQuery(
|
||||||
|
weekStart,
|
||||||
|
weekEnd,
|
||||||
|
query.Dispatchers,
|
||||||
|
query.MyWorkOrders,
|
||||||
|
query.Types,
|
||||||
|
query.Search,
|
||||||
|
currentUserId);
|
||||||
|
|
||||||
|
var result = await _boardDataService.GetBoardRowsAsync(dataQuery);
|
||||||
|
var utcNow = DateTime.UtcNow;
|
||||||
|
|
||||||
|
return new WorkOrderBoardResponseDto
|
||||||
|
{
|
||||||
|
WeekStart = weekStart,
|
||||||
|
WeekEnd = weekEnd,
|
||||||
|
Counts = new WorkOrderBoardCountsDto
|
||||||
|
{
|
||||||
|
Total = result.ScheduledTotalBeforeSearch,
|
||||||
|
Returned = result.ScheduledRows.Count
|
||||||
|
},
|
||||||
|
Scheduled = result.ScheduledRows.Select(r => MapRow(r, utcNow)).ToList(),
|
||||||
|
Unscheduled = result.UnscheduledRows.Select(r => MapRow(r, utcNow)).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync()
|
||||||
|
{
|
||||||
|
var rows = await _boardDataService.GetDispatcherLookupsAsync();
|
||||||
|
return rows.Select(r => new DispatcherLookupDto
|
||||||
|
{
|
||||||
|
Id = r.Id,
|
||||||
|
Name = FormatName(r.FirstName, r.LastName),
|
||||||
|
Initials = r.Initials,
|
||||||
|
Color = r.Color
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
|
||||||
|
{
|
||||||
|
if (weekEnd < weekStart)
|
||||||
|
throw new ArgumentException("weekEnd must be on or after weekStart.");
|
||||||
|
|
||||||
|
var spanDays = weekEnd.DayNumber - weekStart.DayNumber;
|
||||||
|
if (spanDays > 6)
|
||||||
|
throw new ArgumentException("Week window cannot exceed 7 days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderBoardRowDto MapRow(WorkOrderBoardRawRow row, DateTime utcNow)
|
||||||
|
{
|
||||||
|
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
|
||||||
|
var apptEnd = row.ScheduledEnd;
|
||||||
|
|
||||||
|
return new WorkOrderBoardRowDto
|
||||||
|
{
|
||||||
|
Id = row.Id,
|
||||||
|
WoNumber = row.InternalWONumber,
|
||||||
|
RescheduleCount = row.RescheduleCount,
|
||||||
|
CarriedOver = row.CarriedOver,
|
||||||
|
WorkOrderType = row.WorkOrderType,
|
||||||
|
IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow),
|
||||||
|
SiteCode = row.SiteCode,
|
||||||
|
LocationName = row.LocationName,
|
||||||
|
PocName = row.PocName,
|
||||||
|
PocPhone = row.PocPhone,
|
||||||
|
PocNotes = row.PocNotes,
|
||||||
|
LifecycleStatus = row.LifecycleStatus,
|
||||||
|
LifecycleStatusLabel = WorkOrderDerivedFields.GetLifecycleStatusLabel(row.LifecycleStatus),
|
||||||
|
LegacyStatus = row.LegacyStatus,
|
||||||
|
DispatcherId = row.AssignTo,
|
||||||
|
DispatcherName = FormatName(row.DispatcherFirstName, row.DispatcherLastName),
|
||||||
|
Initials = row.Initials,
|
||||||
|
Color = row.Color,
|
||||||
|
DueDate = row.DueDate,
|
||||||
|
ScheduledDate = row.ScheduledDate,
|
||||||
|
TargetWeek = row.TargetWeek,
|
||||||
|
ScheduleWeekOnly = row.ScheduleWeekOnly,
|
||||||
|
DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate),
|
||||||
|
VendorId = row.VendorId,
|
||||||
|
VendorName = row.VendorName,
|
||||||
|
TechName = row.TechName,
|
||||||
|
TechPhone = row.TechPhone,
|
||||||
|
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
||||||
|
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
||||||
|
Pm = row.Trade ?? row.Problem,
|
||||||
|
DocStatus = row.DocStatus,
|
||||||
|
RowVersion = row.RowVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatName(string? firstName, string? lastName)
|
||||||
|
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
510
SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
Normal file
510
SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderBoardUpdateService : IWorkOrderBoardUpdateService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly IWorkOrderBoardDataService _boardDataService;
|
||||||
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
|
||||||
|
public WorkOrderBoardUpdateService(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
IWorkOrderBoardDataService boardDataService,
|
||||||
|
IWorkOrderAuditService auditService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_boardDataService = boardDataService;
|
||||||
|
_auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderBoardRowDto> PatchFieldAsync(
|
||||||
|
int workOrderId,
|
||||||
|
WorkOrderBoardPatchRequestDto request,
|
||||||
|
string? actorId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Field))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
|
||||||
|
|
||||||
|
var field = request.Field.Trim();
|
||||||
|
if (!WorkOrderBoardFieldNames.IsEditable(field))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.");
|
||||||
|
|
||||||
|
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion");
|
||||||
|
if (workOrderVersion == null)
|
||||||
|
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
||||||
|
|
||||||
|
var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion");
|
||||||
|
|
||||||
|
var workOrder = await _context.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
|
if (workOrder == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||||
|
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
|
||||||
|
|
||||||
|
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus);
|
||||||
|
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(field, isPastDue))
|
||||||
|
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first.");
|
||||||
|
|
||||||
|
SetOriginalRowVersion(_context.Entry(workOrder).Property(w => w.RowVersion), workOrderVersion);
|
||||||
|
|
||||||
|
Dispatch? dispatch = null;
|
||||||
|
if (WorkOrderBoardFieldNames.IsDispatchField(field))
|
||||||
|
{
|
||||||
|
var vendorIdHint = field.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? ParseVendorIdHint(request.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint);
|
||||||
|
|
||||||
|
if (dispatchVersion != null)
|
||||||
|
SetOriginalRowVersion(_context.Entry(dispatch).Property(d => d.RowVersion), dispatchVersion);
|
||||||
|
else if (dispatch.RowVersion != null)
|
||||||
|
throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(field);
|
||||||
|
var changes = ApplyFieldMutation(field, workOrder, dispatch, request.Value, auditField);
|
||||||
|
changes = changes.Where(c => c.Changed).ToList();
|
||||||
|
|
||||||
|
if (changes.Count == 0)
|
||||||
|
{
|
||||||
|
var unchanged = await LoadBoardRowAsync(workOrderId);
|
||||||
|
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var change in changes)
|
||||||
|
{
|
||||||
|
switch (change.Action)
|
||||||
|
{
|
||||||
|
case AuditActionType.StatusChanged:
|
||||||
|
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||||
|
break;
|
||||||
|
case AuditActionType.AssignmentChanged:
|
||||||
|
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
var currentState = await LoadBoardRowAsync(workOrderId);
|
||||||
|
throw new WorkOrderBoardConcurrencyException(currentState);
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = await LoadBoardRowAsync(workOrderId);
|
||||||
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FieldChange> ApplyFieldMutation(
|
||||||
|
string field,
|
||||||
|
WorkOrder workOrder,
|
||||||
|
Dispatch? dispatch,
|
||||||
|
string? value,
|
||||||
|
string auditField)
|
||||||
|
{
|
||||||
|
return field.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { ApplyWoNumber(workOrder, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||||
|
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
|
||||||
|
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
|
||||||
|
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
|
||||||
|
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
|
||||||
|
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { ApplyVendorId(dispatch!, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
|
||||||
|
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
|
||||||
|
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dispatch> ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate)
|
||||||
|
{
|
||||||
|
if (primaryDispatchId.HasValue)
|
||||||
|
{
|
||||||
|
var dispatch = await _context.Dispatches
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == primaryDispatchId.Value && d.WorkOrderId == workOrder.Id);
|
||||||
|
|
||||||
|
if (dispatch == null)
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order.");
|
||||||
|
|
||||||
|
workOrder.PrimaryDispatchId = dispatch.Id;
|
||||||
|
return dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workOrder.PrimaryDispatchId.HasValue)
|
||||||
|
{
|
||||||
|
var existing = await _context.Dispatches
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == workOrder.PrimaryDispatchId.Value);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0)
|
||||||
|
throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId.");
|
||||||
|
|
||||||
|
var created = new Dispatch
|
||||||
|
{
|
||||||
|
WorkOrderId = workOrder.Id,
|
||||||
|
VendorId = vendorIdForCreate.Value,
|
||||||
|
Status = "Pending",
|
||||||
|
CreatedDate = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_context.Dispatches.Add(created);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
workOrder.PrimaryDispatchId = created.Id;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||||
|
|
||||||
|
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id))
|
||||||
|
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||||
|
|
||||||
|
var old = workOrder.InternalWONumber;
|
||||||
|
if (old == normalized)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
workOrder.InternalWONumber = normalized;
|
||||||
|
workOrder.WorkerOrderNumber = normalized;
|
||||||
|
return FieldChange.Changed(auditField, old, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
|
||||||
|
|
||||||
|
if (!Enum.TryParse<WorkOrderType>(value, true, out var parsed))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
||||||
|
|
||||||
|
var old = workOrder.WorkOrderType?.ToString();
|
||||||
|
if (workOrder.WorkOrderType == parsed)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
workOrder.WorkOrderType = parsed;
|
||||||
|
return FieldChange.Changed(auditField, old, parsed.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyStringField(
|
||||||
|
string? value,
|
||||||
|
string auditField,
|
||||||
|
Action<string?> setter,
|
||||||
|
Func<string?> getter)
|
||||||
|
{
|
||||||
|
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
var old = getter();
|
||||||
|
if (old == normalized)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
setter(normalized);
|
||||||
|
return FieldChange.Changed(auditField, old, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
|
||||||
|
if (parsed == null)
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid lifecycleStatus: {value}");
|
||||||
|
|
||||||
|
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
|
||||||
|
if (workOrder.LifecycleStatus == parsed)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
workOrder.LifecycleStatus = parsed;
|
||||||
|
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
|
||||||
|
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
||||||
|
workOrder.LegacyStatus = workOrder.Status;
|
||||||
|
|
||||||
|
return FieldChange.StatusChanged(auditField, old, parsed.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
var old = workOrder.AssignTo;
|
||||||
|
var changes = new List<FieldChange>();
|
||||||
|
|
||||||
|
if (old != normalized)
|
||||||
|
{
|
||||||
|
workOrder.AssignTo = normalized;
|
||||||
|
changes.Add(FieldChange.AssignmentChanged(auditField, old, normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyDateField(
|
||||||
|
string? value,
|
||||||
|
string auditField,
|
||||||
|
Action<DateTime?> setter,
|
||||||
|
Func<DateTime?> getter)
|
||||||
|
{
|
||||||
|
DateTime? parsed = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(value, out var dt))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid date for {auditField}: {value}");
|
||||||
|
parsed = dt.Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
var old = FormatDate(getter());
|
||||||
|
var newVal = FormatDate(parsed);
|
||||||
|
if (old == newVal)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
setter(parsed);
|
||||||
|
return FieldChange.Changed(auditField, old, newVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FieldChange> ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
var previousDate = workOrder.ScheduledDate;
|
||||||
|
DateTime? parsed = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(value, out var dt))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid scheduledDate: {value}");
|
||||||
|
parsed = dt.Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
var changes = new List<FieldChange>();
|
||||||
|
var oldFormatted = FormatDate(previousDate);
|
||||||
|
var newFormatted = FormatDate(parsed);
|
||||||
|
|
||||||
|
if (oldFormatted != newFormatted)
|
||||||
|
{
|
||||||
|
workOrder.ScheduledDate = parsed;
|
||||||
|
changes.Add(FieldChange.Changed(auditField, oldFormatted, newFormatted));
|
||||||
|
|
||||||
|
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
|
||||||
|
{
|
||||||
|
workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value);
|
||||||
|
var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed);
|
||||||
|
if (originalWeek.HasValue)
|
||||||
|
workOrder.OriginalWeek = originalWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed))
|
||||||
|
{
|
||||||
|
var oldCount = workOrder.RescheduleCount.ToString();
|
||||||
|
workOrder.RescheduleCount++;
|
||||||
|
changes.Add(FieldChange.Changed(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
DateOnly? parsed = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
if (!DateOnly.TryParse(value, out var d))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid targetWeek: {value}");
|
||||||
|
parsed = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd");
|
||||||
|
var newVal = parsed?.ToString("yyyy-MM-dd");
|
||||||
|
if (old == newVal)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
workOrder.TargetWeek = parsed;
|
||||||
|
return FieldChange.Changed(auditField, old, newVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyBoolField(
|
||||||
|
string? value,
|
||||||
|
string auditField,
|
||||||
|
Action<bool?> setter,
|
||||||
|
Func<bool?> getter)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"{auditField} value is required.");
|
||||||
|
|
||||||
|
if (!bool.TryParse(value, out var parsed))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid boolean for {auditField}: {value}");
|
||||||
|
|
||||||
|
var old = getter()?.ToString();
|
||||||
|
if (getter() == parsed)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
setter(parsed);
|
||||||
|
return FieldChange.Changed(auditField, old, parsed.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField)
|
||||||
|
{
|
||||||
|
var vendorId = ParseVendorIdHint(value);
|
||||||
|
if (!vendorId.HasValue)
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer.");
|
||||||
|
|
||||||
|
var old = dispatch.VendorId.ToString();
|
||||||
|
if (dispatch.VendorId == vendorId.Value)
|
||||||
|
return FieldChange.Unchanged(auditField, dispatch.Id);
|
||||||
|
|
||||||
|
dispatch.VendorId = vendorId.Value;
|
||||||
|
return FieldChange.Changed(auditField, old, vendorId.Value.ToString(), dispatch.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
|
||||||
|
{
|
||||||
|
DateTime? parsed = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(value, out var dt))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid apptDate: {value}");
|
||||||
|
parsed = dt.Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
var old = FormatDate(dispatch.ScheduledDate);
|
||||||
|
var newVal = FormatDate(parsed);
|
||||||
|
if (old == newVal)
|
||||||
|
return FieldChange.Unchanged(auditField, dispatch.Id);
|
||||||
|
|
||||||
|
dispatch.ScheduledDate = parsed;
|
||||||
|
return FieldChange.Changed(auditField, old, newVal, dispatch.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField)
|
||||||
|
{
|
||||||
|
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
|
||||||
|
|
||||||
|
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
|
||||||
|
var oldStart = FormatDateTime(workOrder.ScheduledStart);
|
||||||
|
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
|
|
||||||
|
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
|
||||||
|
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
|
||||||
|
|
||||||
|
var newStart = FormatDateTime(workOrder.ScheduledStart);
|
||||||
|
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
|
||||||
|
var oldCombined = FormatApptRange(oldStart, oldEnd);
|
||||||
|
var newCombined = FormatApptRange(newStart, newEnd);
|
||||||
|
|
||||||
|
if (oldCombined == newCombined)
|
||||||
|
return FieldChange.Unchanged(auditField, dispatch.Id);
|
||||||
|
|
||||||
|
return FieldChange.Changed(auditField, oldCombined, newCombined, dispatch.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", "docStatus value is required.");
|
||||||
|
|
||||||
|
if (!Enum.TryParse<DocStatus>(value, true, out var parsed))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid docStatus: {value}");
|
||||||
|
|
||||||
|
var old = workOrder.DocStatus?.ToString();
|
||||||
|
if (workOrder.DocStatus == parsed)
|
||||||
|
return FieldChange.Unchanged(auditField);
|
||||||
|
|
||||||
|
workOrder.DocStatus = parsed;
|
||||||
|
return FieldChange.Changed(auditField, old, parsed.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||||
|
{
|
||||||
|
var changes = new List<FieldChange>();
|
||||||
|
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo))
|
||||||
|
return changes;
|
||||||
|
|
||||||
|
var old = workOrder.LifecycleStatus?.ToString();
|
||||||
|
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
|
||||||
|
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
|
||||||
|
changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetOriginalRowVersion<T>(PropertyEntry<T, byte[]?> property, byte[] version)
|
||||||
|
{
|
||||||
|
property.OriginalValue = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId);
|
||||||
|
return raw == null ? null : WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? ParseRowVersion(string? base64, string fieldName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(base64))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.FromBase64String(base64);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidRowVersion", $"Invalid {fieldName} format.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ParseVendorIdHint(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || !int.TryParse(value, out var vendorId) || vendorId <= 0)
|
||||||
|
return null;
|
||||||
|
return vendorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
|
||||||
|
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
|
||||||
|
private static string? FormatApptRange(string? start, string? end)
|
||||||
|
=> start == null && end == null ? null : $"{start}|{end}";
|
||||||
|
|
||||||
|
private sealed record FieldChange(
|
||||||
|
string FieldName,
|
||||||
|
string? OldValue,
|
||||||
|
string? NewValue,
|
||||||
|
AuditActionType Action,
|
||||||
|
int? DispatchId,
|
||||||
|
bool Changed)
|
||||||
|
{
|
||||||
|
public static FieldChange Unchanged(string fieldName, int? dispatchId = null)
|
||||||
|
=> new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false);
|
||||||
|
|
||||||
|
public static FieldChange Changed(string fieldName, string? oldValue, string? newValue, int? dispatchId = null)
|
||||||
|
=> new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true);
|
||||||
|
|
||||||
|
public static FieldChange StatusChanged(string fieldName, string? oldValue, string? newValue)
|
||||||
|
=> new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true);
|
||||||
|
|
||||||
|
public static FieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue)
|
||||||
|
=> new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
SeaHaven.Services/Implementation/WorkOrderCommentService.cs
Normal file
59
SeaHaven.Services/Implementation/WorkOrderCommentService.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderCommentService : IWorkOrderCommentService
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
|
private readonly ICommentDataService _commentData;
|
||||||
|
|
||||||
|
public WorkOrderCommentService(
|
||||||
|
IWorkOrderDetailDataService detailData,
|
||||||
|
ICommentDataService commentData)
|
||||||
|
{
|
||||||
|
_detailData = detailData;
|
||||||
|
_commentData = commentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkOrderCommentDto>> GetCommentsAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
return Array.Empty<WorkOrderCommentDto>();
|
||||||
|
|
||||||
|
var comments = await _detailData.GetCommentsAsync(workOrderId);
|
||||||
|
return WorkOrderCommentProjection.ToDtos(comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderCommentDto> AddCommentAsync(
|
||||||
|
int workOrderId,
|
||||||
|
WorkOrderCommentCreateDto request,
|
||||||
|
string? actorId,
|
||||||
|
string? documentUrl = null)
|
||||||
|
{
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Text))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required.");
|
||||||
|
|
||||||
|
var comment = new Comments
|
||||||
|
{
|
||||||
|
WorkerOrderId = workOrderId,
|
||||||
|
UserId = actorId,
|
||||||
|
Commenttext = request.Text.Trim(),
|
||||||
|
CommentType = "General",
|
||||||
|
RecordType = "WorkOrder",
|
||||||
|
Documents = documentUrl,
|
||||||
|
CreatedDate = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await _commentData.AddAsync(comment);
|
||||||
|
return WorkOrderCommentProjection.ToDto(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
Normal file
157
SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderCompletionService : IWorkOrderCompletionService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly ICompletionDocTemplateDataService _templateData;
|
||||||
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
|
private readonly IWorkOrderAuditService _auditService;
|
||||||
|
|
||||||
|
public WorkOrderCompletionService(
|
||||||
|
ApplicationDbContext context,
|
||||||
|
ICompletionDocTemplateDataService templateData,
|
||||||
|
IWorkOrderDetailDataService detailData,
|
||||||
|
IWorkOrderAuditService auditService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_templateData = templateData;
|
||||||
|
_detailData = detailData;
|
||||||
|
_auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
|
||||||
|
{
|
||||||
|
var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType);
|
||||||
|
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
|
||||||
|
{
|
||||||
|
var row = await _templateData.GetByIdAsync(id);
|
||||||
|
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
||||||
|
int workOrderId,
|
||||||
|
WorkOrderCompletionDocUploadDto request,
|
||||||
|
string fileUrl,
|
||||||
|
string? actorId)
|
||||||
|
{
|
||||||
|
var workOrder = await _context.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||||
|
|
||||||
|
if (workOrder == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||||
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
|
|
||||||
|
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
|
||||||
|
if (workOrderVersion != null && workOrder.RowVersion != null && !workOrder.RowVersion.SequenceEqual(workOrderVersion))
|
||||||
|
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
|
||||||
|
|
||||||
|
var oldDocStatus = workOrder.DocStatus?.ToString();
|
||||||
|
var oldAttachment = workOrder.SignOffAttachment;
|
||||||
|
|
||||||
|
workOrder.SignOffAttachment = fileUrl;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.SignOffName))
|
||||||
|
workOrder.SignOffName = request.SignOffName.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.SignOffSignature))
|
||||||
|
workOrder.SignOffSignature = request.SignOffSignature.Trim();
|
||||||
|
|
||||||
|
workOrder.DocStatus = DocStatus.Yes;
|
||||||
|
|
||||||
|
_auditService.StageFieldChanged(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId);
|
||||||
|
if (oldDocStatus != DocStatus.Yes.ToString())
|
||||||
|
_auditService.StageFieldChanged(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
|
||||||
|
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
|
||||||
|
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
|
||||||
|
|
||||||
|
return new WorkOrderCompletionDto
|
||||||
|
{
|
||||||
|
DocStatus = workOrder.DocStatus,
|
||||||
|
Template = template == null ? null : WorkOrderDetailService.MapTemplate(template),
|
||||||
|
SignOffName = extended?.SignOffName,
|
||||||
|
SignOffAttachment = extended?.SignOffAttachment,
|
||||||
|
SignOffSignature = extended?.SignOffSignature,
|
||||||
|
DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto
|
||||||
|
{
|
||||||
|
DispatchId = s.DispatchId,
|
||||||
|
SignoffType = s.SignoffType,
|
||||||
|
Name = s.Name,
|
||||||
|
SignedAt = s.SignedAt?.ToUniversalTime().ToString("o")
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required.");
|
||||||
|
|
||||||
|
var entity = new CompletionDocTemplate
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
ServiceKey = request.ServiceKey.Trim(),
|
||||||
|
WorkOrderType = request.WorkOrderType,
|
||||||
|
TemplateUrl = request.TemplateUrl.Trim(),
|
||||||
|
IsActive = request.IsActive
|
||||||
|
};
|
||||||
|
|
||||||
|
var created = await _templateData.CreateAsync(entity);
|
||||||
|
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
||||||
|
created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request)
|
||||||
|
{
|
||||||
|
var entity = await _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
|
||||||
|
if (entity == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
||||||
|
|
||||||
|
entity.Name = request.Name.Trim();
|
||||||
|
entity.ServiceKey = request.ServiceKey.Trim();
|
||||||
|
entity.WorkOrderType = request.WorkOrderType;
|
||||||
|
entity.TemplateUrl = request.TemplateUrl.Trim();
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await _templateData.UpdateAsync(entity);
|
||||||
|
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
||||||
|
entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteTemplateAsync(int id)
|
||||||
|
{
|
||||||
|
if (!await _templateData.DeleteAsync(id))
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? ParseRowVersion(string? base64)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(base64))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.FromBase64String(base64);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
SeaHaven.Services/Implementation/WorkOrderDetailService.cs
Normal file
149
SeaHaven.Services/Implementation/WorkOrderDetailService.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderDetailService : IWorkOrderDetailService
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderBoardService _boardService;
|
||||||
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
|
private readonly ICompletionDocTemplateDataService _templateData;
|
||||||
|
|
||||||
|
public WorkOrderDetailService(
|
||||||
|
IWorkOrderBoardService boardService,
|
||||||
|
IWorkOrderDetailDataService detailData,
|
||||||
|
ICompletionDocTemplateDataService templateData)
|
||||||
|
{
|
||||||
|
_boardService = boardService;
|
||||||
|
_detailData = detailData;
|
||||||
|
_templateData = templateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var boardRow = await _boardService.GetBoardRowAsync(workOrderId);
|
||||||
|
if (boardRow == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
|
||||||
|
var comments = await _detailData.GetCommentsAsync(workOrderId);
|
||||||
|
var audit = await _detailData.GetAuditLogsAsync(workOrderId);
|
||||||
|
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
|
||||||
|
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
|
||||||
|
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||||
|
|
||||||
|
var template = await _templateData.ResolveForWorkOrderAsync(
|
||||||
|
extended?.Trade ?? boardRow.Pm,
|
||||||
|
boardRow.WorkOrderType);
|
||||||
|
|
||||||
|
return new WorkOrderDetailDto
|
||||||
|
{
|
||||||
|
Info = MapInfo(boardRow, extended),
|
||||||
|
Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs),
|
||||||
|
Comments = WorkOrderCommentProjection.ToDtos(comments),
|
||||||
|
Audit = WorkOrderAuditProjection.ToDtos(audit),
|
||||||
|
Media = workOrder == null
|
||||||
|
? new List<WorkOrderMediaFileDto>()
|
||||||
|
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkOrderAuditEntryDto>> GetAuditAsync(int workOrderId, int limit = 50)
|
||||||
|
{
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
return Array.Empty<WorkOrderAuditEntryDto>();
|
||||||
|
|
||||||
|
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
|
||||||
|
return WorkOrderAuditProjection.ToDtos(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderDetailInfoDto MapInfo(
|
||||||
|
WorkOrderBoardRowDto row,
|
||||||
|
WorkOrderDetailExtendedFields? extended)
|
||||||
|
{
|
||||||
|
return new WorkOrderDetailInfoDto
|
||||||
|
{
|
||||||
|
Id = row.Id,
|
||||||
|
WoNumber = row.WoNumber,
|
||||||
|
RescheduleCount = row.RescheduleCount,
|
||||||
|
CarriedOver = row.CarriedOver,
|
||||||
|
WorkOrderType = row.WorkOrderType,
|
||||||
|
IsPastDue = row.IsPastDue,
|
||||||
|
SiteCode = row.SiteCode,
|
||||||
|
LocationName = row.LocationName,
|
||||||
|
PocName = row.PocName,
|
||||||
|
PocPhone = row.PocPhone,
|
||||||
|
PocNotes = row.PocNotes,
|
||||||
|
LifecycleStatus = row.LifecycleStatus,
|
||||||
|
LifecycleStatusLabel = row.LifecycleStatusLabel,
|
||||||
|
LegacyStatus = row.LegacyStatus,
|
||||||
|
DispatcherId = row.DispatcherId,
|
||||||
|
DispatcherName = row.DispatcherName,
|
||||||
|
Initials = row.Initials,
|
||||||
|
Color = row.Color,
|
||||||
|
DueDate = row.DueDate,
|
||||||
|
ScheduledDate = row.ScheduledDate,
|
||||||
|
TargetWeek = row.TargetWeek,
|
||||||
|
ScheduleWeekOnly = row.ScheduleWeekOnly,
|
||||||
|
DayGroup = row.DayGroup,
|
||||||
|
VendorId = row.VendorId,
|
||||||
|
VendorName = row.VendorName,
|
||||||
|
TechName = row.TechName,
|
||||||
|
TechPhone = row.TechPhone,
|
||||||
|
ApptDate = row.ApptDate,
|
||||||
|
ApptTime = row.ApptTime,
|
||||||
|
Pm = row.Pm,
|
||||||
|
DocStatus = row.DocStatus,
|
||||||
|
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||||
|
RowVersion = row.RowVersion,
|
||||||
|
DispatchRowVersion = row.DispatchRowVersion,
|
||||||
|
Description = extended?.Description,
|
||||||
|
Trade = extended?.Trade,
|
||||||
|
Problem = extended?.Problem,
|
||||||
|
SubTrade = extended?.SubTrade,
|
||||||
|
OriginalWeek = extended?.OriginalWeek,
|
||||||
|
OriginalDate = extended?.OriginalDate,
|
||||||
|
CompletedDate = extended?.CompletedDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderCompletionDto MapCompletion(
|
||||||
|
WorkOrderDetailExtendedFields? extended,
|
||||||
|
Data.SeaHavenIndustries.Enums.DocStatus? docStatus,
|
||||||
|
CompletionDocTemplateRow? template,
|
||||||
|
IReadOnlyList<DispatchSignoffRow> signoffs)
|
||||||
|
{
|
||||||
|
return new WorkOrderCompletionDto
|
||||||
|
{
|
||||||
|
DocStatus = docStatus,
|
||||||
|
Template = template == null ? null : MapTemplate(template),
|
||||||
|
SignOffName = extended?.SignOffName,
|
||||||
|
SignOffAttachment = extended?.SignOffAttachment,
|
||||||
|
SignOffSignature = extended?.SignOffSignature,
|
||||||
|
DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto
|
||||||
|
{
|
||||||
|
DispatchId = s.DispatchId,
|
||||||
|
SignoffType = s.SignoffType,
|
||||||
|
Name = s.Name,
|
||||||
|
SignedAt = s.SignedAt?.ToUniversalTime().ToString("o")
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static CompletionDocTemplateDto MapTemplate(CompletionDocTemplateRow template)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = template.Id,
|
||||||
|
Name = template.Name,
|
||||||
|
ServiceKey = template.ServiceKey,
|
||||||
|
WorkOrderType = template.WorkOrderType,
|
||||||
|
TemplateUrl = template.TemplateUrl,
|
||||||
|
IsActive = template.IsActive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
190
SeaHaven.Services/Implementation/WorkOrderIngestService.cs
Normal file
190
SeaHaven.Services/Implementation/WorkOrderIngestService.cs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderIngestService : IWorkOrderIngestService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly ISyncFieldMergePolicy _mergePolicy;
|
||||||
|
private readonly IWorkOrderFieldLockService _fieldLocks;
|
||||||
|
private readonly IWorkOrderAuditService _audit;
|
||||||
|
|
||||||
|
public WorkOrderIngestService(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
ISyncFieldMergePolicy mergePolicy,
|
||||||
|
IWorkOrderFieldLockService fieldLocks,
|
||||||
|
IWorkOrderAuditService audit)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_mergePolicy = mergePolicy;
|
||||||
|
_fieldLocks = fieldLocks;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
|
||||||
|
IReadOnlyList<WorkOrderIngestPayloadDto> items,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new WorkOrderIngestBatchResultDto();
|
||||||
|
if (items.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var nextInternal = await GetNextInternalWoNumberAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.ExternalWorkOrderId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var upsert = await UpsertOneAsync(item, ref nextInternal, cancellationToken);
|
||||||
|
result.Results.Add(upsert);
|
||||||
|
if (upsert.Created)
|
||||||
|
result.Created++;
|
||||||
|
else
|
||||||
|
result.Updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WorkOrderIngestItemResultDto> UpsertOneAsync(
|
||||||
|
WorkOrderIngestPayloadDto item,
|
||||||
|
ref int nextInternal,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await _db.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == item.ExternalWorkOrderId, cancellationToken);
|
||||||
|
|
||||||
|
var locationId = await ResolveLocationIdAsync(
|
||||||
|
item.SiteCode, item.Building, item.Address, cancellationToken);
|
||||||
|
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
InternalWONumber = (nextInternal++).ToString("D8"),
|
||||||
|
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||||
|
WorkerOrderNumber = item.ExternalWorkOrderId,
|
||||||
|
WorkerOrderTitle = item.Description,
|
||||||
|
Description = item.Description,
|
||||||
|
Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus) ?? "Open",
|
||||||
|
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
|
||||||
|
Severity = item.Severity,
|
||||||
|
Customer = item.Customer,
|
||||||
|
SiteCode = item.SiteCode,
|
||||||
|
Building = item.Building,
|
||||||
|
LocationId = locationId,
|
||||||
|
DueDate = item.DueDate,
|
||||||
|
DateReported = item.DateReported,
|
||||||
|
ScheduledStart = item.ScheduledStart,
|
||||||
|
SourceEmailS3Key = item.SourceEmailS3Key,
|
||||||
|
CreatedDate = item.CreatedAt ?? DateTime.UtcNow,
|
||||||
|
istemplate = false
|
||||||
|
};
|
||||||
|
_db.workOrders.Add(wo);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new WorkOrderIngestItemResultDto
|
||||||
|
{
|
||||||
|
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||||
|
WorkOrderId = wo.Id,
|
||||||
|
Created = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncContext = new WorkOrderSyncContext
|
||||||
|
{
|
||||||
|
WorkOrder = existing,
|
||||||
|
FieldLocks = _fieldLocks,
|
||||||
|
Audit = _audit
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.Description != null)
|
||||||
|
{
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Description", item.Description);
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "WorkerOrderTitle", item.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
|
||||||
|
if (mappedStatus != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus);
|
||||||
|
|
||||||
|
var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity);
|
||||||
|
if (priority != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority);
|
||||||
|
|
||||||
|
if (item.Severity != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Severity", item.Severity);
|
||||||
|
|
||||||
|
if (item.SiteCode != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "SiteCode", item.SiteCode);
|
||||||
|
|
||||||
|
if (item.Building != null)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "Building", item.Building);
|
||||||
|
|
||||||
|
existing.LocationId = locationId ?? existing.LocationId;
|
||||||
|
|
||||||
|
if (item.DueDate.HasValue)
|
||||||
|
await _mergePolicy.TryApplyAsync(syncContext, "DueDate", item.DueDate.Value);
|
||||||
|
|
||||||
|
existing.DateReported = item.DateReported ?? existing.DateReported;
|
||||||
|
existing.ScheduledStart = item.ScheduledStart ?? existing.ScheduledStart;
|
||||||
|
existing.SourceEmailS3Key = item.SourceEmailS3Key ?? existing.SourceEmailS3Key;
|
||||||
|
|
||||||
|
return new WorkOrderIngestItemResultDto
|
||||||
|
{
|
||||||
|
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||||
|
WorkOrderId = existing.Id,
|
||||||
|
Created = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> GetNextInternalWoNumberAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var lastInternalWo = await _db.workOrders
|
||||||
|
.Where(w => w.InternalWONumber != null && w.InternalWONumber != "")
|
||||||
|
.OrderByDescending(w => w.InternalWONumber)
|
||||||
|
.Select(w => w.InternalWONumber)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var next = 10000001;
|
||||||
|
if (lastInternalWo != null && int.TryParse(lastInternalWo, out var parsed))
|
||||||
|
next = parsed + 1;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int?> ResolveLocationIdAsync(
|
||||||
|
string? siteCode,
|
||||||
|
string? building,
|
||||||
|
string? address,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var matchCode = siteCode ?? building;
|
||||||
|
var existing = await _db.Locations
|
||||||
|
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
return existing.Id;
|
||||||
|
|
||||||
|
var location = new Locations
|
||||||
|
{
|
||||||
|
Name = matchCode,
|
||||||
|
Title = building,
|
||||||
|
Address1 = address,
|
||||||
|
Status = "Active"
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Locations.Add(location);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return location.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
SeaHaven.Services/Implementation/WorkOrderMediaService.cs
Normal file
127
SeaHaven.Services/Implementation/WorkOrderMediaService.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderMediaService : IWorkOrderMediaService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
|
|
||||||
|
public WorkOrderMediaService(ApplicationDbContext context, IWorkOrderDetailDataService detailData)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_detailData = detailData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkOrderMediaFileDto>> GetMediaAsync(int workOrderId)
|
||||||
|
{
|
||||||
|
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||||
|
if (workOrder == null)
|
||||||
|
return Array.Empty<WorkOrderMediaFileDto>();
|
||||||
|
|
||||||
|
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
|
||||||
|
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
|
||||||
|
int workOrderId,
|
||||||
|
WorkOrderMediaCategory category,
|
||||||
|
string fileUrl,
|
||||||
|
string? actorId)
|
||||||
|
{
|
||||||
|
if (!await _detailData.ExistsAsync(workOrderId))
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||||
|
if (workOrder == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||||
|
|
||||||
|
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||||
|
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||||
|
|
||||||
|
if (category == WorkOrderMediaCategory.Before)
|
||||||
|
{
|
||||||
|
workOrder.BeforPhotoAttachment = fileUrl;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = -1,
|
||||||
|
Category = category,
|
||||||
|
Url = fileUrl,
|
||||||
|
IsLegacy = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category == WorkOrderMediaCategory.After)
|
||||||
|
{
|
||||||
|
workOrder.AfterPhotoAttachment = fileUrl;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = -2,
|
||||||
|
Category = category,
|
||||||
|
Url = fileUrl,
|
||||||
|
IsLegacy = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category == WorkOrderMediaCategory.Completion)
|
||||||
|
{
|
||||||
|
workOrder.SignOffAttachment = fileUrl;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = -3,
|
||||||
|
Category = category,
|
||||||
|
Url = fileUrl,
|
||||||
|
IsLegacy = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachment = new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = workOrderId,
|
||||||
|
Attachments = fileUrl,
|
||||||
|
Category = category,
|
||||||
|
CreatedDate = DateTime.UtcNow,
|
||||||
|
createdby = actorId
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.workOrderAttachments.Add(attachment);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new WorkOrderMediaFileDto
|
||||||
|
{
|
||||||
|
Id = attachment.Id,
|
||||||
|
Category = category,
|
||||||
|
Url = fileUrl,
|
||||||
|
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
|
||||||
|
IsLegacy = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId)
|
||||||
|
{
|
||||||
|
if (mediaId <= 0)
|
||||||
|
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint.");
|
||||||
|
|
||||||
|
var attachment = await _context.workOrderAttachments
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true);
|
||||||
|
|
||||||
|
if (attachment == null)
|
||||||
|
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
|
||||||
|
|
||||||
|
attachment.IsDeleted = true;
|
||||||
|
attachment.DeletionTime = DateTime.UtcNow;
|
||||||
|
attachment.DeleterUserId = actorId;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly WorkOrderJobRunStateAccessor _runState;
|
||||||
|
private readonly bool _syncEnabled;
|
||||||
|
private readonly bool _ingestEnabled;
|
||||||
|
private readonly bool _legacyDeprecationEnabled;
|
||||||
|
private readonly string? _legacySunsetDate;
|
||||||
|
|
||||||
|
public WorkOrderOpsHealthService(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
WorkOrderJobRunStateAccessor runState,
|
||||||
|
IOptions<WorkOrderOpsHealthOptions> options)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_runState = runState;
|
||||||
|
_syncEnabled = options.Value.SyncEnabled;
|
||||||
|
_ingestEnabled = options.Value.IngestEnabled;
|
||||||
|
_legacyDeprecationEnabled = options.Value.LegacyDeprecationEnabled;
|
||||||
|
_legacySunsetDate = options.Value.LegacySunsetDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddHours(-24);
|
||||||
|
|
||||||
|
var syncRejected = await _db.WorkOrderAuditLogs
|
||||||
|
.CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken);
|
||||||
|
|
||||||
|
var fieldLocks = await _db.WorkOrderFieldLocks.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var state = _runState.State;
|
||||||
|
var ledgerWeekRolled = await _db.WorkOrderWeekRolledLedgers
|
||||||
|
.MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken);
|
||||||
|
|
||||||
|
return new WorkOrderOpsHealthDto
|
||||||
|
{
|
||||||
|
LastWeekRolledRunUtc = state.LastWeekRolledRunUtc ?? ledgerWeekRolled,
|
||||||
|
LastPastDueCacheRunUtc = state.LastPastDueCacheRunUtc,
|
||||||
|
LastWeekRolledError = state.LastWeekRolledError,
|
||||||
|
LastPastDueCacheError = state.LastPastDueCacheError,
|
||||||
|
SyncRejectedLast24h = syncRejected,
|
||||||
|
FieldLockCount = fieldLocks,
|
||||||
|
SyncEnabled = _syncEnabled,
|
||||||
|
IngestEnabled = _ingestEnabled,
|
||||||
|
LegacyDeprecationEnabled = _legacyDeprecationEnabled,
|
||||||
|
LegacySunsetDate = _legacySunsetDate,
|
||||||
|
CheckedAtUtc = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Options bridged from API configuration for health snapshots.</summary>
|
||||||
|
public class WorkOrderOpsHealthOptions
|
||||||
|
{
|
||||||
|
public bool SyncEnabled { get; set; } = true;
|
||||||
|
public bool IngestEnabled { get; set; } = true;
|
||||||
|
public bool LegacyDeprecationEnabled { get; set; }
|
||||||
|
public string? LegacySunsetDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderJobRunStateAccessor
|
||||||
|
{
|
||||||
|
public WorkOrderJobRunState State { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderJobRunState
|
||||||
|
{
|
||||||
|
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||||
|
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||||
|
public string? LastWeekRolledError { get; set; }
|
||||||
|
public string? LastPastDueCacheError { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
150
SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs
Normal file
150
SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderStatusMapper : IWorkOrderStatusMapper
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> TerminalCanonical = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
nameof(WorkOrderStatus.Completed),
|
||||||
|
nameof(WorkOrderStatus.Cancelled),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> DbToCanonical = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Open"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["new"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["assigned"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["unknown"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["UnAssigned"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["Unassigned"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["In Progress"] = nameof(WorkOrderStatus.InProgress),
|
||||||
|
["InProgress"] = nameof(WorkOrderStatus.InProgress),
|
||||||
|
["in_progress"] = nameof(WorkOrderStatus.InProgress),
|
||||||
|
["On Hold"] = nameof(WorkOrderStatus.OnHold),
|
||||||
|
["OnHold"] = nameof(WorkOrderStatus.OnHold),
|
||||||
|
["on_hold"] = nameof(WorkOrderStatus.OnHold),
|
||||||
|
["Done"] = nameof(WorkOrderStatus.Completed),
|
||||||
|
["Completed"] = nameof(WorkOrderStatus.Completed),
|
||||||
|
["completed"] = nameof(WorkOrderStatus.Completed),
|
||||||
|
["Cancelled"] = nameof(WorkOrderStatus.Cancelled),
|
||||||
|
["cancelled"] = nameof(WorkOrderStatus.Cancelled),
|
||||||
|
["Canceled"] = nameof(WorkOrderStatus.Cancelled),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> CanonicalToDisplay = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[nameof(WorkOrderStatus.Open)] = "Scheduled",
|
||||||
|
[nameof(WorkOrderStatus.InProgress)] = "In Progress",
|
||||||
|
[nameof(WorkOrderStatus.OnHold)] = "Pending",
|
||||||
|
[nameof(WorkOrderStatus.Completed)] = "Completed",
|
||||||
|
[nameof(WorkOrderStatus.Cancelled)] = "Canceled",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> CanonicalToStorage = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[nameof(WorkOrderStatus.Open)] = "Open",
|
||||||
|
[nameof(WorkOrderStatus.InProgress)] = "In Progress",
|
||||||
|
[nameof(WorkOrderStatus.OnHold)] = "On Hold",
|
||||||
|
[nameof(WorkOrderStatus.Completed)] = "Done",
|
||||||
|
[nameof(WorkOrderStatus.Cancelled)] = "Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> DisplayToCanonical = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Incomplete"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["Pending"] = nameof(WorkOrderStatus.OnHold),
|
||||||
|
["Scheduled"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["En Route"] = nameof(WorkOrderStatus.InProgress),
|
||||||
|
["On Site"] = nameof(WorkOrderStatus.InProgress),
|
||||||
|
["In Progress"] = nameof(WorkOrderStatus.InProgress),
|
||||||
|
["Completed"] = nameof(WorkOrderStatus.Completed),
|
||||||
|
["Rescheduled"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["Canceled"] = nameof(WorkOrderStatus.Cancelled),
|
||||||
|
["Cancelled"] = nameof(WorkOrderStatus.Cancelled),
|
||||||
|
["Pending Quote"] = nameof(WorkOrderStatus.OnHold),
|
||||||
|
["Open"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["On Hold"] = nameof(WorkOrderStatus.OnHold),
|
||||||
|
["Done"] = nameof(WorkOrderStatus.Completed),
|
||||||
|
["Unassigned"] = nameof(WorkOrderStatus.Open),
|
||||||
|
["UnAssigned"] = nameof(WorkOrderStatus.Open),
|
||||||
|
};
|
||||||
|
|
||||||
|
public string NormalizeToCanonical(string? dbStatus)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dbStatus))
|
||||||
|
return nameof(WorkOrderStatus.Open);
|
||||||
|
|
||||||
|
var trimmed = dbStatus.Trim();
|
||||||
|
if (DbToCanonical.TryGetValue(trimmed, out var canonical))
|
||||||
|
return canonical;
|
||||||
|
|
||||||
|
if (Enum.TryParse<WorkOrderStatus>(trimmed, ignoreCase: true, out var parsed))
|
||||||
|
return parsed.ToString();
|
||||||
|
|
||||||
|
return nameof(WorkOrderStatus.Open);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ToDisplay(string canonical, bool isUnassigned = false)
|
||||||
|
{
|
||||||
|
if (isUnassigned && string.Equals(canonical, nameof(WorkOrderStatus.Open), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "Incomplete";
|
||||||
|
|
||||||
|
if (CanonicalToDisplay.TryGetValue(canonical, out var display))
|
||||||
|
return display;
|
||||||
|
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsTerminal(string canonical)
|
||||||
|
{
|
||||||
|
return TerminalCanonical.Contains(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> ExpandCanonicalForFilter(string canonical)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeToCanonical(canonical);
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
nameof(WorkOrderStatus.Open) => new[] { "Open", "UnAssigned", "Unassigned", "new", "assigned", "unknown" },
|
||||||
|
nameof(WorkOrderStatus.InProgress) => new[] { "In Progress", "InProgress", "in_progress" },
|
||||||
|
nameof(WorkOrderStatus.OnHold) => new[] { "On Hold", "OnHold", "on_hold" },
|
||||||
|
nameof(WorkOrderStatus.Completed) => new[] { "Done", "Completed", "completed" },
|
||||||
|
nameof(WorkOrderStatus.Cancelled) => new[] { "Cancelled", "cancelled", "Canceled" },
|
||||||
|
_ => new[] { normalized },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> ExpandCanonicalFilters(IEnumerable<string>? canonicalStatuses)
|
||||||
|
{
|
||||||
|
if (canonicalStatuses == null)
|
||||||
|
return Array.Empty<string>();
|
||||||
|
|
||||||
|
return canonicalStatuses
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.SelectMany(ExpandCanonicalForFilter)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ToStorage(string canonical)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeToCanonical(canonical);
|
||||||
|
return CanonicalToStorage.TryGetValue(normalized, out var storage)
|
||||||
|
? storage
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? FromDisplay(string? statusDisplay)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(statusDisplay))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (DisplayToCanonical.TryGetValue(statusDisplay.Trim(), out var canonical))
|
||||||
|
return ToStorage(canonical);
|
||||||
|
|
||||||
|
return ToStorage(NormalizeToCanonical(statusDisplay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderAdvancedSearchService
|
||||||
|
{
|
||||||
|
Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
|
||||||
|
WorkOrderAdvancedSearchQueryDto query,
|
||||||
|
string? currentUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardCancelService
|
||||||
|
{
|
||||||
|
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardCreateService
|
||||||
|
{
|
||||||
|
Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs
Normal file
11
SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardMapper
|
||||||
|
{
|
||||||
|
WorkOrderBoardItemDto MapToBoardItem(
|
||||||
|
WorkOrderBoardRowDto row,
|
||||||
|
DateOnly? today = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs
Normal file
10
SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardService
|
||||||
|
{
|
||||||
|
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId);
|
||||||
|
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardUpdateService
|
||||||
|
{
|
||||||
|
Task<WorkOrderBoardRowDto> PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs
Normal file
11
SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderCommentService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<WorkOrderCommentDto>> GetCommentsAsync(int workOrderId);
|
||||||
|
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs
Normal file
16
SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderCompletionService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||||
|
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
|
||||||
|
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
||||||
|
int workOrderId,
|
||||||
|
WorkOrderCompletionDocUploadDto request,
|
||||||
|
string fileUrl,
|
||||||
|
string? actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs
Normal file
10
SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderDetailService
|
||||||
|
{
|
||||||
|
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId);
|
||||||
|
Task<IReadOnlyList<WorkOrderAuditEntryDto>> GetAuditAsync(int workOrderId, int limit = 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs
Normal file
12
SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderMediaService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<WorkOrderMediaFileDto>> GetMediaAsync(int workOrderId);
|
||||||
|
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
|
||||||
|
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs
Normal file
16
SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderIngestService
|
||||||
|
{
|
||||||
|
Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
|
||||||
|
IReadOnlyList<WorkOrderIngestPayloadDto> items,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IWorkOrderOpsHealthService
|
||||||
|
{
|
||||||
|
Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs
Normal file
30
SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
namespace SeaHaven.Services.Interfaces
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps work order status between DB strings, API canonical values, and FE display labels.
|
||||||
|
/// See docs/spikes/status-mapping.md (Gate G2).
|
||||||
|
/// </summary>
|
||||||
|
public interface IWorkOrderStatusMapper
|
||||||
|
{
|
||||||
|
/// <summary>Normalizes a raw DB/Sync value to API canonical status (enum name).</summary>
|
||||||
|
string NormalizeToCanonical(string? dbStatus);
|
||||||
|
|
||||||
|
/// <summary>Maps canonical status to Schedule Board display label (StatusDisplay).</summary>
|
||||||
|
string ToDisplay(string canonical, bool isUnassigned = false);
|
||||||
|
|
||||||
|
/// <summary>True when work order should not be treated as past due.</summary>
|
||||||
|
bool IsTerminal(string canonical);
|
||||||
|
|
||||||
|
/// <summary>All DB string variants that match a canonical filter value.</summary>
|
||||||
|
IReadOnlyList<string> ExpandCanonicalForFilter(string canonical);
|
||||||
|
|
||||||
|
/// <summary>Expands multiple canonical filters to distinct DB values for SQL IN clauses.</summary>
|
||||||
|
IReadOnlyList<string> ExpandCanonicalFilters(IEnumerable<string>? canonicalStatuses);
|
||||||
|
|
||||||
|
/// <summary>Fase 2 write path — persists legacy-compatible string (Blazor/Sync).</summary>
|
||||||
|
string ToStorage(string canonical);
|
||||||
|
|
||||||
|
/// <summary>Fase 2 write path — FE display label to storage string.</summary>
|
||||||
|
string? FromDisplay(string? statusDisplay);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
using FluentValidation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Validation
|
||||||
|
{
|
||||||
|
public interface IWorkOrderBoardCreateValidation : IValidator<WorkOrderBoardCreateRequestDto>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderBoardCreateValidation : AbstractValidator<WorkOrderBoardCreateRequestDto>, IWorkOrderBoardCreateValidation
|
||||||
|
{
|
||||||
|
public WorkOrderBoardCreateValidation()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.WorkOrderType)
|
||||||
|
.IsInEnum().WithMessage("workOrderType is required and must be a valid value.");
|
||||||
|
|
||||||
|
RuleFor(x => x.SiteCode)
|
||||||
|
.NotEmpty().WithMessage("siteCode is required.")
|
||||||
|
.MaximumLength(32);
|
||||||
|
|
||||||
|
RuleFor(x => x.TargetWeek)
|
||||||
|
.NotNull().WithMessage("targetWeek is required when scheduleWeekOnly is true.")
|
||||||
|
.When(x => x.ScheduleWeekOnly == true);
|
||||||
|
|
||||||
|
RuleFor(x => x.Description)
|
||||||
|
.MaximumLength(2000)
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.Description));
|
||||||
|
|
||||||
|
RuleFor(x => x.Trade)
|
||||||
|
.MaximumLength(128)
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.Trade));
|
||||||
|
|
||||||
|
RuleFor(x => x.LocationId)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.When(x => x.LocationId.HasValue);
|
||||||
|
|
||||||
|
RuleFor(x => x.VendorId)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.When(x => x.VendorId.HasValue);
|
||||||
|
|
||||||
|
RuleFor(x => x.PocContactId)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.When(x => x.PocContactId.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
151
SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
Normal file
151
SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardCancelServiceTests
|
||||||
|
{
|
||||||
|
private static (ApplicationDbContext Context, WorkOrderBoardCancelService Cancel, WorkOrderBoardUpdateService Update) CreateSut()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var cancel = new WorkOrderBoardCancelService(context, boardService, audit);
|
||||||
|
var update = new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||||
|
return (context, cancel, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToVersion(WorkOrder workOrder)
|
||||||
|
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_SetsLifecycleStatusCanceled()
|
||||||
|
{
|
||||||
|
var (context, cancel, _) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
Status = "Incomplete",
|
||||||
|
SiteCode = "BK5",
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await cancel.CancelAsync(1, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
|
||||||
|
Assert.Equal("Canceled", result.LifecycleStatusLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_IsIdempotentWhenAlreadyCanceled()
|
||||||
|
{
|
||||||
|
var (context, cancel, _) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Canceled,
|
||||||
|
Status = "Canceled",
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var before = await context.WorkOrderAuditLogs.CountAsync();
|
||||||
|
var result = await cancel.CancelAsync(1, "actor-1");
|
||||||
|
var after = await context.WorkOrderAuditLogs.CountAsync();
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
|
||||||
|
Assert.Equal(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_BlocksWhenComplete()
|
||||||
|
{
|
||||||
|
var (context, cancel, _) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Complete,
|
||||||
|
Status = "Complete"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
|
||||||
|
Assert.Equal("CancelNotAllowed", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_BlocksWhenClosed()
|
||||||
|
{
|
||||||
|
var (context, cancel, _) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Closed,
|
||||||
|
Status = "Closed"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
|
||||||
|
Assert.Equal("CancelNotAllowed", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancel_WritesStatusChangedAudit()
|
||||||
|
{
|
||||||
|
var (context, cancel, _) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
Status = "Scheduled",
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await cancel.CancelAsync(1, "actor-1");
|
||||||
|
|
||||||
|
var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged");
|
||||||
|
Assert.Equal("Canceled", log.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Patch_AfterCancel_IsBlocked()
|
||||||
|
{
|
||||||
|
var (context, cancel, update) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
Status = "Incomplete",
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await cancel.CancelAsync(1, "actor-1");
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||||
|
Value = "NEW",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
184
SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
Normal file
184
SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardConcurrencyTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext(string dbName)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(dbName)
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
return new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParallelVendorAndStatusEdits_OnDifferentAggregates_Succeed()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
await using var seedContext = CreateContext(dbName);
|
||||||
|
seedContext.Vendors.Add(new Vendor { Id = 3, CompanyName = "Parallel Vendor" });
|
||||||
|
var dispatch = new Dispatch
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
VendorId = 3,
|
||||||
|
WorkOrderId = 1,
|
||||||
|
RowVersion = new byte[] { 9, 0, 0, 0, 0, 0, 0, 9 }
|
||||||
|
};
|
||||||
|
seedContext.Dispatches.Add(dispatch);
|
||||||
|
seedContext.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
PrimaryDispatchId = 10,
|
||||||
|
RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 }
|
||||||
|
});
|
||||||
|
await seedContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
await using var woContext = CreateContext(dbName);
|
||||||
|
await using var dispatchContext = CreateContext(dbName);
|
||||||
|
var woService = CreateService(woContext);
|
||||||
|
var dispatchService = CreateService(dispatchContext);
|
||||||
|
|
||||||
|
var wo = await woContext.workOrders.FindAsync(1);
|
||||||
|
var disp = await dispatchContext.Dispatches.FindAsync(10);
|
||||||
|
|
||||||
|
var statusTask = woService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||||
|
Value = "Scheduled",
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
|
||||||
|
}, "dispatcher-a");
|
||||||
|
|
||||||
|
var vendorTask = dispatchService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.VendorId,
|
||||||
|
Value = "3",
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!),
|
||||||
|
DispatchVersion = Convert.ToBase64String(disp!.RowVersion!),
|
||||||
|
PrimaryDispatchId = 10
|
||||||
|
}, "dispatcher-b");
|
||||||
|
|
||||||
|
await Task.WhenAll(statusTask, vendorTask);
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Scheduled, statusTask.Result.LifecycleStatus);
|
||||||
|
Assert.Equal(3, vendorTask.Result.VendorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StaleWorkOrderVersion_ThrowsConcurrencyWithCurrentState()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
await using var context1 = CreateContext(dbName);
|
||||||
|
context1.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
SiteCode = "A",
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
await context1.SaveChangesAsync();
|
||||||
|
|
||||||
|
await using var context2 = CreateContext(dbName);
|
||||||
|
var wo2 = await context2.workOrders.FirstAsync();
|
||||||
|
wo2.SiteCode = "B";
|
||||||
|
await context2.SaveChangesAsync();
|
||||||
|
|
||||||
|
await using var patchContext = CreateContext(dbName);
|
||||||
|
var service = CreateService(patchContext);
|
||||||
|
var staleVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
|
||||||
|
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||||
|
Value = "C",
|
||||||
|
WorkOrderVersion = staleVersion
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.NotNull(ex.CurrentState);
|
||||||
|
Assert.Equal("B", ex.CurrentState!.SiteCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RescheduleAuditCount_MatchesChangedFields()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
await using var context = CreateContext(dbName);
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 20),
|
||||||
|
RescheduleCount = 0,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = CreateService(context);
|
||||||
|
var wo = await context.workOrders.FindAsync(1);
|
||||||
|
|
||||||
|
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = "2026-06-25",
|
||||||
|
WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
var auditCount = await context.WorkOrderAuditLogs.CountAsync();
|
||||||
|
Assert.Equal(2, auditCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SecondEditWithStaleVersion_FailsAfterFirstEdit()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
await using var seed = CreateContext(dbName);
|
||||||
|
seed.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
SiteCode = "A",
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 }
|
||||||
|
});
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
|
||||||
|
var originalVersion = Convert.ToBase64String(new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 });
|
||||||
|
|
||||||
|
await using (var ctx1 = CreateContext(dbName))
|
||||||
|
{
|
||||||
|
var svc1 = CreateService(ctx1);
|
||||||
|
await svc1.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||||
|
Value = "B",
|
||||||
|
WorkOrderVersion = originalVersion
|
||||||
|
}, "user-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var ctx2 = CreateContext(dbName);
|
||||||
|
var svc2 = CreateService(ctx2);
|
||||||
|
await Assert.ThrowsAsync<WorkOrderBoardConcurrencyException>(() =>
|
||||||
|
svc2.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||||
|
Value = "C",
|
||||||
|
WorkOrderVersion = originalVersion
|
||||||
|
}, "user-2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
180
SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
Normal file
180
SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardCreateServiceTests
|
||||||
|
{
|
||||||
|
private static (ApplicationDbContext Context, WorkOrderBoardCreateService Service) CreateSut()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var validator = new WorkOrderBoardCreateValidation();
|
||||||
|
var service = new WorkOrderBoardCreateService(context, boardData, boardService, audit, validator);
|
||||||
|
return (context, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Minimal_ReturnsIncomplete()
|
||||||
|
{
|
||||||
|
var (_, service) = CreateSut();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
|
||||||
|
Assert.Equal("BK5", result.SiteCode);
|
||||||
|
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.WoNumber));
|
||||||
|
Assert.Equal(11, result.WoNumber!.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithManualWoNumber_NormalizesTo11Digits()
|
||||||
|
{
|
||||||
|
var (_, service) = CreateSut();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WoNumber = "12345",
|
||||||
|
WorkOrderType = WorkOrderType.PO,
|
||||||
|
SiteCode = "DAL"
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal("00000012345", result.WoNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_DuplicateWoNumber_Throws()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder { InternalWONumber = "00000099999", WorkerOrderNumber = "00000099999" });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WoNumber = "99999",
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_AutoSchedulesWhenDateAndAssignee()
|
||||||
|
{
|
||||||
|
var (_, service) = CreateSut();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
AssignTo = "dispatcher-1",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 25)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||||
|
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WeekOnlyScheduling_SetsTargetWeek()
|
||||||
|
{
|
||||||
|
var (_, service) = CreateSut();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
ScheduleWeekOnly = true,
|
||||||
|
TargetWeek = new DateOnly(2026, 6, 22)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.True(result.ScheduleWeekOnly);
|
||||||
|
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WeekOnlyWithoutTargetWeek_ThrowsValidation()
|
||||||
|
{
|
||||||
|
var (_, service) = CreateSut();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
|
||||||
|
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
ScheduleWeekOnly = true
|
||||||
|
}, "actor-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithVendor_CreatesPrimaryDispatch()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
VendorId = 5,
|
||||||
|
ApptDate = new DateTime(2026, 6, 26)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(5, result.VendorId);
|
||||||
|
Assert.NotNull(result.PrimaryDispatchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_CreatesFieldLocksForPopulatedFields()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
|
||||||
|
await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
Description = "Test WO"
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
var locks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||||
|
Assert.Contains(locks, l => l.FieldName == "SiteCode");
|
||||||
|
Assert.Contains(locks, l => l.FieldName == "WorkOrderType");
|
||||||
|
Assert.Contains(locks, l => l.FieldName == "Description");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WritesCreateAuditEvent()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
|
||||||
|
await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
var logs = await context.WorkOrderAuditLogs.ToListAsync();
|
||||||
|
Assert.Contains(logs, l => l.Action == "Create");
|
||||||
|
Assert.Contains(logs, l => l.Action == "FieldChanged" && l.FieldName == "SiteCode");
|
||||||
|
}
|
||||||
|
}
|
||||||
101
SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs
Normal file
101
SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardCreateSyncLockTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AfterShocCreate_SyncRejectsLockedSiteCode()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var createService = new WorkOrderBoardCreateService(
|
||||||
|
context,
|
||||||
|
boardData,
|
||||||
|
boardService,
|
||||||
|
audit,
|
||||||
|
new WorkOrderBoardCreateValidation());
|
||||||
|
var policy = new SyncFieldMergePolicy(fieldLocks, audit);
|
||||||
|
|
||||||
|
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
}, "dispatcher-1");
|
||||||
|
|
||||||
|
var wo = await context.workOrders.SingleAsync();
|
||||||
|
var syncContext = new WorkOrderSyncContext
|
||||||
|
{
|
||||||
|
WorkOrder = wo,
|
||||||
|
FieldLocks = fieldLocks,
|
||||||
|
Audit = audit
|
||||||
|
};
|
||||||
|
|
||||||
|
var applied = await policy.TryApplyAsync(syncContext, "SiteCode", "SYNC-SITE");
|
||||||
|
|
||||||
|
Assert.False(applied);
|
||||||
|
Assert.Equal("BK5", wo.SiteCode);
|
||||||
|
|
||||||
|
var rejected = await context.WorkOrderAuditLogs
|
||||||
|
.SingleAsync(l => l.Action == "SyncRejected" && l.FieldName == "SiteCode");
|
||||||
|
Assert.Equal("SYNC-SITE", rejected.NewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AfterShocCreate_SyncCanUpdateUnlockedField_NotInShocSet()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var createService = new WorkOrderBoardCreateService(
|
||||||
|
context,
|
||||||
|
boardData,
|
||||||
|
boardService,
|
||||||
|
audit,
|
||||||
|
new WorkOrderBoardCreateValidation());
|
||||||
|
var policy = new SyncFieldMergePolicy(fieldLocks, audit);
|
||||||
|
|
||||||
|
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
}, "dispatcher-1");
|
||||||
|
|
||||||
|
var wo = await context.workOrders.SingleAsync();
|
||||||
|
wo.Customer = "Original";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var syncContext = new WorkOrderSyncContext
|
||||||
|
{
|
||||||
|
WorkOrder = wo,
|
||||||
|
FieldLocks = fieldLocks,
|
||||||
|
Audit = audit
|
||||||
|
};
|
||||||
|
|
||||||
|
// Customer is not in SyncFieldMergePolicy apply switch — use Description which wasn't set on create
|
||||||
|
var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync");
|
||||||
|
|
||||||
|
Assert.True(applied);
|
||||||
|
Assert.Equal("From sync", wo.Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
Normal file
56
SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardMutationRulesTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LifecycleStatus.Canceled, true)]
|
||||||
|
[InlineData(LifecycleStatus.Closed, true)]
|
||||||
|
[InlineData(LifecycleStatus.Complete, true)]
|
||||||
|
[InlineData(LifecycleStatus.Scheduled, false)]
|
||||||
|
[InlineData(LifecycleStatus.Incomplete, false)]
|
||||||
|
public void IsReadOnly_ReflectsTerminalStatuses(LifecycleStatus status, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, WorkOrderBoardMutationRules.IsReadOnly(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldBlockStatusChangeWhenPastDue_BlocksOnlyLifecycleStatus()
|
||||||
|
{
|
||||||
|
Assert.True(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
|
||||||
|
WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true));
|
||||||
|
Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(
|
||||||
|
WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee()
|
||||||
|
{
|
||||||
|
Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||||
|
LifecycleStatus.Incomplete,
|
||||||
|
new DateTime(2026, 6, 25),
|
||||||
|
"dispatcher-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldAutoSchedule_FalseWhenAlreadyScheduled()
|
||||||
|
{
|
||||||
|
Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule(
|
||||||
|
LifecycleStatus.Scheduled,
|
||||||
|
new DateTime(2026, 6, 25),
|
||||||
|
"dispatcher-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsReschedule_WhenPreviousDateExistsAndChanges()
|
||||||
|
{
|
||||||
|
Assert.True(WorkOrderBoardMutationRules.IsReschedule(
|
||||||
|
new DateTime(2026, 6, 20),
|
||||||
|
new DateTime(2026, 6, 25)));
|
||||||
|
Assert.False(WorkOrderBoardMutationRules.IsReschedule(
|
||||||
|
null,
|
||||||
|
new DateTime(2026, 6, 25)));
|
||||||
|
}
|
||||||
|
}
|
||||||
238
SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
Normal file
238
SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Helpers;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardSearchFilterTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("BK5", 1)]
|
||||||
|
[InlineData("10000000001", 1)]
|
||||||
|
[InlineData("HVAC Tech", 1)]
|
||||||
|
[InlineData("Acme HVAC", 1)]
|
||||||
|
[InlineData("sched", 1)]
|
||||||
|
public async Task Apply_MatchesExpectedField(string search, int expectedId)
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedSearchFixtures(context);
|
||||||
|
|
||||||
|
var ids = await WorkOrderBoardSearchFilter
|
||||||
|
.Apply(context.workOrders.AsQueryable(), search)
|
||||||
|
.Select(w => w.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.Contains(expectedId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeSearch_IgnoresSingleCharacter()
|
||||||
|
{
|
||||||
|
Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch("a"));
|
||||||
|
Assert.Null(WorkOrderBoardSearchFilter.NormalizeSearch(" "));
|
||||||
|
Assert.Equal("ab", WorkOrderBoardSearchFilter.NormalizeSearch(" ab "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_LifecycleLabel_MatchesScheduled()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
await SeedSearchFixtures(context);
|
||||||
|
|
||||||
|
var ids = await WorkOrderBoardSearchFilter
|
||||||
|
.Apply(context.workOrders.AsQueryable(), "scheduled")
|
||||||
|
.Select(w => w.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.Single(ids);
|
||||||
|
Assert.Equal(1, ids[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedSearchFixtures(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
context.Vendors.Add(new Vendor
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
CompanyName = "Acme HVAC",
|
||||||
|
ContactName = "HVAC Tech",
|
||||||
|
Phone = "555-0100"
|
||||||
|
});
|
||||||
|
context.Dispatches.Add(new Dispatch { Id = 10, VendorId = 1 });
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = 10
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderBoardSearchCountsTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardRows_SearchReducesScheduledAndKeepsTotal()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
SiteCode = "BK6",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
ScheduledDate = null,
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new WorkOrderBoardDataService(context);
|
||||||
|
var result = await service.GetBoardRowsAsync(new SeaHaven.DataServices.Interfaces.WorkOrderBoardQuery(
|
||||||
|
weekStart,
|
||||||
|
weekStart.AddDays(4),
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"BK5",
|
||||||
|
null));
|
||||||
|
|
||||||
|
Assert.Equal(2, result.ScheduledTotalBeforeSearch);
|
||||||
|
Assert.Single(result.ScheduledRows);
|
||||||
|
Assert.Equal("BK5", result.ScheduledRows[0].SiteCode);
|
||||||
|
Assert.Single(result.UnscheduledRows);
|
||||||
|
Assert.Equal("BK5", result.UnscheduledRows[0].SiteCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderSearchDateRangeResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_ThisWeek_UsesIsoMonday()
|
||||||
|
{
|
||||||
|
var reference = new DateOnly(2026, 6, 24);
|
||||||
|
var range = SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve(
|
||||||
|
SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
reference);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 6, 22), range.From);
|
||||||
|
Assert.Equal(new DateOnly(2026, 6, 28), range.To);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_Custom_RequiresDates()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() =>
|
||||||
|
SeaHaven.Services.Helpers.WorkOrderSearchDateRangeResolver.Resolve(
|
||||||
|
SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
|
||||||
|
Assert.Contains("dateFrom", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderAdvancedSearchServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_FiltersBySiteAndPaginates()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
SiteCode = "BK5",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000001"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
SiteCode = "BK6",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
InternalWONumber = "10000000002"
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||||
|
Sites = new List<string> { "BK5" },
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 50
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Single(result.Items);
|
||||||
|
Assert.Equal("BK5", result.Items.First().SiteCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAsync_ClampsPageSizeToMax()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||||
|
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||||
|
{
|
||||||
|
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||||
|
PageSize = 500
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
Assert.Equal(100, result.PageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
211
SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
Normal file
211
SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardDataServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardRows_ReturnsScheduledInWeekAndUnscheduled()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC", ContactName = "Tech", Phone = "555-0100" });
|
||||||
|
context.Dispatches.Add(new Dispatch
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
VendorId = 1,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
PrimaryDispatchId = 10,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
InternalWONumber = "10000000002",
|
||||||
|
ScheduledDate = null,
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
SiteCode = "BK6"
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
InternalWONumber = "10000000003",
|
||||||
|
ScheduledDate = new DateTime(2026, 7, 1),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new WorkOrderBoardDataService(context);
|
||||||
|
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
|
||||||
|
weekStart,
|
||||||
|
weekStart.AddDays(4),
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
|
||||||
|
Assert.Single(result.ScheduledRows);
|
||||||
|
Assert.Equal(1, result.ScheduledRows[0].Id);
|
||||||
|
Assert.Equal("Acme HVAC", result.ScheduledRows[0].VendorName);
|
||||||
|
Assert.Single(result.UnscheduledRows);
|
||||||
|
Assert.Equal(2, result.UnscheduledRows[0].Id);
|
||||||
|
Assert.Equal(1, result.ScheduledTotalBeforeSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardRows_FiltersByDispatcher()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AssignTo = "user-a",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
AssignTo = "user-b",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new WorkOrderBoardDataService(context);
|
||||||
|
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
|
||||||
|
weekStart,
|
||||||
|
weekStart.AddDays(4),
|
||||||
|
new[] { "user-a" },
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
|
||||||
|
Assert.Single(result.ScheduledRows);
|
||||||
|
Assert.Equal("user-a", result.ScheduledRows[0].AssignTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardRows_MyWorkOrders_FiltersCurrentUser()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AssignTo = "me",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
AssignTo = "other",
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 23),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var service = new WorkOrderBoardDataService(context);
|
||||||
|
var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery(
|
||||||
|
weekStart,
|
||||||
|
weekStart.AddDays(4),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"me"));
|
||||||
|
|
||||||
|
Assert.Single(result.ScheduledRows);
|
||||||
|
Assert.Equal(1, result.ScheduledRows[0].Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderBoardServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardAsync_ComputesCountsAndPastDue()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
var utcPast = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
ScheduledDate = utcPast.AddDays(-2),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dataService = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(dataService);
|
||||||
|
|
||||||
|
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||||
|
{
|
||||||
|
WeekStart = weekStart
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
Assert.Equal(1, response.Counts.Total);
|
||||||
|
Assert.Single(response.Scheduled);
|
||||||
|
Assert.True(response.Scheduled[0].IsPastDue);
|
||||||
|
Assert.Equal("wednesday", response.Scheduled[0].DayGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||||
|
{
|
||||||
|
WeekStart = new DateOnly(2026, 6, 22),
|
||||||
|
WeekEnd = new DateOnly(2026, 6, 15)
|
||||||
|
}, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
227
SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
Normal file
227
SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderBoardUpdateServiceTests
|
||||||
|
{
|
||||||
|
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var service = new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||||
|
return (context, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToVersion(WorkOrder workOrder)
|
||||||
|
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||||
|
|
||||||
|
private static string ToVersion(Dispatch dispatch)
|
||||||
|
=> Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 });
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_AutoSchedulesIncompleteToScheduled()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
AssignTo = "user-a",
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = "2026-06-25",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
|
||||||
|
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_IncrementsRescheduleCount()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 20),
|
||||||
|
RescheduleCount = 1,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.ScheduledDate,
|
||||||
|
Value = "2026-06-25",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(2, result.RescheduleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_BlocksStatusChangeWhenPastDue()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = DateTime.UtcNow.Date.AddDays(-3),
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.LifecycleStatus,
|
||||||
|
Value = "Complete",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_BlocksEditWhenCanceled()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Canceled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||||
|
Value = "BK5",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_VendorCreatesPrimaryDispatch()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co" });
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.VendorId,
|
||||||
|
Value = "5",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.NotNull(result.PrimaryDispatchId);
|
||||||
|
Assert.Equal(5, result.VendorId);
|
||||||
|
Assert.Equal(1, await context.Dispatches.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_CreatesFieldLockAndAuditPerField()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
SiteCode = "OLD",
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.SiteCode,
|
||||||
|
Value = "BK5",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.Action == AuditActionType.FieldChanged.ToString()).ToListAsync());
|
||||||
|
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_NormalizesWoNumber()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "00000009999",
|
||||||
|
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.WoNumber,
|
||||||
|
Value = "12345",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal("00000012345", result.WoNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_RejectsDuplicateWoNumber()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
|
||||||
|
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var wo = await context.workOrders.FindAsync(1);
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.WoNumber,
|
||||||
|
Value = "22222",
|
||||||
|
WorkOrderVersion = ToVersion(wo!)
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
Normal file
80
SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderDerivedFieldsTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Today = new(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var result = WorkOrderDerivedFields.IsPastDue(
|
||||||
|
Today.AddDays(-1),
|
||||||
|
LifecycleStatus.Scheduled,
|
||||||
|
Today);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPastDue_WhenScheduledToday_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var result = WorkOrderDerivedFields.IsPastDue(
|
||||||
|
Today,
|
||||||
|
LifecycleStatus.Scheduled,
|
||||||
|
Today);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPastDue_WhenComplete_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var result = WorkOrderDerivedFields.IsPastDue(
|
||||||
|
Today.AddDays(-3),
|
||||||
|
LifecycleStatus.Complete,
|
||||||
|
Today);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPastDue_WhenNoScheduledDate_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var result = WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Incomplete, Today);
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(DayOfWeek.Monday, "monday")]
|
||||||
|
[InlineData(DayOfWeek.Friday, "friday")]
|
||||||
|
[InlineData(DayOfWeek.Saturday, null)]
|
||||||
|
public void GetDayGroup_MapsWeekdays(DayOfWeek day, string? expected)
|
||||||
|
{
|
||||||
|
var date = new DateTime(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
while date.DayOfWeek != day)
|
||||||
|
date = date.AddDays(1);
|
||||||
|
|
||||||
|
Assert.Equal(expected, WorkOrderDerivedFields.GetDayGroup(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatApptTime_WithStartAndEnd_ReturnsRange()
|
||||||
|
{
|
||||||
|
var start = new DateTime(2026, 6, 24, 7, 0, 0);
|
||||||
|
var end = new DateTime(2026, 6, 24, 9, 0, 0);
|
||||||
|
|
||||||
|
var result = WorkOrderDerivedFields.FormatApptTime(start, end);
|
||||||
|
|
||||||
|
Assert.Equal("07:00 – 09:00", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatApptTime_WithStartOnly_ReturnsTime()
|
||||||
|
{
|
||||||
|
var start = new DateTime(2026, 6, 24, 7, 30, 0);
|
||||||
|
Assert.Equal("07:30", WorkOrderDerivedFields.FormatApptTime(start, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
284
SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
Normal file
284
SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Exceptions;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderDocStatusPatchTests
|
||||||
|
{
|
||||||
|
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var service = new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||||
|
return (context, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToVersion(WorkOrder workOrder)
|
||||||
|
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Yes", DocStatus.Yes)]
|
||||||
|
[InlineData("No", DocStatus.No)]
|
||||||
|
[InlineData("NotNeeded", DocStatus.NotNeeded)]
|
||||||
|
public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected)
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.DocStatus,
|
||||||
|
Value = value,
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(expected, result.DocStatus);
|
||||||
|
var audit = await context.WorkOrderAuditLogs.SingleAsync();
|
||||||
|
Assert.Equal("DocStatus", audit.FieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Canceled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||||
|
{
|
||||||
|
Field = WorkOrderBoardFieldNames.DocStatus,
|
||||||
|
Value = "Yes",
|
||||||
|
WorkOrderVersion = ToVersion(wo)
|
||||||
|
}, "actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderDetailServiceTests
|
||||||
|
{
|
||||||
|
private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
|
var templateData = new CompletionDocTemplateDataService(context);
|
||||||
|
var service = new WorkOrderDetailService(boardService, detailData, templateData);
|
||||||
|
return (context, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDetail_ReturnsDerivedFieldsAndFeAuditMapping()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "00000000001",
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||||
|
Trade = "HVAC PM",
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
DocStatus = DocStatus.No,
|
||||||
|
Description = "Leak",
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
context.CompletionDocTemplates.Add(new CompletionDocTemplate
|
||||||
|
{
|
||||||
|
Name = "HVAC PM Completion",
|
||||||
|
ServiceKey = "HVAC PM",
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
TemplateUrl = "https://example.com/hvac.pdf",
|
||||||
|
IsActive = true
|
||||||
|
});
|
||||||
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = 1,
|
||||||
|
EventType = "System",
|
||||||
|
Action = "WeekRolled",
|
||||||
|
FieldName = "CarriedOver",
|
||||||
|
OldValue = "0",
|
||||||
|
NewValue = "1",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
context.Comments.Add(new Comments
|
||||||
|
{
|
||||||
|
WorkerOrderId = 1,
|
||||||
|
UserId = "user-1",
|
||||||
|
Commenttext = "Called vendor",
|
||||||
|
CreatedDate = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var detail = await service.GetDetailAsync(1);
|
||||||
|
|
||||||
|
Assert.NotNull(detail);
|
||||||
|
Assert.True(detail!.Info.IsPastDue);
|
||||||
|
Assert.Equal("HVAC PM", detail.Info.Trade);
|
||||||
|
Assert.Equal("Leak", detail.Info.Description);
|
||||||
|
Assert.NotNull(detail.Completion.Template);
|
||||||
|
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
|
||||||
|
Assert.Single(detail.Comments);
|
||||||
|
Assert.Equal("user-1", detail.Comments[0].AuthorId);
|
||||||
|
Assert.Equal("Called vendor", detail.Comments[0].Text);
|
||||||
|
Assert.Single(detail.Audit);
|
||||||
|
Assert.Equal("system", detail.Audit[0].Type);
|
||||||
|
Assert.Equal("WeekRolled", detail.Audit[0].Action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderCompletionServiceTests
|
||||||
|
{
|
||||||
|
private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
var templateData = new CompletionDocTemplateDataService(context);
|
||||||
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var service = new WorkOrderCompletionService(context, templateData, detailData, audit);
|
||||||
|
return (context, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDoc_SetsDocStatusYesAndSignOff()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
Trade = "HVAC PM",
|
||||||
|
DocStatus = DocStatus.No,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await service.UploadCompletionDocAsync(
|
||||||
|
1,
|
||||||
|
new WorkOrderCompletionDocUploadDto { SignOffName = "Jane Dispatcher" },
|
||||||
|
"https://example.com/signed.pdf",
|
||||||
|
"actor-1");
|
||||||
|
|
||||||
|
Assert.Equal(DocStatus.Yes, result.DocStatus);
|
||||||
|
Assert.Equal("https://example.com/signed.pdf", result.SignOffAttachment);
|
||||||
|
Assert.Equal("Jane Dispatcher", result.SignOffName);
|
||||||
|
|
||||||
|
var updated = await context.workOrders.FindAsync(1);
|
||||||
|
Assert.Equal(DocStatus.Yes, updated!.DocStatus);
|
||||||
|
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadCompletionDoc_ReadOnly_Throws()
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Complete
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.UploadCompletionDocAsync(
|
||||||
|
1,
|
||||||
|
new WorkOrderCompletionDocUploadDto(),
|
||||||
|
"https://example.com/signed.pdf",
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("ReadOnly", ex.Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderCommentServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AddComment_ReturnsFeSchema()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
|
var commentData = new CommentDataService(context);
|
||||||
|
var service = new WorkOrderCommentService(detailData, commentData);
|
||||||
|
|
||||||
|
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
|
||||||
|
|
||||||
|
Assert.Equal("user-abc", result.AuthorId);
|
||||||
|
Assert.Equal("Note", result.Text);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result.Time));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderMediaServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var context = new ApplicationDbContext(options);
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
BeforPhotoAttachment = "https://example.com/before.jpg",
|
||||||
|
AfterPhotoAttachment = "https://example.com/after.jpg"
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/extra.pdf",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
|
var service = new WorkOrderMediaService(context, detailData);
|
||||||
|
var media = await service.GetMediaAsync(1);
|
||||||
|
|
||||||
|
Assert.Equal(3, media.Count);
|
||||||
|
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Before);
|
||||||
|
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.After);
|
||||||
|
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs
Normal file
170
SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderPhase7CoexistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IngestFieldMapper_MapsDynamoStatus()
|
||||||
|
{
|
||||||
|
Assert.Equal("Open", WorkOrderIngestFieldMapper.MapStatus("new"));
|
||||||
|
Assert.Equal("In Progress", WorkOrderIngestFieldMapper.MapStatus("in_progress"));
|
||||||
|
Assert.Equal("Done", WorkOrderIngestFieldMapper.MapStatus("completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestService_CreatesThenUpdates_IdempotentByExternalId()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks, audit), fieldLocks, audit);
|
||||||
|
|
||||||
|
var payload = new WorkOrderIngestPayloadDto
|
||||||
|
{
|
||||||
|
ExternalWorkOrderId = "ext-100",
|
||||||
|
Description = "Initial",
|
||||||
|
WoStatus = "new",
|
||||||
|
Severity = "2",
|
||||||
|
SiteCode = "BK1"
|
||||||
|
};
|
||||||
|
|
||||||
|
var create = await ingest.UpsertBatchAsync(new[] { payload });
|
||||||
|
Assert.Equal(1, create.Created);
|
||||||
|
Assert.Equal(0, create.Updated);
|
||||||
|
|
||||||
|
payload.Description = "Updated via ingest";
|
||||||
|
var update = await ingest.UpsertBatchAsync(new[] { payload });
|
||||||
|
Assert.Equal(0, update.Created);
|
||||||
|
Assert.Equal(1, update.Updated);
|
||||||
|
|
||||||
|
var wo = await context.workOrders.SingleAsync();
|
||||||
|
Assert.Equal("Updated via ingest", wo.Description);
|
||||||
|
Assert.Equal("ext-100", wo.ExternalWorkOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestRespectsFieldLock_AfterShocBoardCreate()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||||
|
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||||
|
var createService = new WorkOrderBoardCreateService(
|
||||||
|
context, boardData, boardService, audit, new WorkOrderBoardCreateValidation());
|
||||||
|
var policy = new SyncFieldMergePolicy(fieldLocks, audit);
|
||||||
|
var ingest = new WorkOrderIngestService(context, policy, fieldLocks, audit);
|
||||||
|
|
||||||
|
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||||
|
{
|
||||||
|
WorkOrderType = WorkOrderType.PM,
|
||||||
|
SiteCode = "BK5"
|
||||||
|
}, "dispatcher-1");
|
||||||
|
|
||||||
|
var wo = await context.workOrders.SingleAsync();
|
||||||
|
wo.ExternalWorkOrderId = "ext-shoc-1";
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
await ingest.UpsertBatchAsync(new[]
|
||||||
|
{
|
||||||
|
new WorkOrderIngestPayloadDto
|
||||||
|
{
|
||||||
|
ExternalWorkOrderId = "ext-shoc-1",
|
||||||
|
SiteCode = "SYNC-SITE"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("BK5", wo.SiteCode);
|
||||||
|
Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => l.Action == "SyncRejected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpsHealth_ReturnsCoexistenceMetrics()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
|
||||||
|
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||||
|
{
|
||||||
|
WorkOrderId = 1,
|
||||||
|
FieldName = "SiteCode",
|
||||||
|
LockedAt = DateTime.UtcNow,
|
||||||
|
LockedByUserId = "u1"
|
||||||
|
});
|
||||||
|
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||||
|
{
|
||||||
|
WorkOrderId = 1,
|
||||||
|
Action = "SyncRejected",
|
||||||
|
FieldName = "Description",
|
||||||
|
CreatedAt = DateTime.UtcNow.AddHours(-1)
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var runState = new WorkOrderJobRunStateAccessor
|
||||||
|
{
|
||||||
|
State = { LastWeekRolledRunUtc = DateTime.UtcNow.AddDays(-1) }
|
||||||
|
};
|
||||||
|
|
||||||
|
var healthOptions = Options.Create(new WorkOrderOpsHealthOptions
|
||||||
|
{
|
||||||
|
SyncEnabled = true,
|
||||||
|
IngestEnabled = true,
|
||||||
|
LegacyDeprecationEnabled = true,
|
||||||
|
LegacySunsetDate = "2026-12-31"
|
||||||
|
});
|
||||||
|
|
||||||
|
var service = new WorkOrderOpsHealthService(context, runState, healthOptions);
|
||||||
|
var health = await service.GetHealthAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, health.FieldLockCount);
|
||||||
|
Assert.Equal(1, health.SyncRejectedLast24h);
|
||||||
|
Assert.True(health.SyncEnabled);
|
||||||
|
Assert.True(health.IngestEnabled);
|
||||||
|
Assert.Equal("2026-12-31", health.LegacySunsetDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BoardAndLegacyEndpoints_CanCoexist_InSameDatabase()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
await using var context = new ApplicationDbContext(options);
|
||||||
|
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
WorkerOrderTitle = "Legacy row",
|
||||||
|
Status = "Open",
|
||||||
|
InternalWONumber = "10000001",
|
||||||
|
istemplate = false
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var boardData = new WorkOrderBoardDataService(context);
|
||||||
|
var boardService = new WorkOrderBoardService(boardData);
|
||||||
|
var count = await context.workOrders.CountAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
Assert.NotNull(boardService);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
Normal file
291
SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
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())
|
||||||
|
.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.Complete
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 18),
|
||||||
|
LifecycleStatus = LifecycleStatus.Canceled
|
||||||
|
},
|
||||||
|
new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 3,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 18),
|
||||||
|
LifecycleStatus = LifecycleStatus.Closed
|
||||||
|
});
|
||||||
|
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(1, second.Skipped);
|
||||||
|
Assert.Equal(0, second.Processed);
|
||||||
|
|
||||||
|
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(1, rerun.Skipped);
|
||||||
|
|
||||||
|
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.Complete,
|
||||||
|
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_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs
Normal file
26
SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHavenIndustries.Options;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Helper
|
||||||
|
{
|
||||||
|
public class BlazorWorkOrderSunsetGuard
|
||||||
|
{
|
||||||
|
private readonly BlazorWorkOrderSunsetOptions _options;
|
||||||
|
|
||||||
|
public BlazorWorkOrderSunsetGuard(IOptions<BlazorWorkOrderSunsetOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSunsetEnabled => _options.Enabled;
|
||||||
|
|
||||||
|
public string ShocBaseUrl => _options.ShocBaseUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
public void ThrowIfMutationsBlocked()
|
||||||
|
{
|
||||||
|
if (_options.Enabled)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Work Orders module has moved to SHOC. Editing via Blazor is disabled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue