feat(work-orders): isolate phase 2 inline edit with optimistic concurrency

Deliver PATCH board field updates and drop phases 3-7 code from the branch while keeping phase 0/1 dependencies required to build and test.
This commit is contained in:
Arthur Bassi 2026-07-07 11:26:13 -03:00
parent 4f697f257a
commit d040832b87
97 changed files with 295 additions and 5313 deletions

View file

@ -17,6 +17,7 @@ using System.Security.Claims;
using static System.Runtime.InteropServices.JavaScript.JSType;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.DataServices.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -42,6 +43,7 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly Helper.SendMessage _sendMessage;
private readonly Helper.VendorPortalTokenService _vendorTokens;
private readonly IConfiguration _config;
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
public WorkOrderController(
UserManager<ApplicationUser> userManager,
@ -58,7 +60,8 @@ namespace Api.SeaHavenIndustries.Controllers
IHttpContextAccessor httpContext,
Helper.SendMessage sendMessage,
Helper.VendorPortalTokenService vendorTokens,
IConfiguration config)
IConfiguration config,
IWorkOrderBoardUpdateService boardUpdateService)
{
_userManager = userManager;
_workOrderService = workOrderService;
@ -75,7 +78,40 @@ namespace Api.SeaHavenIndustries.Controllers
_sendMessage = sendMessage;
_vendorTokens = vendorTokens;
_config = config;
_boardUpdateService = boardUpdateService;
}
[Authorize]
[HttpPatch("{id:int}/board")]
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId);
return Ok(row);
}
catch (WorkOrderBoardConcurrencyException ex)
{
return Conflict(new WorkOrderBoardConflictDto
{
CurrentState = ex.CurrentState
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost]
[Route("AddWorkorder")]
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)

View file

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

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

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

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

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

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

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

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

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

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

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

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

@ -93,6 +93,51 @@ namespace Data.SeaHavenIndustries
public DbSet<Department> Departments { get; set; }
public DbSet<JobTitle> JobTitles { get; set; }
public DbSet<Region> Regions { get; set; }
public override int SaveChanges()
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpRowVersionsForInMemoryProvider();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpRowVersionsForInMemoryProvider()
{
if (!Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? true)
return;
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
foreach (var entry in ChangeTracker.Entries<Dispatch>())
{
if (entry.State == EntityState.Modified)
entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion);
}
}
private static byte[] IncrementRowVersion(byte[]? current)
{
var bytes = current != null && current.Length > 0
? (byte[])current.Clone()
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
for (var i = 0; i < bytes.Length; i++)
{
if (++bytes[i] != 0)
break;
}
return bytes;
}
}
public class ApplicationUser : IdentityUser

View file

@ -156,12 +156,4 @@ namespace Data.SeaHavenIndustries.Enums
Contact = 4,
Asset = 5
}
public enum WorkOrderMediaCategory
{
Before = 1,
After = 2,
Extra = 3,
Completion = 4
}
}

View file

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

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

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

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

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

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

View file

@ -1,10 +0,0 @@
namespace SeaHaven.DataServices.DTOs
{
public record WorkOrderBoardQuery(
DateOnly ScheduledFrom,
DateOnly ScheduledTo,
string? Search,
IReadOnlyList<string>? AssigneeIds,
IReadOnlyList<string>? DbStatuses,
int? LocationId);
}

View file

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

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

View file

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

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

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

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -33,7 +34,10 @@ namespace SeaHaven.DataServices.Implementation
|| (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart));
var unscheduledBase = baseQuery.Where(w =>
w.ScheduledDate == null && !IsTerminalExpression(w));
w.ScheduledDate == null
&& w.LifecycleStatus != LifecycleStatus.Complete
&& w.LifecycleStatus != LifecycleStatus.Canceled
&& w.LifecycleStatus != LifecycleStatus.Closed);
var scheduledTotal = await scheduledBase.CountAsync();
@ -43,8 +47,8 @@ namespace SeaHaven.DataServices.Implementation
unscheduledBase = ApplySearchFilter(unscheduledBase, query.Search);
}
var scheduledRows = await ProjectRows(scheduledBase, isUnscheduled: false);
var unscheduledRows = await ProjectRows(unscheduledBase, isUnscheduled: true);
var scheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(scheduledBase, isUnscheduled: false);
var unscheduledRows = await WorkOrderBoardProjection.ProjectRowsAsync(unscheduledBase, isUnscheduled: true);
return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal);
}
@ -65,6 +69,27 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync();
}
public async Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId)
{
var query = _context.workOrders
.AsNoTracking()
.Where(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(query, isUnscheduled: false);
return rows.FirstOrDefault();
}
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
{
return await _context.workOrders
.AsNoTracking()
.AnyAsync(w =>
w.Id != excludeWorkOrderId
&& w.istemplate != true
&& (w.IsDeleted != true || w.IsDeleted == null)
&& w.InternalWONumber == normalizedWoNumber);
}
private static IQueryable<WorkOrder> ApplyDispatcherFilter(IQueryable<WorkOrder> query, WorkOrderBoardQuery boardQuery)
{
if (boardQuery.MyWorkOrders && !string.IsNullOrEmpty(boardQuery.CurrentUserId))
@ -112,111 +137,5 @@ namespace SeaHaven.DataServices.Implementation
|| (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

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

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

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

View file

@ -6,5 +6,7 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<WorkOrderBoardQueryResult> GetBoardRowsAsync(WorkOrderBoardQuery query);
Task<IReadOnlyList<DispatcherLookupRow>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRawRow?> GetBoardRowByIdAsync(int workOrderId);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
}
}

View file

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

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

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

View file

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

@ -43,7 +43,9 @@ namespace SeaHaven.DataServices.Interfaces
string? Trade,
string? Problem,
DocStatus? DocStatus,
int? PrimaryDispatchId,
byte[]? RowVersion,
byte[]? DispatchRowVersion,
bool IsUnscheduled);
public record WorkOrderBoardQueryResult(

View file

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

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

View file

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

@ -1,4 +1,5 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHaven.Services.DTOs
{
@ -51,7 +52,30 @@ namespace SeaHaven.Services.DTOs
public string? ApptTime { get; set; }
public string? Pm { get; set; }
public DocStatus? DocStatus { get; set; }
public int? PrimaryDispatchId { get; set; }
public byte[]? RowVersion { get; set; }
public byte[]? DispatchRowVersion { get; set; }
}
public class WorkOrderBoardPatchRequestDto
{
public string Field { get; set; } = "";
public string? Value { get; set; }
public string? WorkOrderVersion { get; set; }
public string? DispatchVersion { get; set; }
public int? PrimaryDispatchId { get; set; }
}
public class WorkOrderBoardConflictDto
{
public string Message { get; set; } = "The record was modified by another user. Refresh and retry.";
public WorkOrderBoardRowDto? CurrentState { get; set; }
}
public class WorkOrderBoardValidationErrorDto
{
public string Code { get; set; } = "";
public string Message { get; set; } = "";
}
public class WorkOrderBoardResponseDto

View file

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

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

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

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

View file

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

@ -12,7 +12,7 @@ namespace SeaHaven.Services.Helpers
return true;
var trimmed = value.Trim();
var parts = trimmed.Split('–', '—', '-', StringComparison.Ordinal)
var parts = trimmed.Split(new[] { '–', '—', '-' }, StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.Trim())
.Where(p => !string.IsNullOrEmpty(p))
.ToArray();

View file

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

@ -41,11 +41,26 @@ namespace SeaHaven.Services.Helpers
DocStatus
};
public static bool IsEditable(string field) => AllEditable.Contains(field);
public static bool IsEditable(string field) => Canonicalize(field) != null;
public static bool IsDispatchField(string field) => DispatchFields.Contains(field);
public static bool IsDispatchField(string field)
{
var canonical = Canonicalize(field);
return canonical != null && DispatchFields.Contains(canonical);
}
public static string ToAuditFieldName(string field) => field switch
public static string? Canonicalize(string field)
{
if (string.IsNullOrWhiteSpace(field))
return null;
return AllEditable.FirstOrDefault(f => string.Equals(f, field, StringComparison.OrdinalIgnoreCase));
}
public static string ToAuditFieldName(string field)
{
var canonical = Canonicalize(field) ?? field;
return canonical switch
{
WoNumber => "InternalWONumber",
WorkOrderType => "WorkOrderType",
@ -60,7 +75,8 @@ namespace SeaHaven.Services.Helpers
ApptDate => "ApptDate",
ApptTime => "ApptTime",
DocStatus => "DocStatus",
_ => field
_ => canonical
};
}
}
}

View file

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

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

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

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

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

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

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

@ -28,6 +28,18 @@ namespace SeaHaven.Services.Implementation
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
AuditActorType.Sync, fieldName, oldValue, newValue);
public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public void StageCreated(int workOrderId, string? actorId, string? woNumber)
=> StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public async Task LogAsync(
int workOrderId,
AuditActionType action,
@ -39,6 +51,45 @@ namespace SeaHaven.Services.Implementation
string? actorId = null,
int? dispatchId = null,
string? correlationId = null)
{
AddAuditEntry(workOrderId, action, eventType, actorType, fieldName, oldValue, newValue, actorId, dispatchId, correlationId);
if (eventType == AuditEventType.Manual &&
(action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged))
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
await _context.SaveChangesAsync();
}
private void StageAudit(
int workOrderId,
AuditActionType action,
string fieldName,
string? oldValue,
string? newValue,
string? actorId,
int? dispatchId = null)
{
AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher,
fieldName, oldValue, newValue, actorId, dispatchId);
StageFieldLock(workOrderId, fieldName, actorId);
}
private void AddAuditEntry(
int workOrderId,
AuditActionType action,
AuditEventType eventType,
AuditActorType actorType,
string fieldName,
string? oldValue,
string? newValue,
string? actorId,
int? dispatchId = null,
string? correlationId = null)
{
_context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
@ -54,16 +105,23 @@ namespace SeaHaven.Services.Implementation
CorrelationId = correlationId,
CreatedAt = DateTime.UtcNow
});
}
if (eventType == AuditEventType.Manual &&
(action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged))
private void StageFieldLock(int workOrderId, string fieldName, string? userId)
{
var alreadyTracked = _context.WorkOrderFieldLocks.Local
.Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
if (alreadyTracked)
return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
await _context.SaveChangesAsync();
WorkOrderId = workOrderId,
FieldName = fieldName,
LockedAt = DateTime.UtcNow,
LockedByUserId = userId
});
}
}
}

View file

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

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

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

@ -45,8 +45,8 @@ namespace SeaHaven.Services.Implementation
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()
Scheduled = result.ScheduledRows.Select(r => MapRawRow(r, utcNow)).ToList(),
Unscheduled = result.UnscheduledRows.Select(r => MapRawRow(r, utcNow)).ToList()
};
}
@ -62,6 +62,12 @@ namespace SeaHaven.Services.Implementation
}).ToList();
}
public async Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId)
{
var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId);
return raw == null ? null : MapRawRow(raw, DateTime.UtcNow);
}
private static void ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd)
{
if (weekEnd < weekStart)
@ -72,7 +78,7 @@ namespace SeaHaven.Services.Implementation
throw new ArgumentException("Week window cannot exceed 7 days.");
}
private static WorkOrderBoardRowDto MapRow(WorkOrderBoardRawRow row, DateTime utcNow)
public static WorkOrderBoardRowDto MapRawRow(WorkOrderBoardRawRow row, DateTime utcNow)
{
var apptStart = row.ScheduledStart ?? row.DispatchApptDate;
var apptEnd = row.ScheduledEnd;
@ -110,7 +116,9 @@ namespace SeaHaven.Services.Implementation
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
Pm = row.Trade ?? row.Problem,
DocStatus = row.DocStatus,
RowVersion = row.RowVersion
PrimaryDispatchId = row.PrimaryDispatchId,
RowVersion = row.RowVersion,
DispatchRowVersion = row.DispatchRowVersion
};
}

View file

@ -34,7 +34,8 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardValidationException("InvalidField", "Field is required.");
var field = request.Field.Trim();
if (!WorkOrderBoardFieldNames.IsEditable(field))
var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field);
if (canonicalField == null)
throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.");
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion");
@ -49,40 +50,60 @@ namespace SeaHaven.Services.Implementation
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion))
{
var staleState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(staleState);
}
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))
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, 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);
_context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion;
Dispatch? dispatch = null;
if (WorkOrderBoardFieldNames.IsDispatchField(field))
if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField))
{
var vendorIdHint = field.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)
var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)
? ParseVendorIdHint(request.Value)
: null;
var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue;
dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint);
if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion))
{
var staleState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(staleState);
}
if (dispatchVersion != null)
SetOriginalRowVersion(_context.Entry(dispatch).Property(d => d.RowVersion), dispatchVersion);
else if (dispatch.RowVersion != null)
_context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = dispatchVersion;
else if (hadPrimaryDispatch && 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();
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
changes = changes.Where(c => c.HasChanged).ToList();
if (changes.Count == 0)
if (changes.Count == 0 && !_context.ChangeTracker.HasChanges())
{
var unchanged = await LoadBoardRowAsync(workOrderId);
return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
if (changes.Count == 0)
{
await _context.SaveChangesAsync();
var persisted = await LoadBoardRowAsync(workOrderId);
return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
foreach (var change in changes)
{
switch (change.Action)
@ -114,16 +135,16 @@ namespace SeaHaven.Services.Implementation
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private List<FieldChange> ApplyFieldMutation(
private async Task<List<FieldChange>> ApplyFieldMutationAsync(
string field,
WorkOrder workOrder,
Dispatch? dispatch,
string? value,
string auditField)
{
return field.ToLowerInvariant() switch
return field switch
{
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { ApplyWoNumber(workOrder, value, auditField) },
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await 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) },
@ -193,7 +214,7 @@ namespace SeaHaven.Services.Implementation
workOrder.InternalWONumber = normalized;
workOrder.WorkerOrderNumber = normalized;
return FieldChange.Changed(auditField, old, normalized);
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField)
@ -209,7 +230,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField);
workOrder.WorkOrderType = parsed;
return FieldChange.Changed(auditField, old, parsed.ToString());
return FieldChange.ForField(auditField, old, parsed.ToString());
}
private static FieldChange ApplyStringField(
@ -224,7 +245,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField);
setter(normalized);
return FieldChange.Changed(auditField, old, normalized);
return FieldChange.ForField(auditField, old, normalized);
}
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
@ -281,7 +302,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField);
setter(parsed);
return FieldChange.Changed(auditField, old, newVal);
return FieldChange.ForField(auditField, old, newVal);
}
private static List<FieldChange> ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField)
@ -302,7 +323,7 @@ namespace SeaHaven.Services.Implementation
if (oldFormatted != newFormatted)
{
workOrder.ScheduledDate = parsed;
changes.Add(FieldChange.Changed(auditField, oldFormatted, newFormatted));
changes.Add(FieldChange.ForField(auditField, oldFormatted, newFormatted));
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
{
@ -316,7 +337,7 @@ namespace SeaHaven.Services.Implementation
{
var oldCount = workOrder.RescheduleCount.ToString();
workOrder.RescheduleCount++;
changes.Add(FieldChange.Changed(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString()));
changes.Add(FieldChange.ForField(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString()));
}
}
@ -340,7 +361,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField);
workOrder.TargetWeek = parsed;
return FieldChange.Changed(auditField, old, newVal);
return FieldChange.ForField(auditField, old, newVal);
}
private static FieldChange ApplyBoolField(
@ -360,7 +381,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField);
setter(parsed);
return FieldChange.Changed(auditField, old, parsed.ToString());
return FieldChange.ForField(auditField, old, parsed.ToString());
}
private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField)
@ -374,7 +395,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField, dispatch.Id);
dispatch.VendorId = vendorId.Value;
return FieldChange.Changed(auditField, old, vendorId.Value.ToString(), dispatch.Id);
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id);
}
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
@ -393,7 +414,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField, dispatch.Id);
dispatch.ScheduledDate = parsed;
return FieldChange.Changed(auditField, old, newVal, dispatch.Id);
return FieldChange.ForField(auditField, old, newVal, dispatch.Id);
}
private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField)
@ -416,7 +437,7 @@ namespace SeaHaven.Services.Implementation
if (oldCombined == newCombined)
return FieldChange.Unchanged(auditField, dispatch.Id);
return FieldChange.Changed(auditField, oldCombined, newCombined, dispatch.Id);
return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id);
}
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
@ -432,7 +453,7 @@ namespace SeaHaven.Services.Implementation
return FieldChange.Unchanged(auditField);
workOrder.DocStatus = parsed;
return FieldChange.Changed(auditField, old, parsed.ToString());
return FieldChange.ForField(auditField, old, parsed.ToString());
}
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
@ -448,10 +469,8 @@ namespace SeaHaven.Services.Implementation
return changes;
}
private static void SetOriginalRowVersion<T>(PropertyEntry<T, byte[]?> property, byte[] version)
{
property.OriginalValue = version;
}
private static bool RowVersionsMatch(byte[]? current, byte[] expected)
=> current != null && current.AsSpan().SequenceEqual(expected);
private async Task<WorkOrderBoardRowDto?> LoadBoardRowAsync(int workOrderId)
{
@ -492,12 +511,12 @@ namespace SeaHaven.Services.Implementation
string? NewValue,
AuditActionType Action,
int? DispatchId,
bool Changed)
bool HasChanged)
{
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)
public static FieldChange ForField(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)

View file

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

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

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

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

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

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

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

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

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

View file

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

View file

@ -19,5 +19,10 @@ namespace SeaHaven.Services.Interfaces
Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId);
void StageCreated(int workOrderId, string? actorId, string? woNumber);
}
}

View file

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

View file

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

View file

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

View file

@ -6,5 +6,6 @@ namespace SeaHaven.Services.Interfaces
{
Task<WorkOrderBoardResponseDto> GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId);
Task<IReadOnlyList<DispatcherLookupDto>> GetDispatcherLookupsAsync();
Task<WorkOrderBoardRowDto?> GetBoardRowAsync(int workOrderId);
}
}

View file

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

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

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

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

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

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

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

View file

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

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

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

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

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

@ -54,7 +54,7 @@ public class WorkOrderDerivedFieldsTests
public void GetDayGroup_MapsWeekdays(DayOfWeek day, string? expected)
{
var date = new DateTime(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc);
while date.DayOfWeek != day)
while (date.DayOfWeek != day)
date = date.AddDays(1);
Assert.Equal(expected, WorkOrderDerivedFields.GetDayGroup(date));

View file

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

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

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

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

View file

@ -1,11 +0,0 @@
namespace SeaHavenIndustries.Options
{
public class BlazorWorkOrderSunsetOptions
{
public const string SectionName = "BlazorWorkOrderSunset";
public bool Enabled { get; set; }
public string ShocBaseUrl { get; set; } = "https://shoc.seahaven.com";
}
}

View file

@ -1,40 +0,0 @@
# Load Test — Work Order Search (Tier S)
Harness k6 para validar SLOs da Fase 4 em ambiente local ou staging.
## Pré-requisitos
- [k6](https://k6.io/docs/get-started/installation/) instalado
- API rodando com JWT válido
- (Opcional) Seed sintético: `scripts/seed-work-orders-search.ps1 -Count 15000`
## Variáveis de ambiente
| Variável | Descrição | Default |
|----------|-----------|---------|
| `BASE_URL` | URL base da API | `https://localhost:5001` |
| `JWT` | Bearer token | _(obrigatório)_ |
| `WEEK_START` | Segunda-feira ISO para board | `2026-06-22` |
## Execução
```powershell
$env:JWT = "<seu-token>"
$env:BASE_URL = "https://localhost:5001"
k6 run scripts/load-test/work-order-search.k6.js
```
## SLOs Tier S (p95)
| Métrica | Limite |
|---------|--------|
| `board_no_search` | &lt; 800ms |
| `board_with_search` | &lt; 1000ms |
| `advanced_preset` | &lt; 1200ms |
| `advanced_multi_filter` | &lt; 1500ms |
## Staging (pós Volume Discovery)
1. Executar `scripts/volume-discovery.ps1`
2. Confirmar tier em `docs/work-orders/phase-4/search-tier-decision.md`
3. Repetir k6 com volume real; ajustar thresholds se tier = M

View file

@ -1,60 +0,0 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
const boardNoSearch = new Trend('board_no_search', true);
const boardWithSearch = new Trend('board_with_search', true);
const advancedPreset = new Trend('advanced_preset', true);
const advancedMultiFilter = new Trend('advanced_multi_filter', true);
const BASE_URL = __ENV.BASE_URL || 'https://localhost:5001';
const JWT = __ENV.JWT || '';
const WEEK_START = __ENV.WEEK_START || '2026-06-22';
export const options = {
vus: 10,
duration: '30s',
thresholds: {
board_no_search: ['p(95)<800'],
board_with_search: ['p(95)<1000'],
advanced_preset: ['p(95)<1200'],
advanced_multi_filter: ['p(95)<1500'],
},
};
const headers = {
Authorization: `Bearer ${JWT}`,
Accept: 'application/json',
};
export default function () {
const boardRes = http.get(
`${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}`,
{ headers }
);
boardNoSearch.add(boardRes.timings.duration);
check(boardRes, { 'board 200': (r) => r.status === 200 });
const searchRes = http.get(
`${BASE_URL}/api/workorders/board?weekStart=${WEEK_START}&search=BK5`,
{ headers }
);
boardWithSearch.add(searchRes.timings.duration);
check(searchRes, { 'board search 200': (r) => r.status === 200 });
const advRes = http.get(
`${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&page=1&pageSize=50`,
{ headers }
);
advancedPreset.add(advRes.timings.duration);
check(advRes, { 'advanced preset 200': (r) => r.status === 200 });
const multiRes = http.get(
`${BASE_URL}/api/workorders/board/search?datePreset=thisMonth&sites=BK5&types=PM&search=HVAC&page=1`,
{ headers }
);
advancedMultiFilter.add(multiRes.timings.duration);
check(multiRes, { 'advanced multi 200': (r) => r.status === 200 });
sleep(0.5);
}