From d040832b874d66ee4a423025fca23e6965b60d5c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 7 Jul 2026 11:26:13 -0300 Subject: [PATCH] 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. --- .../Controllers/WorkOrderController.cs | 38 ++- .../Controllers/WorkOrderIngestController.cs | 47 --- .../Controllers/WorkOrderJobsController.cs | 58 ---- .../Controllers/WorkOrderOpsController.cs | 29 -- .../Filters/IngestApiKeyFilter.cs | 56 ---- .../PastDueCacheHostedService.cs | 67 ---- .../WorkOrderWeekRolledHostedService.cs | 75 ----- .../Middleware/LegacyDeprecationMiddleware.cs | 50 --- .../Options/LegacyEndpointOptions.cs | 12 - Api.SeaHavenIndustries/Options/SyncOptions.cs | 10 - .../Options/WorkOrderIngestOptions.cs | 12 - .../Options/WorkOrderJobRunState.cs | 11 - .../Options/WorkOrderJobsOptions.cs | 23 -- .../Auth/ApplicationDbContext.cs | 45 +++ .../Enums/WorkOrderEnums.cs | 8 - .../20260624192552_Phase5_DomainEvents.cs | 61 ---- .../20260624200000_Phase4_SearchIndexes.cs | 48 --- ...260625120000_Phase6_CompletionSlideOver.cs | 69 ----- .../Models/CompletionDocTemplate.cs | 13 - .../Models/WorkOrderWeekRolledLedger.cs | 15 - .../Board/WorkOrderBoardConstants.cs | 7 - .../DTOs/WorkOrderBoardQuery.cs | 10 - .../DTOs/WorkOrderBoardRowData.cs | 20 -- .../Exceptions/BoardQueryException.cs | 9 - .../Helpers/WorkOrderBoardSearchFilter.cs | 47 --- .../CompletionDocTemplateDataService.cs | 99 ------ .../WorkOrderAdvancedSearchDataService.cs | 64 ---- .../WorkOrderBoardDataService.cs | 137 ++------- .../WorkOrderDetailDataService.cs | 90 ------ .../WorkOrderDomainJobDataService.cs | 167 ---------- .../IWorkOrderAdvancedSearchDataService.cs | 9 - .../Interfaces/IWorkOrderBoardDataService.cs | 2 + .../Interfaces/IWorkOrderDetailDataService.cs | 27 -- .../IWorkOrderDomainJobDataService.cs | 38 --- .../Interfaces/PrimaryDispatchProjection.cs | 9 - .../WorkOrderAdvancedSearchModels.cs | 27 -- .../Interfaces/WorkOrderBoardModels.cs | 2 + .../Interfaces/WorkOrderDetailModels.cs | 30 -- .../Board/WorkOrderBoardConstants.cs | 8 - .../Board/WorkOrderBoardStatusFilter.cs | 25 -- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 24 ++ SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 104 ------- SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs | 21 -- SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs | 53 ---- .../Exceptions/BoardQueryException.cs | 9 - .../Helpers/WorkOrderAuditProjection.cs | 41 --- .../Helpers/WorkOrderBoardApptTimeParser.cs | 2 +- .../Helpers/WorkOrderBoardFieldMutations.cs | 132 -------- .../Helpers/WorkOrderBoardFieldNames.cs | 24 +- .../Helpers/WorkOrderCommentProjection.cs | 23 -- .../Helpers/WorkOrderIngestFieldMapper.cs | 27 -- .../Helpers/WorkOrderMediaProjection.cs | 82 ----- .../Helpers/WorkOrderOperationalWeek.cs | 20 -- .../WorkOrderSearchDateRangeResolver.cs | 95 ------ .../Implementation/PastDueCacheService.cs | 48 --- .../WorkOrderAdvancedSearchService.cs | 62 ---- .../Implementation/WorkOrderAuditService.cs | 74 ++++- .../WorkOrderBoardCancelService.cs | 57 ---- .../WorkOrderBoardCreateService.cs | 210 ------------- .../Implementation/WorkOrderBoardMapper.cs | 41 --- .../Implementation/WorkOrderBoardService.cs | 16 +- .../WorkOrderBoardUpdateService.cs | 83 +++-- .../Implementation/WorkOrderCommentService.cs | 59 ---- .../WorkOrderCompletionService.cs | 157 ---------- .../Implementation/WorkOrderDetailService.cs | 149 --------- .../Implementation/WorkOrderIngestService.cs | 190 ------------ .../Implementation/WorkOrderMediaService.cs | 127 -------- .../WorkOrderOpsHealthService.cs | 82 ----- .../Implementation/WorkOrderStatusMapper.cs | 150 --------- .../WorkOrderWeekRolledService.cs | 111 ------- .../Interfaces/IPastDueCacheService.cs | 9 - .../IWorkOrderAdvancedSearchService.cs | 11 - .../Interfaces/IWorkOrderAuditService.cs | 5 + .../IWorkOrderBoardCancelService.cs | 9 - .../IWorkOrderBoardCreateService.cs | 9 - .../Interfaces/IWorkOrderBoardMapper.cs | 11 - .../Interfaces/IWorkOrderBoardService.cs | 1 + .../Interfaces/IWorkOrderCommentService.cs | 11 - .../Interfaces/IWorkOrderCompletionService.cs | 16 - .../Interfaces/IWorkOrderDetailService.cs | 10 - .../Interfaces/IWorkOrderMediaService.cs | 12 - .../Interfaces/IWorkOrderPhase7Services.cs | 16 - .../Interfaces/IWorkOrderStatusMapper.cs | 30 -- .../Interfaces/IWorkOrderWeekRolledService.cs | 11 - .../WorkOrderBoardCreateValidation.cs | 46 --- .../WorkOrderBoardCancelServiceTests.cs | 151 --------- .../WorkOrderBoardCreateServiceTests.cs | 180 ----------- .../WorkOrderBoardCreateSyncLockTests.cs | 101 ------ .../WorkOrderBoardSearchTests.cs | 238 -------------- .../WorkOrderDerivedFieldsTests.cs | 2 +- .../WorkOrderPhase6Tests.cs | 284 ----------------- .../WorkOrderPhase7CoexistenceTests.cs | 170 ---------- .../WorkOrderWeekRolledTests.cs | 291 ------------------ .../Helper/BlazorWorkOrderSunsetGuard.cs | 26 -- .../Options/BlazorWorkOrderSunsetOptions.cs | 11 - scripts/load-test/README.md | 40 --- scripts/load-test/work-order-search.k6.js | 60 ---- 97 files changed, 295 insertions(+), 5313 deletions(-) delete mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs delete mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs delete mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs delete mode 100644 Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs delete mode 100644 Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs delete mode 100644 Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs delete mode 100644 Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs delete mode 100644 Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs delete mode 100644 Api.SeaHavenIndustries/Options/SyncOptions.cs delete mode 100644 Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs delete mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs delete mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs delete mode 100644 Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs delete mode 100644 Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs delete mode 100644 Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs delete mode 100644 Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs delete mode 100644 Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs delete mode 100644 SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs delete mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs delete mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs delete mode 100644 SeaHaven.DataServices/Exceptions/BoardQueryException.cs delete mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs delete mode 100644 SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs delete mode 100644 SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs delete mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs delete mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs delete mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs delete mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs delete mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs delete mode 100644 SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs delete mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs delete mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs delete mode 100644 SeaHaven.Services/Board/WorkOrderBoardConstants.cs delete mode 100644 SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs delete mode 100644 SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs delete mode 100644 SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs delete mode 100644 SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs delete mode 100644 SeaHaven.Services/Exceptions/BoardQueryException.cs delete mode 100644 SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs delete mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs delete mode 100644 SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs delete mode 100644 SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs delete mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs delete mode 100644 SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs delete mode 100644 SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs delete mode 100644 SeaHaven.Services/Implementation/PastDueCacheService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderCommentService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderCompletionService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderDetailService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderIngestService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderMediaService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs delete mode 100644 SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs delete mode 100644 SeaHaven.Services/Interfaces/IPastDueCacheService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs delete mode 100644 SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs delete mode 100644 SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs delete mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs delete mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs delete mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs delete mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs delete mode 100644 SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs delete mode 100644 SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs delete mode 100644 SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs delete mode 100644 SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs delete mode 100644 SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs delete mode 100644 scripts/load-test/README.md delete mode 100644 scripts/load-test/work-order-search.k6.js diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 57eb4b8..01dcc0e 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -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 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 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 Addworkorder([FromForm] Workorder_DTO model) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs deleted file mode 100644 index dcfbe7d..0000000 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs +++ /dev/null @@ -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; - } - - /// - /// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target). - /// Auth: X-Ingest-Key header. - /// - [HttpPost("ingest")] - [IngestApiKey] - [ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)] - public async Task> 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 NormalizeItems(WorkOrderIngestBatchRequestDto? batch) - { - if (batch?.Items is { Count: > 0 }) - return batch.Items; - - return new List(); - } - } -} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs deleted file mode 100644 index 4277475..0000000 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs +++ /dev/null @@ -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 -{ - /// Ops-only endpoints for manual domain job reprocessing. - [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; - } - - /// - /// Reprocess WeekRolled for a specific operational week (Monday start). - /// Idempotent via WorkOrderWeekRolledLedger. - /// - [HttpPost("week-rolled")] - [ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)] - public async Task> 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); - } - - /// - /// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read). - /// - [HttpPost("past-due-cache")] - [ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)] - public async Task> RunPastDueCache(CancellationToken cancellationToken) - { - var result = await _pastDueCacheService.RefreshAsync(cancellationToken); - return Ok(result); - } - } -} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs deleted file mode 100644 index dfbff62..0000000 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using SeaHaven.Services.DTOs; -using SeaHaven.Services.Interfaces; - -namespace Api.SeaHavenIndustries.Controllers -{ - /// Ops health and coexistence metrics (Fase 7). - [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> GetHealth(CancellationToken cancellationToken) - { - var health = await _healthService.GetHealthAsync(cancellationToken); - return Ok(health); - } - } -} diff --git a/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs b/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs deleted file mode 100644 index 3959e0f..0000000 --- a/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs +++ /dev/null @@ -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 -{ - /// Validates X-Ingest-Key for POST /api/workorders/ingest. - public class IngestApiKeyFilter : IAsyncActionFilter - { - private readonly WorkOrderIngestOptions _options; - - public IngestApiKeyFilter(IOptions 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)) - { - } - } -} diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs deleted file mode 100644 index c9e05f1..0000000 --- a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs +++ /dev/null @@ -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 _logger; - private readonly PastDueCacheJobOptions _options; - private DateOnly? _lastRunDate; - - public PastDueCacheHostedService( - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger 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(); - 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); - } - } -} diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs deleted file mode 100644 index 7963737..0000000 --- a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs +++ /dev/null @@ -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 _logger; - private readonly WeekRolledJobOptions _options; - private DateOnly? _lastRunDate; - - public WorkOrderWeekRolledHostedService( - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger 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(); - 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(_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); - } - } -} diff --git a/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs b/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs deleted file mode 100644 index b7c5039..0000000 --- a/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Api.SeaHavenIndustries.Options; -using Microsoft.Extensions.Options; - -namespace Api.SeaHavenIndustries.Middleware -{ - /// Adds Sunset/Deprecation headers to legacy WorkOrder REST routes. - public class LegacyDeprecationMiddleware - { - private static readonly HashSet LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase) - { - "GetWorkOrderList", - "AddWorkorder", - "EditWorkorder", - "ChangeStatus", - "ChangeAssignment", - "DeleteWorkorder", - "GetWorkorderById" - }; - - private readonly RequestDelegate _next; - private readonly LegacyEndpointOptions _options; - - public LegacyDeprecationMiddleware(RequestDelegate next, IOptions 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"] = "; rel=\"successor-version\""; - } - } -} diff --git a/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs deleted file mode 100644 index ffd01ef..0000000 --- a/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Api.SeaHavenIndustries.Options -{ - public class LegacyEndpointOptions - { - public const string SectionName = "LegacyEndpoints"; - - public bool DeprecationEnabled { get; set; } - - /// ISO date returned in Sunset header (e.g. 2026-12-31). - public string? SunsetDate { get; set; } - } -} diff --git a/Api.SeaHavenIndustries/Options/SyncOptions.cs b/Api.SeaHavenIndustries/Options/SyncOptions.cs deleted file mode 100644 index 48681c4..0000000 --- a/Api.SeaHavenIndustries/Options/SyncOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Api.SeaHavenIndustries.Options -{ - public class SyncOptions - { - public const string SectionName = "Sync"; - - /// When false, SyncController endpoints return 503 (post-Lambda cutover). - public bool Enabled { get; set; } = true; - } -} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs deleted file mode 100644 index b82d711..0000000 --- a/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Api.SeaHavenIndustries.Options -{ - public class WorkOrderIngestOptions - { - public const string SectionName = "WorkOrderIngest"; - - public bool Enabled { get; set; } = true; - - /// Shared secret for X-Ingest-Key header (Lambda / service accounts). - public string? ApiKey { get; set; } - } -} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs deleted file mode 100644 index dd95de5..0000000 --- a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Api.SeaHavenIndustries.Options -{ - /// In-memory last-run timestamps for hosted domain jobs (ops health). - public class WorkOrderJobRunState - { - public DateTime? LastWeekRolledRunUtc { get; set; } - public DateTime? LastPastDueCacheRunUtc { get; set; } - public string? LastWeekRolledError { get; set; } - public string? LastPastDueCacheError { get; set; } - } -} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs deleted file mode 100644 index 791638c..0000000 --- a/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs +++ /dev/null @@ -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"; - } -} diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 40a6842..be52ecf 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -93,6 +93,51 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + + public override int SaveChanges() + { + BumpRowVersionsForInMemoryProvider(); + return base.SaveChanges(); + } + + public override Task 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()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } + + foreach (var entry in ChangeTracker.Entries()) + { + 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 diff --git a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs index 955b277..65fcb06 100644 --- a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs +++ b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs @@ -156,12 +156,4 @@ namespace Data.SeaHavenIndustries.Enums Contact = 4, Asset = 5 } - - public enum WorkOrderMediaCategory - { - Before = 1, - After = 2, - Extra = 3, - Completion = 4 - } } diff --git a/Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs b/Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs deleted file mode 100644 index 325bab6..0000000 --- a/Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Data.SeaHavenIndustries.Migrations -{ - /// - public partial class Phase5_DomainEvents : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "OperationalFlags", - table: "workOrders", - type: "int", - nullable: false, - defaultValue: 0); - - migrationBuilder.CreateTable( - name: "WorkOrderWeekRolledLedgers", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - WorkOrderId = table.Column(type: "int", nullable: false), - SourceWeekStart = table.Column(type: "date", nullable: false), - ProcessedAt = table.Column(type: "datetime2", nullable: false), - CorrelationId = table.Column(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); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "WorkOrderWeekRolledLedgers"); - - migrationBuilder.DropColumn( - name: "OperationalFlags", - table: "workOrders"); - } - } -} diff --git a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs deleted file mode 100644 index 427b6cf..0000000 --- a/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Data.SeaHavenIndustries.Migrations -{ - /// - public partial class Phase4_SearchIndexes : Migration - { - /// - 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"); - } - - /// - 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"); - } - } -} diff --git a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs deleted file mode 100644 index b134db2..0000000 --- a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Data.SeaHavenIndustries.Migrations -{ - /// - public partial class Phase6_CompletionSlideOver : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Category", - table: "workOrderAttachments", - type: "int", - nullable: true); - - migrationBuilder.CreateTable( - name: "CompletionDocTemplates", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: false), - ServiceKey = table.Column(type: "nvarchar(450)", nullable: false), - WorkOrderType = table.Column(type: "int", nullable: true), - TemplateUrl = table.Column(type: "nvarchar(max)", nullable: false), - IsActive = table.Column(type: "bit", nullable: false), - IsDeleted = table.Column(type: "bit", nullable: true), - createdby = table.Column(type: "nvarchar(max)", nullable: true), - DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), - DeletionTime = table.Column(type: "datetime2", nullable: true), - CreatedDate = table.Column(type: "datetime2", nullable: true), - LastModificationTime = table.Column(type: "datetime2", nullable: true), - LastModifierUserId = table.Column(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)) <> ''; - "); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CompletionDocTemplates"); - - migrationBuilder.DropColumn( - name: "Category", - table: "workOrderAttachments"); - } - } -} diff --git a/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs deleted file mode 100644 index 17cb4d3..0000000 --- a/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs +++ /dev/null @@ -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; - } -} diff --git a/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs deleted file mode 100644 index efa661a..0000000 --- a/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs +++ /dev/null @@ -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; } = ""; - } -} diff --git a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs deleted file mode 100644 index bceff32..0000000 --- a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SeaHaven.DataServices.Board -{ - public static class WorkOrderBoardConstants - { - public const int ClientSideThreshold = 300; - } -} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs deleted file mode 100644 index 6d5d1e0..0000000 --- a/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SeaHaven.DataServices.DTOs -{ - public record WorkOrderBoardQuery( - DateOnly ScheduledFrom, - DateOnly ScheduledTo, - string? Search, - IReadOnlyList? AssigneeIds, - IReadOnlyList? DbStatuses, - int? LocationId); -} diff --git a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs deleted file mode 100644 index a7eca60..0000000 --- a/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs +++ /dev/null @@ -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); -} diff --git a/SeaHaven.DataServices/Exceptions/BoardQueryException.cs b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs deleted file mode 100644 index b659424..0000000 --- a/SeaHaven.DataServices/Exceptions/BoardQueryException.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SeaHaven.DataServices.Exceptions -{ - public class BoardQueryException : Exception - { - public BoardQueryException(string message) : base(message) - { - } - } -} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs deleted file mode 100644 index 2db505e..0000000 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ /dev/null @@ -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 Apply(IQueryable 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))); - } - } -} diff --git a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs deleted file mode 100644 index 9466233..0000000 --- a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs +++ /dev/null @@ -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> 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 GetByIdAsync(int id) - { - return await _context.CompletionDocTemplates - .AsNoTracking() - .Where(t => t.Id == id && t.IsDeleted != true) - .Select(MapRow) - .FirstOrDefaultAsync(); - } - - public async Task 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 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 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); - } -} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs deleted file mode 100644 index 556fe4f..0000000 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ /dev/null @@ -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 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 ApplySort(IQueryable 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) - }; - } - } -} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index e17b7e2..5a29f24 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -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 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 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 ApplyDispatcherFilter(IQueryable 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> IsTerminalExpression(WorkOrder w) - => w => w.LifecycleStatus == LifecycleStatus.Complete - || w.LifecycleStatus == LifecycleStatus.Canceled - || w.LifecycleStatus == LifecycleStatus.Closed; - - private async Task> ProjectRows( - IQueryable 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(); - } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs deleted file mode 100644 index 8ea9358..0000000 --- a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs +++ /dev/null @@ -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 ExistsAsync(int workOrderId) - => WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) - .AnyAsync(w => w.Id == workOrderId); - - public async Task 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> GetCommentsAsync(int workOrderId) - { - return await _context.Comments - .AsNoTracking() - .Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true) - .OrderByDescending(c => c.CreatedDate) - .ToListAsync(); - } - - public async Task> 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> GetAttachmentsAsync(int workOrderId) - { - return await _context.workOrderAttachments - .AsNoTracking() - .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true) - .OrderByDescending(a => a.CreatedDate) - .ToListAsync(); - } - - public async Task> 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 GetWorkOrderForMediaAsync(int workOrderId) - { - return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) - .FirstOrDefaultAsync(w => w.Id == workOrderId); - } - } -} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs deleted file mode 100644 index f7e1ca1..0000000 --- a/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs +++ /dev/null @@ -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> 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 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 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 - }; - } - } -} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs deleted file mode 100644 index f758550..0000000 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SeaHaven.DataServices.Interfaces; - -namespace SeaHaven.DataServices.Interfaces -{ - public interface IWorkOrderAdvancedSearchDataService - { - Task SearchAsync(WorkOrderAdvancedSearchQuery query); - } -} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs index 67f1459..4f23d5a 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -6,5 +6,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetBoardRowsAsync(WorkOrderBoardQuery query); Task> GetDispatcherLookupsAsync(); + Task GetBoardRowByIdAsync(int workOrderId); + Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs deleted file mode 100644 index 3f61a70..0000000 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; -using SeaHaven.DataServices.Interfaces; - -namespace SeaHaven.DataServices.Interfaces -{ - public interface IWorkOrderDetailDataService - { - Task ExistsAsync(int workOrderId); - Task GetExtendedFieldsAsync(int workOrderId); - Task> GetCommentsAsync(int workOrderId); - Task> GetAuditLogsAsync(int workOrderId, int? limit = null); - Task> GetAttachmentsAsync(int workOrderId); - Task> GetDispatchSignoffsAsync(int workOrderId); - Task GetWorkOrderForMediaAsync(int workOrderId); - } - - public interface ICompletionDocTemplateDataService - { - Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType); - Task GetByIdAsync(int id); - Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType); - Task CreateAsync(CompletionDocTemplate template); - Task UpdateAsync(CompletionDocTemplate template); - Task DeleteAsync(int id); - } -} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs deleted file mode 100644 index 4c6af01..0000000 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs +++ /dev/null @@ -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> GetWeekRolledCandidatesAsync( - DateOnly sourceWeekStart, - DateOnly sourceWeekEnd, - int batchSize = 500, - int skip = 0, - CancellationToken cancellationToken = default); - - Task TryProcessWeekRolledAsync( - int workOrderId, - DateOnly sourceWeekStart, - string correlationId, - CancellationToken cancellationToken = default); - - Task RefreshPastDueFlagsAsync( - DateTime utcNow, - int batchSize = 500, - CancellationToken cancellationToken = default); - } -} diff --git a/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs b/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs deleted file mode 100644 index ce6ebf6..0000000 --- a/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SeaHaven.DataServices.Interfaces -{ - public record PrimaryDispatchProjection( - int WorkOrderId, - int? DispatchId, - string? VendorName, - DateTime? ApptDate, - string? DispatchStatus); -} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs deleted file mode 100644 index 0cede89..0000000 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ /dev/null @@ -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? Sites, - IReadOnlyList? Types, - IReadOnlyList? Dispatchers, - IReadOnlyList? Statuses, - IReadOnlyList? PmTypes, - IReadOnlyList? VendorIds, - IReadOnlyList? DocStatuses, - bool MyWorkOrders, - string? CurrentUserId, - int Page, - int PageSize, - string SortBy, - string SortDir); - - public record WorkOrderAdvancedSearchResult( - IReadOnlyList Rows, - int TotalCount); -} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 4b96481..0173d07 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -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( diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs deleted file mode 100644 index cab956b..0000000 --- a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs +++ /dev/null @@ -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); -} diff --git a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs deleted file mode 100644 index a00f3e2..0000000 --- a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SeaHaven.Services.Board -{ - public static class WorkOrderBoardConstants - { - public const int MaxWindowDays = 90; - public const int ClientSideThreshold = 300; - } -} diff --git a/SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs b/SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs deleted file mode 100644 index 45431dd..0000000 --- a/SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using SeaHaven.Services.Interfaces; - -namespace SeaHaven.Services.Board -{ - /// - /// Handoff helper for GetWorkOrdersBoard — expands canonical status[] query params to DB values. - /// - public static class WorkOrderBoardStatusFilter - { - /// - /// Example usage in GetWorkOrdersBoardAsync: - /// - /// var dbStatuses = WorkOrderBoardStatusFilter.Resolve(statusFilter, statusMapper); - /// if (dbStatuses.Count > 0) - /// query = query.Where(w => dbStatuses.Contains(w.Status)); - /// - /// - public static IReadOnlyList Resolve( - IEnumerable? canonicalStatuses, - IWorkOrderStatusMapper statusMapper) - { - return statusMapper.ExpandCanonicalFilters(canonicalStatuses); - } - } -} diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index 674097c..b35c303 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -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 diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs deleted file mode 100644 index 7ac1437..0000000 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ /dev/null @@ -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 Comments { get; set; } = new(); - public List Audit { get; set; } = new(); - public List 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 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; } - } -} diff --git a/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs deleted file mode 100644 index 885c363..0000000 --- a/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs +++ /dev/null @@ -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; } - } -} diff --git a/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs b/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs deleted file mode 100644 index 2b02c63..0000000 --- a/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs +++ /dev/null @@ -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? 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 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; } - } -} diff --git a/SeaHaven.Services/Exceptions/BoardQueryException.cs b/SeaHaven.Services/Exceptions/BoardQueryException.cs deleted file mode 100644 index fa3ecb5..0000000 --- a/SeaHaven.Services/Exceptions/BoardQueryException.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SeaHaven.Services.Exceptions -{ - public class BoardQueryException : Exception - { - public BoardQueryException(string message) : base(message) - { - } - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs deleted file mode 100644 index 8b56b47..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs +++ /dev/null @@ -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 ToDtos(IEnumerable 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() - }; - } - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs index f7320a7..8944001 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs @@ -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(); diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs deleted file mode 100644 index b0bf837..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs +++ /dev/null @@ -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 ApplyAssignTo(WorkOrder workOrder, string? value, string auditField = "AssignTo") - { - var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - var old = workOrder.AssignTo; - var changes = new List(); - - if (old != normalized) - { - workOrder.AssignTo = normalized; - changes.Add(BoardFieldChange.AssignmentChanged(auditField, old, normalized)); - } - - changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); - return changes; - } - - public static List ApplyScheduledDate( - WorkOrder workOrder, - DateTime? parsed, - string auditField = "ScheduledDate", - bool incrementRescheduleOnChange = true) - { - var previousDate = workOrder.ScheduledDate; - var changes = new List(); - 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 ApplyAutoScheduleSideEffects(WorkOrder workOrder) - { - var changes = new List(); - 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); - } - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index 491c2f7..cf6e4e8 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -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 }; + } } } diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs deleted file mode 100644 index 096edd5..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs +++ /dev/null @@ -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 ToDtos(IEnumerable comments) - => comments.Select(ToDto).ToList(); - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs b/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs deleted file mode 100644 index cc48e5e..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace SeaHaven.Services.Helpers -{ - /// DynamoDB / ingest field mapping shared by Sync and direct ingest. - 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}"; - } - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs deleted file mode 100644 index a2b4c52..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs +++ /dev/null @@ -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 ProjectAll( - WorkOrder workOrder, - IEnumerable attachments) - { - var media = new List(); - 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; - } - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs deleted file mode 100644 index 5779a17..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs +++ /dev/null @@ -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}"; - } -} diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs deleted file mode 100644 index d41df2f..0000000 --- a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs +++ /dev/null @@ -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); - } - } -} diff --git a/SeaHaven.Services/Implementation/PastDueCacheService.cs b/SeaHaven.Services/Implementation/PastDueCacheService.cs deleted file mode 100644 index 33c1646..0000000 --- a/SeaHaven.Services/Implementation/PastDueCacheService.cs +++ /dev/null @@ -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 _logger; - - public PastDueCacheService( - IWorkOrderDomainJobDataService dataService, - ILogger logger) - { - _dataService = dataService; - _logger = logger; - } - - public async Task 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; - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs deleted file mode 100644 index 51e5b36..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ /dev/null @@ -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> 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 - { - Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(), - TotalCount = result.TotalCount, - Page = page, - PageSize = pageSize - }; - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 4543c91..0646cd0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -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 + }); } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs deleted file mode 100644 index 7a8097c..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs +++ /dev/null @@ -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 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."); - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs deleted file mode 100644 index 6d6c2a0..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ /dev/null @@ -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 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.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 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 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 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; - } - } - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs b/SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs deleted file mode 100644 index e627138..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs +++ /dev/null @@ -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; - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index a4b6001..0a82f76 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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 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 }; } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index bc83567..038f8b8 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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 ApplyFieldMutation( + private async Task> ApplyFieldMutationAsync( string field, WorkOrder workOrder, Dispatch? dispatch, string? value, string auditField) { - return field.ToLowerInvariant() switch + return field switch { - WorkOrderBoardFieldNames.WoNumber => new List { ApplyWoNumber(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.WoNumber => new List { await ApplyWoNumber(workOrder, value, auditField) }, WorkOrderBoardFieldNames.WorkOrderType => new List { ApplyWorkOrderType(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, WorkOrderBoardFieldNames.LifecycleStatus => new List { 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 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 ApplyAutoScheduleSideEffects(WorkOrder workOrder) @@ -448,10 +469,8 @@ namespace SeaHaven.Services.Implementation return changes; } - private static void SetOriginalRowVersion(PropertyEntry property, byte[] version) - { - property.OriginalValue = version; - } + private static bool RowVersionsMatch(byte[]? current, byte[] expected) + => current != null && current.AsSpan().SequenceEqual(expected); private async Task 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) diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs deleted file mode 100644 index 0b046e4..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs +++ /dev/null @@ -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> GetCommentsAsync(int workOrderId) - { - if (!await _detailData.ExistsAsync(workOrderId)) - return Array.Empty(); - - var comments = await _detailData.GetCommentsAsync(workOrderId); - return WorkOrderCommentProjection.ToDtos(comments); - } - - public async Task 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); - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs deleted file mode 100644 index 50a9aac..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs +++ /dev/null @@ -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> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) - { - var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType); - return rows.Select(WorkOrderDetailService.MapTemplate).ToList(); - } - - public async Task GetTemplateByIdAsync(int id) - { - var row = await _templateData.GetByIdAsync(id); - return row == null ? null : WorkOrderDetailService.MapTemplate(row); - } - - public async Task 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 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 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."); - } - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs deleted file mode 100644 index 6723ec1..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ /dev/null @@ -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 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() - : WorkOrderMediaProjection.ProjectAll(workOrder, attachments) - }; - } - - public async Task> GetAuditAsync(int workOrderId, int limit = 50) - { - if (!await _detailData.ExistsAsync(workOrderId)) - return Array.Empty(); - - 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 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 - }; - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs deleted file mode 100644 index f6bb5f5..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs +++ /dev/null @@ -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 UpsertBatchAsync( - IReadOnlyList 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 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 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 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; - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs deleted file mode 100644 index 610443c..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs +++ /dev/null @@ -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> GetMediaAsync(int workOrderId) - { - var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); - if (workOrder == null) - return Array.Empty(); - - var attachments = await _detailData.GetAttachmentsAsync(workOrderId); - return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); - } - - public async Task 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(); - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs deleted file mode 100644 index 9e3d7ab..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs +++ /dev/null @@ -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 options) - { - _db = db; - _runState = runState; - _syncEnabled = options.Value.SyncEnabled; - _ingestEnabled = options.Value.IngestEnabled; - _legacyDeprecationEnabled = options.Value.LegacyDeprecationEnabled; - _legacySunsetDate = options.Value.LegacySunsetDate; - } - - public async Task 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 - }; - } - } - - /// Options bridged from API configuration for health snapshots. - 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; } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs b/SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs deleted file mode 100644 index bde6685..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs +++ /dev/null @@ -1,150 +0,0 @@ -using Data.SeaHavenIndustries.Enums; -using SeaHaven.Services.Interfaces; - -namespace SeaHaven.Services.Implementation -{ - public class WorkOrderStatusMapper : IWorkOrderStatusMapper - { - private static readonly HashSet TerminalCanonical = new(StringComparer.OrdinalIgnoreCase) - { - nameof(WorkOrderStatus.Completed), - nameof(WorkOrderStatus.Cancelled), - }; - - private static readonly Dictionary 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 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 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 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(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 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 ExpandCanonicalFilters(IEnumerable? canonicalStatuses) - { - if (canonicalStatuses == null) - return Array.Empty(); - - 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)); - } - } -} diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs deleted file mode 100644 index bf82574..0000000 --- a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs +++ /dev/null @@ -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 _logger; - - public WorkOrderWeekRolledService( - IWorkOrderDomainJobDataService dataService, - IWorkOrderAuditService auditService, - ILogger logger) - { - _dataService = dataService; - _auditService = auditService; - _logger = logger; - } - - public async Task 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; - } - } -} diff --git a/SeaHaven.Services/Interfaces/IPastDueCacheService.cs b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs deleted file mode 100644 index e87f498..0000000 --- a/SeaHaven.Services/Interfaces/IPastDueCacheService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IPastDueCacheService - { - Task RefreshAsync(CancellationToken cancellationToken = default); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs deleted file mode 100644 index 8da8835..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderAdvancedSearchService - { - Task> SearchAsync( - WorkOrderAdvancedSearchQueryDto query, - string? currentUserId); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs index bb83d5a..7029709 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAuditService.cs @@ -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); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs deleted file mode 100644 index d0b647b..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderBoardCancelService - { - Task CancelAsync(int workOrderId, string? actorId); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs deleted file mode 100644 index 9818053..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderBoardCreateService - { - Task CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs deleted file mode 100644 index 63afef7..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderBoardMapper - { - WorkOrderBoardItemDto MapToBoardItem( - WorkOrderBoardRowDto row, - DateOnly? today = null); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs index d24e64a..2a964cf 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs @@ -6,5 +6,6 @@ namespace SeaHaven.Services.Interfaces { Task GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); Task> GetDispatcherLookupsAsync(); + Task GetBoardRowAsync(int workOrderId); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs deleted file mode 100644 index c08c10a..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Data.SeaHavenIndustries.Enums; -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderCommentService - { - Task> GetCommentsAsync(int workOrderId); - Task AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs deleted file mode 100644 index 1417306..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Data.SeaHavenIndustries.Enums; -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderCompletionService - { - Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); - Task GetTemplateByIdAsync(int id); - Task UploadCompletionDocAsync( - int workOrderId, - WorkOrderCompletionDocUploadDto request, - string fileUrl, - string? actorId); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs deleted file mode 100644 index 385ff58..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderDetailService - { - Task GetDetailAsync(int workOrderId); - Task> GetAuditAsync(int workOrderId, int limit = 50); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs deleted file mode 100644 index 12b3e30..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Data.SeaHavenIndustries.Enums; -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderMediaService - { - Task> GetMediaAsync(int workOrderId); - Task AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); - Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs b/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs deleted file mode 100644 index 0242a38..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs +++ /dev/null @@ -1,16 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderIngestService - { - Task UpsertBatchAsync( - IReadOnlyList items, - CancellationToken cancellationToken = default); - } - - public interface IWorkOrderOpsHealthService - { - Task GetHealthAsync(CancellationToken cancellationToken = default); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs b/SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs deleted file mode 100644 index 13531f3..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace SeaHaven.Services.Interfaces -{ - /// - /// Maps work order status between DB strings, API canonical values, and FE display labels. - /// See docs/spikes/status-mapping.md (Gate G2). - /// - public interface IWorkOrderStatusMapper - { - /// Normalizes a raw DB/Sync value to API canonical status (enum name). - string NormalizeToCanonical(string? dbStatus); - - /// Maps canonical status to Schedule Board display label (StatusDisplay). - string ToDisplay(string canonical, bool isUnassigned = false); - - /// True when work order should not be treated as past due. - bool IsTerminal(string canonical); - - /// All DB string variants that match a canonical filter value. - IReadOnlyList ExpandCanonicalForFilter(string canonical); - - /// Expands multiple canonical filters to distinct DB values for SQL IN clauses. - IReadOnlyList ExpandCanonicalFilters(IEnumerable? canonicalStatuses); - - /// Fase 2 write path — persists legacy-compatible string (Blazor/Sync). - string ToStorage(string canonical); - - /// Fase 2 write path — FE display label to storage string. - string? FromDisplay(string? statusDisplay); - } -} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs deleted file mode 100644 index b8c1a6a..0000000 --- a/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Interfaces -{ - public interface IWorkOrderWeekRolledService - { - Task ProcessWeekRolledAsync( - DateOnly sourceWeekStart, - CancellationToken cancellationToken = default); - } -} diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs deleted file mode 100644 index 3b00bed..0000000 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ /dev/null @@ -1,46 +0,0 @@ -using FluentValidation; -using SeaHaven.Services.DTOs; - -namespace SeaHaven.Services.Validation -{ - public interface IWorkOrderBoardCreateValidation : IValidator - { - } - - public class WorkOrderBoardCreateValidation : AbstractValidator, 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); - } - } -} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs deleted file mode 100644 index 85ab2a3..0000000 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ /dev/null @@ -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() - .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(() => 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(() => 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(() => - update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto - { - Field = WorkOrderBoardFieldNames.SiteCode, - Value = "NEW", - WorkOrderVersion = ToVersion(wo) - }, "actor-1")); - - Assert.Equal("CanceledReadOnly", ex.Code); - } -} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs deleted file mode 100644 index 2920da9..0000000 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ /dev/null @@ -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() - .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(() => - 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(() => - 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"); - } -} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs deleted file mode 100644 index 309c74f..0000000 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs +++ /dev/null @@ -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() - .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() - .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); - } -} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs deleted file mode 100644 index e9e9fd5..0000000 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ /dev/null @@ -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() - .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() - .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(() => - 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() - .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 { "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); - } -} diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs index 4b0e80d..21423c9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -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)); diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs deleted file mode 100644 index fbe2a14..0000000 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ /dev/null @@ -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() - .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(() => - 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() - .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() - .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(() => - 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() - .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() - .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); - } -} diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs deleted file mode 100644 index 31d4f23..0000000 --- a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs +++ /dev/null @@ -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() - .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() - .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() - .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() - .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); - } -} diff --git a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs deleted file mode 100644 index 478efb7..0000000 --- a/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs +++ /dev/null @@ -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() - .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.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); - } -} diff --git a/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs b/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs deleted file mode 100644 index 5c7bbf8..0000000 --- a/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.Extensions.Options; -using SeaHavenIndustries.Options; - -namespace SeaHavenIndustries.Helper -{ - public class BlazorWorkOrderSunsetGuard - { - private readonly BlazorWorkOrderSunsetOptions _options; - - public BlazorWorkOrderSunsetGuard(IOptions 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."); - } - } -} diff --git a/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs b/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs deleted file mode 100644 index e368d9f..0000000 --- a/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs +++ /dev/null @@ -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"; - } -} diff --git a/scripts/load-test/README.md b/scripts/load-test/README.md deleted file mode 100644 index c068043..0000000 --- a/scripts/load-test/README.md +++ /dev/null @@ -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 = "" -$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` | < 800ms | -| `board_with_search` | < 1000ms | -| `advanced_preset` | < 1200ms | -| `advanced_multi_filter` | < 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 diff --git a/scripts/load-test/work-order-search.k6.js b/scripts/load-test/work-order-search.k6.js deleted file mode 100644 index ff69d67..0000000 --- a/scripts/load-test/work-order-search.k6.js +++ /dev/null @@ -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); -}