mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +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