wip: work orders phases 1-7 (isolated from phase 0 foundation)

This commit is contained in:
Arthur Bassi 2026-06-30 10:09:48 -03:00
parent 65c1cdc7e9
commit 4f697f257a
121 changed files with 9583 additions and 0 deletions

View file

@ -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>();
}
}
}

View file

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

View file

@ -0,0 +1,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);
}
}
}

View 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))
{
}
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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\"";
}
}
}

View 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; }
}
}

View 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;
}
}

View 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; }
}
}

View file

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

View file

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

View file

@ -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;
""");
}
}
}

View file

@ -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");
}
}
}

View file

@ -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");
}
}
}

View file

@ -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");
}
}
}

View 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;
}
}

View file

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

View file

@ -0,0 +1,7 @@
namespace SeaHaven.DataServices.Board
{
public static class WorkOrderBoardConstants
{
public const int ClientSideThreshold = 300;
}
}

View 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);
}

View 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);
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Exceptions
{
public class BoardQueryException : Exception
{
public BoardQueryException(string message) : base(message)
{
}
}
}

View 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();
}
}
}

View 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));
}
}
}

View 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)));
}
}
}

View file

@ -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);
}
}

View file

@ -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)
};
}
}
}

View file

@ -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();
}
}
}

View file

@ -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);
}
}
}

View file

@ -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
};
}
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderAdvancedSearchDataService
{
Task<WorkOrderAdvancedSearchResult> SearchAsync(WorkOrderAdvancedSearchQuery query);
}
}

View file

@ -0,0 +1,10 @@
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderBoardDataService
{
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.DataServices.Interfaces
{
public record PrimaryDispatchProjection(
int WorkOrderId,
int? DispatchId,
string? VendorName,
DateTime? ApptDate,
string? DispatchStatus);
}

View file

@ -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);
}

View 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);
}

View 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);
}

View file

@ -0,0 +1,8 @@
namespace SeaHaven.Services.Board
{
public static class WorkOrderBoardConstants
{
public const int MaxWindowDays = 90;
public const int ClientSideThreshold = 300;
}
}

View 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);
}
}
}

View 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; }
}
}

View 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; }
}
}

View file

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

View file

@ -0,0 +1,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; }
}
}

View file

@ -0,0 +1,9 @@
namespace SeaHaven.Services.Exceptions
{
public class BoardQueryException : Exception
{
public BoardQueryException(string message) : base(message)
{
}
}
}

View 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; }
}
}

View 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()
};
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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
};
}
}

View 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);
}
}
}

View 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();
}
}

View 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()
};
}
}
}

View 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}";
}
}
}

View 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;
}
}
}

View 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}";
}
}

View file

@ -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);
}
}
}

View file

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

View file

@ -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
};
}
}
}

View file

@ -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.");
}
}
}

View 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;
}
}
}
}
}

View 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;
}
}
}

View 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();
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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.");
}
}
}
}

View 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
};
}
}

View 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;
}
}
}

View 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();
}
}
}

View file

@ -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; }
}
}

View 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));
}
}
}

View 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;
}
}
}

View file

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

View file

@ -0,0 +1,11 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderAdvancedSearchService
{
Task<PagedResult<WorkOrderBoardRowDto>> SearchAsync(
WorkOrderAdvancedSearchQueryDto query,
string? currentUserId);
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderBoardCancelService
{
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId);
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderBoardCreateService
{
Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId);
}
}

View file

@ -0,0 +1,11 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderBoardMapper
{
WorkOrderBoardItemDto MapToBoardItem(
WorkOrderBoardRowDto row,
DateOnly? today = null);
}
}

View 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();
}
}

View file

@ -0,0 +1,9 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderBoardUpdateService
{
Task<WorkOrderBoardRowDto> PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View file

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

View file

@ -0,0 +1,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);
}
}
}

View 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);
}
}

View 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"));
}
}

View 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");
}
}

View 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);
}
}

View 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)));
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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