From 4f697f257a6663b7b7edf14938be6a5c915631fd Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 30 Jun 2026 10:09:48 -0300 Subject: [PATCH] wip: work orders phases 1-7 (isolated from phase 0 foundation) --- .../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 + ...0_AddWorkOrderTypeAndScheduledDateIndex.cs | 43 ++ .../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/WorkOrderBoardProjection.cs | 106 ++++ .../Helpers/WorkOrderBoardQueryFilters.cs | 138 +++++ .../Helpers/WorkOrderBoardSearchFilter.cs | 47 ++ .../CompletionDocTemplateDataService.cs | 99 ++++ .../WorkOrderAdvancedSearchDataService.cs | 64 +++ .../WorkOrderBoardDataService.cs | 222 ++++++++ .../WorkOrderDetailDataService.cs | 90 ++++ .../WorkOrderDomainJobDataService.cs | 167 ++++++ .../IWorkOrderAdvancedSearchDataService.cs | 9 + .../Interfaces/IWorkOrderBoardDataService.cs | 10 + .../Interfaces/IWorkOrderDetailDataService.cs | 27 + .../IWorkOrderDomainJobDataService.cs | 38 ++ .../Interfaces/PrimaryDispatchProjection.cs | 9 + .../WorkOrderAdvancedSearchModels.cs | 27 + .../Interfaces/WorkOrderBoardModels.cs | 60 +++ .../Interfaces/WorkOrderDetailModels.cs | 30 ++ .../Board/WorkOrderBoardConstants.cs | 8 + .../Board/WorkOrderBoardStatusFilter.cs | 25 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 73 +++ SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 104 ++++ SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs | 21 + SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs | 53 ++ .../Exceptions/BoardQueryException.cs | 9 + .../Exceptions/WorkOrderBoardExceptions.cs | 25 + .../Helpers/WorkOrderAuditProjection.cs | 41 ++ .../Helpers/WorkOrderBoardApptTimeParser.cs | 57 ++ .../Helpers/WorkOrderBoardFieldMutations.cs | 132 +++++ .../Helpers/WorkOrderBoardFieldNames.cs | 66 +++ .../Helpers/WorkOrderBoardMutationRules.cs | 43 ++ .../Helpers/WorkOrderCommentProjection.cs | 23 + .../Helpers/WorkOrderDerivedFields.cs | 80 +++ .../Helpers/WorkOrderIngestFieldMapper.cs | 27 + .../Helpers/WorkOrderMediaProjection.cs | 82 +++ .../Helpers/WorkOrderOperationalWeek.cs | 20 + .../WorkOrderSearchDateRangeResolver.cs | 95 ++++ .../Implementation/PastDueCacheService.cs | 48 ++ .../WorkOrderAdvancedSearchService.cs | 62 +++ .../WorkOrderBoardCancelService.cs | 57 ++ .../WorkOrderBoardCreateService.cs | 210 ++++++++ .../Implementation/WorkOrderBoardMapper.cs | 41 ++ .../Implementation/WorkOrderBoardService.cs | 120 +++++ .../WorkOrderBoardUpdateService.cs | 510 ++++++++++++++++++ .../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 + .../IWorkOrderBoardCancelService.cs | 9 + .../IWorkOrderBoardCreateService.cs | 9 + .../Interfaces/IWorkOrderBoardMapper.cs | 11 + .../Interfaces/IWorkOrderBoardService.cs | 10 + .../IWorkOrderBoardUpdateService.cs | 9 + .../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 ++++++ .../WorkOrderBoardConcurrencyTests.cs | 184 +++++++ .../WorkOrderBoardCreateServiceTests.cs | 180 +++++++ .../WorkOrderBoardCreateSyncLockTests.cs | 101 ++++ .../WorkOrderBoardMutationRulesTests.cs | 56 ++ .../WorkOrderBoardSearchTests.cs | 238 ++++++++ .../WorkOrderBoardServiceTests.cs | 211 ++++++++ .../WorkOrderBoardUpdateServiceTests.cs | 227 ++++++++ .../WorkOrderDerivedFieldsTests.cs | 80 +++ .../WorkOrderPhase6Tests.cs | 284 ++++++++++ .../WorkOrderPhase7CoexistenceTests.cs | 170 ++++++ .../WorkOrderWeekRolledTests.cs | 291 ++++++++++ .../Helper/BlazorWorkOrderSunsetGuard.cs | 26 + .../Options/BlazorWorkOrderSunsetOptions.cs | 11 + docs/adr/work-orders-board-api.md | 194 +++++++ docs/spikes/search-strategy.md | 181 +++++++ docs/work-orders/phase-1/README.md | 155 ++++++ docs/work-orders/phase-2/README.md | 154 ++++++ docs/work-orders/phase-3/README.md | 174 ++++++ docs/work-orders/phase-4/README.md | 162 ++++++ .../phase-4/search-tier-decision.md | 51 ++ docs/work-orders/phase-5/README.md | 119 ++++ docs/work-orders/phase-6/README.md | 159 ++++++ docs/work-orders/phase-7/README.md | 245 +++++++++ docs/work-orders/phase-7/cloudwatch-alerts.md | 40 ++ .../phase-7/lambda-ingest-discovery.md | 93 ++++ .../phase-7/phase-7-gates-signoff.md | 55 ++ scripts/backfill-docstatus.ps1 | 23 + scripts/load-test/README.md | 40 ++ scripts/load-test/work-order-search.k6.js | 60 +++ scripts/seed-work-orders-search.ps1 | 56 ++ scripts/spike-status-mapping.sql | 90 ++++ scripts/spike-work-order-type.sql | 96 ++++ scripts/volume-discovery.ps1 | 61 +++ 121 files changed, 9583 insertions(+) create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs create mode 100644 Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs create mode 100644 Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs create mode 100644 Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs create mode 100644 Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs create mode 100644 Api.SeaHavenIndustries/Options/SyncOptions.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs create mode 100644 Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs create mode 100644 Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs create mode 100644 Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs create mode 100644 SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs create mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs create mode 100644 SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs create mode 100644 SeaHaven.DataServices/Exceptions/BoardQueryException.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs create mode 100644 SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs create mode 100644 SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs create mode 100644 SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs create mode 100644 SeaHaven.Services/Board/WorkOrderBoardConstants.cs create mode 100644 SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs create mode 100644 SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs create mode 100644 SeaHaven.Services/Exceptions/BoardQueryException.cs create mode 100644 SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs create mode 100644 SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs create mode 100644 SeaHaven.Services/Implementation/PastDueCacheService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderCommentService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderCompletionService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderDetailService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderIngestService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderMediaService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs create mode 100644 SeaHaven.Services/Interfaces/IPastDueCacheService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs create mode 100644 SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs create mode 100644 SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs create mode 100644 SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs create mode 100644 docs/adr/work-orders-board-api.md create mode 100644 docs/spikes/search-strategy.md create mode 100644 docs/work-orders/phase-1/README.md create mode 100644 docs/work-orders/phase-2/README.md create mode 100644 docs/work-orders/phase-3/README.md create mode 100644 docs/work-orders/phase-4/README.md create mode 100644 docs/work-orders/phase-4/search-tier-decision.md create mode 100644 docs/work-orders/phase-5/README.md create mode 100644 docs/work-orders/phase-6/README.md create mode 100644 docs/work-orders/phase-7/README.md create mode 100644 docs/work-orders/phase-7/cloudwatch-alerts.md create mode 100644 docs/work-orders/phase-7/lambda-ingest-discovery.md create mode 100644 docs/work-orders/phase-7/phase-7-gates-signoff.md create mode 100644 scripts/backfill-docstatus.ps1 create mode 100644 scripts/load-test/README.md create mode 100644 scripts/load-test/work-order-search.k6.js create mode 100644 scripts/seed-work-orders-search.ps1 create mode 100644 scripts/spike-status-mapping.sql create mode 100644 scripts/spike-work-order-type.sql create mode 100644 scripts/volume-discovery.ps1 diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs new file mode 100644 index 0000000..dcfbe7d --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs @@ -0,0 +1,47 @@ +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [ApiController] + [Route("api/workorders")] + public class WorkOrderIngestController : ControllerBase + { + private readonly IWorkOrderIngestService _ingestService; + + public WorkOrderIngestController(IWorkOrderIngestService ingestService) + { + _ingestService = ingestService; + } + + /// + /// 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 new file mode 100644 index 0000000..4277475 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs @@ -0,0 +1,58 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// 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 new file mode 100644 index 0000000..dfbff62 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..3959e0f --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs @@ -0,0 +1,56 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; + +namespace Api.SeaHavenIndustries.Filters +{ + /// 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 new file mode 100644 index 0000000..c9e05f1 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs @@ -0,0 +1,67 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class PastDueCacheHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _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 new file mode 100644 index 0000000..7963737 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs @@ -0,0 +1,75 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class WorkOrderWeekRolledHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _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 new file mode 100644 index 0000000..b7c5039 --- /dev/null +++ b/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..ffd01ef --- /dev/null +++ b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..48681c4 --- /dev/null +++ b/Api.SeaHavenIndustries/Options/SyncOptions.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..b82d711 --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..dd95de5 --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..791638c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs @@ -0,0 +1,23 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class WorkOrderJobsOptions + { + public const string SectionName = "WorkOrderJobs"; + + public WeekRolledJobOptions WeekRolled { get; set; } = new(); + public PastDueCacheJobOptions PastDueCache { get; set; } = new(); + } + + public class WeekRolledJobOptions + { + public bool Enabled { get; set; } = true; + public string RunAtUtc { get; set; } = "00:05"; + public string DayOfWeek { get; set; } = "Monday"; + } + + public class PastDueCacheJobOptions + { + public bool Enabled { get; set; } + public string RunAtUtc { get; set; } = "00:10"; + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs b/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs new file mode 100644 index 0000000..e1e268e --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260619150000_AddWorkOrderTypeAndScheduledDateIndex.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + public class AddWorkOrderTypeAndScheduledDateIndex : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL + ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL; + """); + + migrationBuilder.Sql(""" + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_ScheduledDate' + AND object_id = OBJECT_ID('workOrders')) + BEGIN + CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate); + END + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + IF EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = 'IX_workOrders_ScheduledDate' + AND object_id = OBJECT_ID('workOrders')) + DROP INDEX IX_workOrders_ScheduledDate ON workOrders; + """); + + migrationBuilder.Sql(""" + IF COL_LENGTH('workOrders', 'WorkOrderType') IS NOT NULL + ALTER TABLE workOrders DROP COLUMN WorkOrderType; + """); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs b/Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs new file mode 100644 index 0000000..325bab6 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624192552_Phase5_DomainEvents.cs @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..427b6cf --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260624200000_Phase4_SearchIndexes.cs @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..b134db2 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs @@ -0,0 +1,69 @@ +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 new file mode 100644 index 0000000..17cb4d3 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs @@ -0,0 +1,13 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + public class CompletionDocTemplate : FullAuditEntity + { + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } = true; + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs new file mode 100644 index 0000000..efa661a --- /dev/null +++ b/Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class WorkOrderWeekRolledLedger + { + public int Id { get; set; } + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public virtual WorkOrder? WorkOrder { get; set; } + public DateOnly SourceWeekStart { get; set; } + public DateTime ProcessedAt { get; set; } + public string CorrelationId { get; set; } = ""; + } +} diff --git a/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs new file mode 100644 index 0000000..bceff32 --- /dev/null +++ b/SeaHaven.DataServices/Board/WorkOrderBoardConstants.cs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..6d5d1e0 --- /dev/null +++ b/SeaHaven.DataServices/DTOs/WorkOrderBoardQuery.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..a7eca60 --- /dev/null +++ b/SeaHaven.DataServices/DTOs/WorkOrderBoardRowData.cs @@ -0,0 +1,20 @@ +namespace SeaHaven.DataServices.DTOs +{ + public record WorkOrderBoardRowData( + int Id, + string? WoNumber, + string? SiteCode, + string? LocationLabel, + DateTime? ScheduledDate, + DateTime? ScheduledStart, + DateTime? DueDate, + string? WorkOrderType, + string? ServiceType, + string? AssigneeId, + string? AssigneeName, + string? VendorCompany, + string? VendorTechnician, + string? Status, + string? PocName, + string? PocPhone); +} diff --git a/SeaHaven.DataServices/Exceptions/BoardQueryException.cs b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs new file mode 100644 index 0000000..b659424 --- /dev/null +++ b/SeaHaven.DataServices/Exceptions/BoardQueryException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.DataServices.Exceptions +{ + public class BoardQueryException : Exception + { + public BoardQueryException(string message) : base(message) + { + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs new file mode 100644 index 0000000..e2ad8d7 --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -0,0 +1,106 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardProjection + { + public static async Task> ProjectRowsAsync( + IQueryable query, + bool isUnscheduled) + { + var rows = await query + .Select(w => new + { + w.Id, + w.InternalWONumber, + w.RescheduleCount, + w.CarriedOver, + w.WorkOrderType, + w.SiteCode, + LocationName = w.Locations != null ? w.Locations.Name : null, + Poc = w.WorkOrderContacts! + .OrderBy(c => c.Id) + .Select(c => new + { + Name = c.POC != null + ? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim() + : null, + c.POC!.PhoneNumber, + c.Notes + }) + .FirstOrDefault(), + w.LifecycleStatus, + w.LegacyStatus, + w.Status, + w.AssignTo, + w.AssignToUser!.FirstName, + w.AssignToUser!.LastName, + w.AssignToUser!.Initials, + w.AssignToUser!.Color, + w.DueDate, + w.ScheduledDate, + w.ScheduledStart, + w.ScheduledEnd, + w.TargetWeek, + w.ScheduleWeekOnly, + VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null, + VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + ? w.PrimaryDispatch.Vendor.CompanyName + : null, + TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + ? w.PrimaryDispatch.Vendor.ContactName + : null, + TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + ? w.PrimaryDispatch.Vendor.Phone + : null, + DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null, + w.Trade, + w.Problem, + w.DocStatus, + w.PrimaryDispatchId, + w.RowVersion, + DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null + }) + .ToListAsync(); + + return rows.Select(w => new WorkOrderBoardRawRow( + w.Id, + w.InternalWONumber, + w.RescheduleCount, + w.CarriedOver, + w.WorkOrderType, + w.SiteCode, + w.LocationName, + w.Poc?.Name, + w.Poc?.PhoneNumber, + w.Poc?.Notes, + w.LifecycleStatus, + w.LegacyStatus ?? w.Status, + w.AssignTo, + w.FirstName, + w.LastName, + w.Initials, + w.Color, + w.DueDate, + w.ScheduledDate, + w.ScheduledStart, + w.ScheduledEnd, + w.TargetWeek, + w.ScheduleWeekOnly, + w.VendorId, + w.VendorName, + w.TechName, + w.TechPhone, + w.DispatchApptDate, + w.Trade ?? w.Problem, + w.Problem, + w.DocStatus, + w.PrimaryDispatchId, + w.RowVersion, + w.DispatchRowVersion, + isUnscheduled)).ToList(); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs new file mode 100644 index 0000000..bb9919b --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -0,0 +1,138 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardQueryFilters + { + public static IQueryable ApplyBaseScope(IQueryable query) + => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + public static IQueryable ApplyDispatcherFilter( + IQueryable query, + IReadOnlyList? dispatchers, + bool myWorkOrders, + string? currentUserId) + { + if (myWorkOrders && !string.IsNullOrEmpty(currentUserId)) + return query.Where(w => w.AssignTo == currentUserId); + + if (dispatchers == null || dispatchers.Count == 0) + return query; + + var ids = dispatchers + .Where(d => d != "__unassigned__") + .ToList(); + var includeUnassigned = dispatchers.Contains("__unassigned__"); + + if (includeUnassigned && ids.Count == 0) + return query.Where(w => w.AssignTo == null || w.AssignTo == ""); + + if (includeUnassigned && ids.Count > 0) + { + return query.Where(w => + w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo)); + } + + return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo)); + } + + public static IQueryable ApplyTypeFilter( + IQueryable query, + IReadOnlyList? types) + { + if (types == null || types.Count == 0) + return query; + + return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value)); + } + + public static IQueryable ApplySiteFilter( + IQueryable query, + IReadOnlyList? sites) + { + if (sites == null || sites.Count == 0) + return query; + + var normalized = sites + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .ToList(); + + if (normalized.Count == 0) + return query; + + return query.Where(w => w.SiteCode != null && normalized.Contains(w.SiteCode)); + } + + public static IQueryable ApplyStatusFilter( + IQueryable query, + IReadOnlyList? statuses) + { + if (statuses == null || statuses.Count == 0) + return query; + + return query.Where(w => w.LifecycleStatus != null && statuses.Contains(w.LifecycleStatus.Value)); + } + + public static IQueryable ApplyDocStatusFilter( + IQueryable query, + IReadOnlyList? docStatuses) + { + if (docStatuses == null || docStatuses.Count == 0) + return query; + + return query.Where(w => w.DocStatus != null && docStatuses.Contains(w.DocStatus.Value)); + } + + public static IQueryable ApplyVendorFilter( + IQueryable query, + IReadOnlyList? vendorIds) + { + if (vendorIds == null || vendorIds.Count == 0) + return query; + + return query.Where(w => + w.PrimaryDispatch != null && vendorIds.Contains(w.PrimaryDispatch.VendorId)); + } + + public static IQueryable ApplyPmTypeFilter( + IQueryable query, + IReadOnlyList? pmTypes) + { + if (pmTypes == null || pmTypes.Count == 0) + return query; + + var normalized = pmTypes + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim().ToLower()) + .ToList(); + + if (normalized.Count == 0) + return query; + + return query.Where(w => + normalized.Any(pm => + (w.Trade ?? "").ToLower().Contains(pm) + || (w.Problem ?? "").ToLower().Contains(pm))); + } + + public static IQueryable ApplyDateRangeFilter( + IQueryable query, + DateOnly dateFrom, + DateOnly dateTo) + { + var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date; + var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date; + + return query.Where(w => + (w.ScheduledDate != null + && w.ScheduledDate.Value.Date >= fromDate + && w.ScheduledDate.Value.Date <= toDate) + || (w.ScheduleWeekOnly == true + && w.TargetWeek != null + && w.TargetWeek >= dateFrom + && w.TargetWeek <= dateTo)); + } + } +} diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs new file mode 100644 index 0000000..2db505e --- /dev/null +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -0,0 +1,47 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Helpers +{ + public static class WorkOrderBoardSearchFilter + { + public static string? NormalizeSearch(string? search) + { + if (string.IsNullOrWhiteSpace(search)) + return null; + + var trimmed = search.Trim(); + return trimmed.Length < 2 ? null : trimmed; + } + + public static IQueryable 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 new file mode 100644 index 0000000..9466233 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs @@ -0,0 +1,99 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService + { + private readonly ApplicationDbContext _context; + + public CompletionDocTemplateDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> 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 new file mode 100644 index 0000000..556fe4f --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,64 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderAdvancedSearchDataService : IWorkOrderAdvancedSearchDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderAdvancedSearchDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task 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 new file mode 100644 index 0000000..e17b7e2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -0,0 +1,222 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderBoardDataService : IWorkOrderBoardDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderBoardDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task GetBoardRowsAsync(WorkOrderBoardQuery query) + { + var weekStartDate = query.WeekStart.ToDateTime(TimeOnly.MinValue); + var weekEndDate = query.WeekEnd.ToDateTime(TimeOnly.MinValue); + + var baseQuery = _context.workOrders + .AsNoTracking() + .Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + baseQuery = ApplyDispatcherFilter(baseQuery, query); + baseQuery = ApplyTypeFilter(baseQuery, query); + + var scheduledBase = baseQuery.Where(w => + (w.ScheduledDate != null + && w.ScheduledDate.Value.Date >= weekStartDate.Date + && w.ScheduledDate.Value.Date <= weekEndDate.Date) + || (w.ScheduleWeekOnly == true && w.TargetWeek == query.WeekStart)); + + var unscheduledBase = baseQuery.Where(w => + w.ScheduledDate == null && !IsTerminalExpression(w)); + + var scheduledTotal = await scheduledBase.CountAsync(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + scheduledBase = ApplySearchFilter(scheduledBase, query.Search); + unscheduledBase = ApplySearchFilter(unscheduledBase, query.Search); + } + + var scheduledRows = await ProjectRows(scheduledBase, isUnscheduled: false); + var unscheduledRows = await ProjectRows(unscheduledBase, isUnscheduled: true); + + return new WorkOrderBoardQueryResult(scheduledRows, unscheduledRows, scheduledTotal); + } + + public async Task> GetDispatcherLookupsAsync() + { + return await _context.Users + .AsNoTracking() + .Where(u => u.IsDeleted != true) + .OrderBy(u => u.FirstName) + .ThenBy(u => u.LastName) + .Select(u => new DispatcherLookupRow( + u.Id, + u.FirstName ?? "", + u.LastName, + u.Initials, + u.Color)) + .ToListAsync(); + } + + private static IQueryable ApplyDispatcherFilter(IQueryable query, WorkOrderBoardQuery boardQuery) + { + if (boardQuery.MyWorkOrders && !string.IsNullOrEmpty(boardQuery.CurrentUserId)) + return query.Where(w => w.AssignTo == boardQuery.CurrentUserId); + + if (boardQuery.Dispatchers == null || boardQuery.Dispatchers.Count == 0) + return query; + + var ids = boardQuery.Dispatchers + .Where(d => d != "__unassigned__") + .ToList(); + var includeUnassigned = boardQuery.Dispatchers.Contains("__unassigned__"); + + if (includeUnassigned && ids.Count == 0) + return query.Where(w => w.AssignTo == null || w.AssignTo == ""); + + if (includeUnassigned && ids.Count > 0) + { + return query.Where(w => + w.AssignTo == null || w.AssignTo == "" || ids.Contains(w.AssignTo)); + } + + return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo)); + } + + private static IQueryable ApplyTypeFilter(IQueryable query, WorkOrderBoardQuery boardQuery) + { + if (boardQuery.Types == null || boardQuery.Types.Count == 0) + return query; + + return query.Where(w => w.WorkOrderType != null && boardQuery.Types.Contains(w.WorkOrderType.Value)); + } + + private static IQueryable ApplySearchFilter(IQueryable query, string search) + { + var s = search.Trim().ToLower(); + return query.Where(w => + (w.SiteCode ?? "").ToLower().Contains(s) + || (w.InternalWONumber ?? "").ToLower().Contains(s) + || (w.WorkerOrderNumber ?? "").ToLower().Contains(s) + || (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s)) + || (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s)) + || (w.Trade ?? "").ToLower().Contains(s) + || (w.Problem ?? "").ToLower().Contains(s) + || (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s)) + || (w.Status ?? "").ToLower().Contains(s)); + } + + private static System.Linq.Expressions.Expression> 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 new file mode 100644 index 0000000..8ea9358 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -0,0 +1,90 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDetailDataService : IWorkOrderDetailDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderDetailDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task 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 new file mode 100644 index 0000000..f7e1ca1 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs @@ -0,0 +1,167 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDomainJobDataService : IWorkOrderDomainJobDataService + { + private static readonly LifecycleStatus?[] TerminalStatuses = + { + LifecycleStatus.Complete, + LifecycleStatus.Canceled, + LifecycleStatus.Closed + }; + + private readonly ApplicationDbContext _context; + + public WorkOrderDomainJobDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> 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 new file mode 100644 index 0000000..f758550 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAdvancedSearchDataService.cs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..67f1459 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -0,0 +1,10 @@ +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderBoardDataService + { + Task GetBoardRowsAsync(WorkOrderBoardQuery query); + Task> GetDispatcherLookupsAsync(); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs new file mode 100644 index 0000000..3f61a70 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..4c6af01 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDomainJobDataService.cs @@ -0,0 +1,38 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public sealed class WeekRolledProcessOutcome + { + public bool Processed { get; init; } + public bool Skipped { get; init; } + public int OldCarriedOver { get; init; } + public int NewCarriedOver { get; init; } + } + + public sealed class PastDueRefreshResult + { + public int SetCount { get; init; } + public int ClearedCount { get; init; } + public int ExaminedCount { get; init; } + } + + public interface IWorkOrderDomainJobDataService + { + Task> 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 new file mode 100644 index 0000000..ce6ebf6 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/PrimaryDispatchProjection.cs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..0cede89 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderAdvancedSearchQuery( + string? Search, + DateOnly DateFrom, + DateOnly DateTo, + IReadOnlyList? 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 new file mode 100644 index 0000000..4b96481 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -0,0 +1,60 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderBoardQuery( + DateOnly WeekStart, + DateOnly WeekEnd, + IReadOnlyList? Dispatchers, + bool MyWorkOrders, + IReadOnlyList? Types, + string? Search, + string? CurrentUserId); + + public record WorkOrderBoardRawRow( + int Id, + string? InternalWONumber, + int RescheduleCount, + int CarriedOver, + WorkOrderType? WorkOrderType, + string? SiteCode, + string? LocationName, + string? PocName, + string? PocPhone, + string? PocNotes, + LifecycleStatus? LifecycleStatus, + string? LegacyStatus, + string? AssignTo, + string? DispatcherFirstName, + string? DispatcherLastName, + string? Initials, + string? Color, + DateTime? DueDate, + DateTime? ScheduledDate, + DateTime? ScheduledStart, + DateTime? ScheduledEnd, + DateOnly? TargetWeek, + bool? ScheduleWeekOnly, + int? VendorId, + string? VendorName, + string? TechName, + string? TechPhone, + DateTime? DispatchApptDate, + string? Trade, + string? Problem, + DocStatus? DocStatus, + byte[]? RowVersion, + bool IsUnscheduled); + + public record WorkOrderBoardQueryResult( + IReadOnlyList ScheduledRows, + IReadOnlyList UnscheduledRows, + int ScheduledTotalBeforeSearch); + + public record DispatcherLookupRow( + string Id, + string FirstName, + string? LastName, + string? Initials, + string? Color); +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs new file mode 100644 index 0000000..cab956b --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderDetailExtendedFields( + string? Description, + string? Trade, + string? Problem, + string? SubTrade, + DateOnly? OriginalWeek, + DateOnly? OriginalDate, + DateTime? CompletedDate, + string? SignOffName, + string? SignOffAttachment, + string? SignOffSignature); + + public record DispatchSignoffRow( + int DispatchId, + string? SignoffType, + string? Name, + DateTime? SignedAt); + + public record CompletionDocTemplateRow( + int Id, + string Name, + string ServiceKey, + WorkOrderType? WorkOrderType, + string TemplateUrl, + bool IsActive); +} diff --git a/SeaHaven.Services/Board/WorkOrderBoardConstants.cs b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs new file mode 100644 index 0000000..a00f3e2 --- /dev/null +++ b/SeaHaven.Services/Board/WorkOrderBoardConstants.cs @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..45431dd --- /dev/null +++ b/SeaHaven.Services/Board/WorkOrderBoardStatusFilter.cs @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..674097c --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -0,0 +1,73 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class WorkOrderBoardQueryDto + { + public DateOnly WeekStart { get; set; } + public DateOnly? WeekEnd { get; set; } + public List? Dispatchers { get; set; } + public bool MyWorkOrders { get; set; } + public List? Types { get; set; } + public string? Search { get; set; } + } + + public class WorkOrderBoardCountsDto + { + public int Returned { get; set; } + public int Total { get; set; } + } + + public class WorkOrderBoardRowDto + { + public int Id { get; set; } + public string? WoNumber { get; set; } + public int RescheduleCount { get; set; } + public int CarriedOver { get; set; } + public WorkOrderType? WorkOrderType { get; set; } + public bool IsPastDue { get; set; } + public string? SiteCode { get; set; } + public string? LocationName { get; set; } + public string? PocName { get; set; } + public string? PocPhone { get; set; } + public string? PocNotes { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public string? LifecycleStatusLabel { get; set; } + public string? LegacyStatus { get; set; } + public string? DispatcherId { get; set; } + public string? DispatcherName { get; set; } + public string? Initials { get; set; } + public string? Color { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateOnly? TargetWeek { get; set; } + public bool? ScheduleWeekOnly { get; set; } + public string? DayGroup { get; set; } + public int? VendorId { get; set; } + public string? VendorName { get; set; } + public string? TechName { get; set; } + public string? TechPhone { get; set; } + public DateTime? ApptDate { get; set; } + public string? ApptTime { get; set; } + public string? Pm { get; set; } + public DocStatus? DocStatus { get; set; } + public byte[]? RowVersion { get; set; } + } + + public class WorkOrderBoardResponseDto + { + public DateOnly WeekStart { get; set; } + public DateOnly WeekEnd { get; set; } + public WorkOrderBoardCountsDto Counts { get; set; } = new(); + public List Unscheduled { get; set; } = new(); + public List Scheduled { get; set; } = new(); + } + + public class DispatcherLookupDto + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string? Initials { get; set; } + public string? Color { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs new file mode 100644 index 0000000..7ac1437 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -0,0 +1,104 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class WorkOrderDetailDto + { + public WorkOrderDetailInfoDto Info { get; set; } = new(); + public WorkOrderCompletionDto Completion { get; set; } = new(); + public List 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 new file mode 100644 index 0000000..885c363 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderJobDTOs.cs @@ -0,0 +1,21 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class WeekRolledJobResult + { + public int Processed { get; set; } + public int Skipped { get; set; } + public int Failed { get; set; } + public string CorrelationId { get; set; } = ""; + public DateOnly SourceWeekStart { get; set; } + public DateOnly SourceWeekEnd { get; set; } + public long DurationMs { get; set; } + } + + public sealed class PastDueCacheJobResult + { + public int SetCount { get; set; } + public int ClearedCount { get; set; } + public int ExaminedCount { get; set; } + public long DurationMs { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs b/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs new file mode 100644 index 0000000..2b02c63 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs @@ -0,0 +1,53 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class WorkOrderIngestPayloadDto + { + public required string ExternalWorkOrderId { get; set; } + public string? Description { get; set; } + public string? WoStatus { get; set; } + public string? Severity { get; set; } + public string? Customer { get; set; } + public string? SiteCode { get; set; } + public string? Building { get; set; } + public string? Address { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? DateReported { get; set; } + public DateTime? ScheduledStart { get; set; } + public string? SourceEmailS3Key { get; set; } + public DateTime? CreatedAt { get; set; } + } + + public sealed class WorkOrderIngestBatchRequestDto + { + public List? 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 new file mode 100644 index 0000000..fa3ecb5 --- /dev/null +++ b/SeaHaven.Services/Exceptions/BoardQueryException.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.Services.Exceptions +{ + public class BoardQueryException : Exception + { + public BoardQueryException(string message) : base(message) + { + } + } +} diff --git a/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs b/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs new file mode 100644 index 0000000..c15ecd0 --- /dev/null +++ b/SeaHaven.Services/Exceptions/WorkOrderBoardExceptions.cs @@ -0,0 +1,25 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Exceptions +{ + public class WorkOrderBoardValidationException : Exception + { + public WorkOrderBoardValidationException(string code, string message) : base(message) + { + Code = code; + } + + public string Code { get; } + } + + public class WorkOrderBoardConcurrencyException : Exception + { + public WorkOrderBoardConcurrencyException(WorkOrderBoardRowDto? currentState) + : base("The record was modified by another user. Refresh and retry.") + { + CurrentState = currentState; + } + + public WorkOrderBoardRowDto? CurrentState { get; } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs new file mode 100644 index 0000000..8b56b47 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -0,0 +1,41 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderAuditProjection + { + public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log) + { + return new WorkOrderAuditEntryDto + { + Type = MapEventType(log.EventType), + DispatcherId = log.UserId, + Action = log.Action ?? "", + FieldName = log.FieldName, + OldValue = log.OldValue ?? "", + NewValue = log.NewValue ?? "", + Time = log.CreatedAt.ToString("o"), + DispatchId = log.DispatchId + }; + } + + public static List 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 new file mode 100644 index 0000000..f7320a7 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardApptTimeParser.cs @@ -0,0 +1,57 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardApptTimeParser + { + public static bool TryParse(string? value, out TimeSpan? start, out TimeSpan? end, out string? error) + { + start = null; + end = null; + error = null; + + if (string.IsNullOrWhiteSpace(value)) + return true; + + var trimmed = value.Trim(); + var parts = trimmed.Split('–', '—', '-', StringComparison.Ordinal) + .Select(p => p.Trim()) + .Where(p => !string.IsNullOrEmpty(p)) + .ToArray(); + + if (parts.Length == 1) + { + if (!TimeSpan.TryParse(parts[0], out var single)) + { + error = "Invalid appointment time format."; + return false; + } + + start = single; + return true; + } + + if (parts.Length == 2) + { + if (!TimeSpan.TryParse(parts[0], out var startTime) || !TimeSpan.TryParse(parts[1], out var endTime)) + { + error = "Invalid appointment time range format."; + return false; + } + + start = startTime; + end = endTime; + return true; + } + + error = "Invalid appointment time format."; + return false; + } + + public static DateTime? CombineDateAndTime(DateTime? date, TimeSpan? time) + { + if (!date.HasValue || !time.HasValue) + return date; + + return date.Value.Date.Add(time.Value); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs new file mode 100644 index 0000000..b0bf837 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -0,0 +1,132 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Exceptions; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardFieldMutations + { + public static List 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 new file mode 100644 index 0000000..491c2f7 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -0,0 +1,66 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardFieldNames + { + public const string WoNumber = "woNumber"; + public const string WorkOrderType = "workOrderType"; + public const string SiteCode = "siteCode"; + public const string LifecycleStatus = "lifecycleStatus"; + public const string AssignTo = "assignTo"; + public const string DueDate = "dueDate"; + public const string ScheduledDate = "scheduledDate"; + public const string TargetWeek = "targetWeek"; + public const string ScheduleWeekOnly = "scheduleWeekOnly"; + public const string VendorId = "vendorId"; + public const string ApptDate = "apptDate"; + public const string ApptTime = "apptTime"; + public const string DocStatus = "docStatus"; + public const string RescheduleCount = "RescheduleCount"; + + private static readonly HashSet DispatchFields = new(StringComparer.OrdinalIgnoreCase) + { + VendorId, + ApptDate, + ApptTime + }; + + private static readonly HashSet AllEditable = new(StringComparer.OrdinalIgnoreCase) + { + WoNumber, + WorkOrderType, + SiteCode, + LifecycleStatus, + AssignTo, + DueDate, + ScheduledDate, + TargetWeek, + ScheduleWeekOnly, + VendorId, + ApptDate, + ApptTime, + DocStatus + }; + + public static bool IsEditable(string field) => AllEditable.Contains(field); + + public static bool IsDispatchField(string field) => DispatchFields.Contains(field); + + public static string ToAuditFieldName(string field) => field switch + { + WoNumber => "InternalWONumber", + WorkOrderType => "WorkOrderType", + SiteCode => "SiteCode", + LifecycleStatus => "LifecycleStatus", + AssignTo => "AssignTo", + DueDate => "DueDate", + ScheduledDate => "ScheduledDate", + TargetWeek => "TargetWeek", + ScheduleWeekOnly => "ScheduleWeekOnly", + VendorId => "VendorId", + ApptDate => "ApptDate", + ApptTime => "ApptTime", + DocStatus => "DocStatus", + _ => field + }; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs new file mode 100644 index 0000000..7a3a59a --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardMutationRules.cs @@ -0,0 +1,43 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderBoardMutationRules + { + private static readonly HashSet ReadOnlyStatuses = new() + { + LifecycleStatus.Canceled, + LifecycleStatus.Closed, + LifecycleStatus.Complete + }; + + public static bool IsReadOnly(LifecycleStatus? status) => ReadOnlyStatuses.Contains(status); + + public static bool ShouldBlockStatusChangeWhenPastDue(string field, bool isPastDue) + => field.Equals(WorkOrderBoardFieldNames.LifecycleStatus, StringComparison.OrdinalIgnoreCase) && isPastDue; + + public static bool ShouldAutoSchedule( + LifecycleStatus? status, + DateTime? scheduledDate, + string? assignTo) + => status == LifecycleStatus.Incomplete + && scheduledDate.HasValue + && !string.IsNullOrWhiteSpace(assignTo); + + public static bool IsReschedule(DateTime? previousDate, DateTime? newDate) + => previousDate.HasValue + && newDate.HasValue + && previousDate.Value.Date != newDate.Value.Date; + + public static DateOnly? ComputeOriginalWeek(DateTime? scheduledDate) + => scheduledDate.HasValue + ? DateOnly.FromDateTime(GetWeekStartMonday(scheduledDate.Value)) + : null; + + private static DateTime GetWeekStartMonday(DateTime date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.Date.AddDays(-diff); + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs new file mode 100644 index 0000000..096edd5 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -0,0 +1,23 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderCommentProjection + { + public static WorkOrderCommentDto ToDto(Comments comment) + { + return new WorkOrderCommentDto + { + Id = comment.Id, + AuthorId = comment.UserId, + Text = comment.Commenttext ?? "", + Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"), + Documents = comment.Documents + }; + } + + public static List ToDtos(IEnumerable comments) + => comments.Select(ToDto).ToList(); + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs new file mode 100644 index 0000000..382ecae --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs @@ -0,0 +1,80 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderDerivedFields + { + private static readonly HashSet TerminalStatuses = new() + { + LifecycleStatus.Complete, + LifecycleStatus.Canceled, + LifecycleStatus.Closed + }; + + public static bool IsPastDue( + DateTime? scheduledDate, + LifecycleStatus? lifecycleStatus, + DateTime? utcNow = null) + { + if (!scheduledDate.HasValue) + return false; + + if (TerminalStatuses.Contains(lifecycleStatus)) + return false; + + var today = (utcNow ?? DateTime.UtcNow).Date; + return scheduledDate.Value.Date < today; + } + + public static bool IsTerminal(LifecycleStatus? lifecycleStatus) + => TerminalStatuses.Contains(lifecycleStatus); + + public static string? GetDayGroup(DateTime? scheduledDate) + { + if (!scheduledDate.HasValue) + return null; + + return scheduledDate.Value.DayOfWeek switch + { + DayOfWeek.Monday => "monday", + DayOfWeek.Tuesday => "tuesday", + DayOfWeek.Wednesday => "wednesday", + DayOfWeek.Thursday => "thursday", + DayOfWeek.Friday => "friday", + _ => null + }; + } + + public static string? FormatApptTime(DateTime? start, DateTime? end) + { + if (!start.HasValue) + return null; + + if (end.HasValue) + return $"{start.Value:HH:mm} – {end.Value:HH:mm}"; + + return start.Value.ToString("HH:mm"); + } + + public static string GetLifecycleStatusLabel(LifecycleStatus? status) + { + if (!status.HasValue) + return ""; + + return status.Value switch + { + LifecycleStatus.Incomplete => "Incomplete", + LifecycleStatus.Scheduled => "Scheduled", + LifecycleStatus.InProgress => "In Progress", + LifecycleStatus.Complete => "Complete", + LifecycleStatus.Canceled => "Canceled", + LifecycleStatus.OnHold => "On Hold", + LifecycleStatus.PendingVendor => "Pending Vendor", + LifecycleStatus.PendingApproval => "Pending Approval", + LifecycleStatus.NeedsReview => "Needs Review", + LifecycleStatus.Closed => "Closed", + _ => status.Value.ToString() + }; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs b/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs new file mode 100644 index 0000000..cc48e5e --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..a2b4c52 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs @@ -0,0 +1,82 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderMediaProjection + { + public static List 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 new file mode 100644 index 0000000..5779a17 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderOperationalWeek.cs @@ -0,0 +1,20 @@ +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderOperationalWeek + { + public static DateOnly GetMondayOfWeek(DateOnly date) + { + var daysFromMonday = ((int)date.DayOfWeek + 6) % 7; + return date.AddDays(-daysFromMonday); + } + + public static DateOnly GetPreviousOperationalWeekStart(DateTime utcNow) + => GetMondayOfWeek(DateOnly.FromDateTime(utcNow)).AddDays(-7); + + public static DateOnly GetOperationalWeekEnd(DateOnly weekStartMonday) + => weekStartMonday.AddDays(4); + + public static string BuildWeekCorrelationId(DateOnly sourceWeekStart) + => $"week:{sourceWeekStart:yyyy-MM-dd}"; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs new file mode 100644 index 0000000..d41df2f --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -0,0 +1,95 @@ +namespace SeaHaven.Services.Helpers +{ + public enum WorkOrderAdvancedSearchDatePreset + { + ThisWeek, + LastWeek, + ThisMonth, + Last3Months, + NextWeek, + NextMonth, + Custom + } + + public record WorkOrderSearchDateRange(DateOnly From, DateOnly To); + + public static class WorkOrderSearchDateRangeResolver + { + public static WorkOrderSearchDateRange Resolve( + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo, + DateOnly? referenceDate = null) + { + var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + var resolvedPreset = preset ?? WorkOrderAdvancedSearchDatePreset.ThisWeek; + + if (resolvedPreset == WorkOrderAdvancedSearchDatePreset.Custom) + { + if (!dateFrom.HasValue || !dateTo.HasValue) + throw new ArgumentException("dateFrom and dateTo are required when datePreset is custom."); + + if (dateTo.Value < dateFrom.Value) + throw new ArgumentException("dateTo must be on or after dateFrom."); + + return new WorkOrderSearchDateRange(dateFrom.Value, dateTo.Value); + } + + return resolvedPreset switch + { + WorkOrderAdvancedSearchDatePreset.ThisWeek => ThisIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.LastWeek => LastIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.ThisMonth => ThisMonth(today), + WorkOrderAdvancedSearchDatePreset.Last3Months => Last3Months(today), + WorkOrderAdvancedSearchDatePreset.NextWeek => NextIsoWeek(today), + WorkOrderAdvancedSearchDatePreset.NextMonth => NextMonth(today), + _ => ThisIsoWeek(today) + }; + } + + public static DateOnly GetIsoWeekStart(DateOnly date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.AddDays(-diff); + } + + private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(-7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today) + { + var start = GetIsoWeekStart(today).AddDays(7); + return new WorkOrderSearchDateRange(start, start.AddDays(6)); + } + + private static WorkOrderSearchDateRange ThisMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange NextMonth(DateOnly today) + { + var start = new DateOnly(today.Year, today.Month, 1).AddMonths(1); + var end = start.AddMonths(1).AddDays(-1); + return new WorkOrderSearchDateRange(start, end); + } + + private static WorkOrderSearchDateRange Last3Months(DateOnly today) + { + var end = today; + var start = today.AddMonths(-3); + return new WorkOrderSearchDateRange(start, end); + } + } +} diff --git a/SeaHaven.Services/Implementation/PastDueCacheService.cs b/SeaHaven.Services/Implementation/PastDueCacheService.cs new file mode 100644 index 0000000..33c1646 --- /dev/null +++ b/SeaHaven.Services/Implementation/PastDueCacheService.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class PastDueCacheService : IPastDueCacheService + { + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly ILogger _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 new file mode 100644 index 0000000..51e5b36 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -0,0 +1,62 @@ +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService + { + private const int DefaultPageSize = 50; + private const int MaxPageSize = 100; + + private readonly IWorkOrderAdvancedSearchDataService _searchDataService; + + public WorkOrderAdvancedSearchService(IWorkOrderAdvancedSearchDataService searchDataService) + { + _searchDataService = searchDataService; + } + + public async Task> 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/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs new file mode 100644 index 0000000..7a8097c --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs @@ -0,0 +1,57 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardCancelService : IWorkOrderBoardCancelService + { + private readonly ApplicationDbContext _context; + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderBoardCancelService( + ApplicationDbContext context, + IWorkOrderBoardService boardService, + IWorkOrderAuditService auditService) + { + _context = context; + _boardService = boardService; + _auditService = auditService; + } + + public async Task 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 new file mode 100644 index 0000000..6d6c2a0 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -0,0 +1,210 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardCreateService : IWorkOrderBoardCreateService + { + private readonly ApplicationDbContext _context; + private readonly IWorkOrderBoardDataService _boardDataService; + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderAuditService _auditService; + private readonly IWorkOrderBoardCreateValidation _validator; + + public WorkOrderBoardCreateService( + ApplicationDbContext context, + IWorkOrderBoardDataService boardDataService, + IWorkOrderBoardService boardService, + IWorkOrderAuditService auditService, + IWorkOrderBoardCreateValidation validator) + { + _context = context; + _boardDataService = boardDataService; + _boardService = boardService; + _auditService = auditService; + _validator = validator; + } + + public async Task 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 new file mode 100644 index 0000000..e627138 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardMapper.cs @@ -0,0 +1,41 @@ +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardMapper : IWorkOrderBoardMapper + { + private readonly IWorkOrderStatusMapper _statusMapper; + + public WorkOrderBoardMapper(IWorkOrderStatusMapper statusMapper) + { + _statusMapper = statusMapper; + } + + public WorkOrderBoardItemDto MapToBoardItem(WorkOrderBoardRowDto row, DateOnly? today = null) + { + var effectiveToday = today ?? DateOnly.FromDateTime(DateTime.UtcNow); + var isUnassigned = string.IsNullOrWhiteSpace(row.AssigneeId); + var isPastDue = ComputeIsPastDue(row, effectiveToday); + + var presentation = new BoardRowPresentation( + StatusDisplay: _statusMapper.ToDisplay(row.Status, isUnassigned), + IsPastDue: isPastDue, + WorkOrderTypeDisplay: string.IsNullOrWhiteSpace(row.WorkOrderType) ? null : row.WorkOrderType.Trim() + ); + + return new WorkOrderBoardItemDto(row, presentation); + } + + private bool ComputeIsPastDue(WorkOrderBoardRowDto row, DateOnly today) + { + if (!row.ScheduledDate.HasValue) + return false; + + if (_statusMapper.IsTerminal(row.Status)) + return false; + + return row.ScheduledDate.Value < today; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs new file mode 100644 index 0000000..a4b6001 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -0,0 +1,120 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardService : IWorkOrderBoardService + { + private readonly IWorkOrderBoardDataService _boardDataService; + + public WorkOrderBoardService(IWorkOrderBoardDataService boardDataService) + { + _boardDataService = boardDataService; + } + + public async Task GetBoardAsync( + WorkOrderBoardQueryDto query, + string? currentUserId) + { + var weekStart = query.WeekStart; + var weekEnd = query.WeekEnd ?? weekStart.AddDays(4); + + ValidateWeekWindow(weekStart, weekEnd); + + var dataQuery = new WorkOrderBoardQuery( + weekStart, + weekEnd, + query.Dispatchers, + query.MyWorkOrders, + query.Types, + query.Search, + currentUserId); + + var result = await _boardDataService.GetBoardRowsAsync(dataQuery); + var utcNow = DateTime.UtcNow; + + return new WorkOrderBoardResponseDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Counts = new WorkOrderBoardCountsDto + { + Total = result.ScheduledTotalBeforeSearch, + Returned = result.ScheduledRows.Count + }, + Scheduled = result.ScheduledRows.Select(r => MapRow(r, utcNow)).ToList(), + Unscheduled = result.UnscheduledRows.Select(r => MapRow(r, utcNow)).ToList() + }; + } + + public async Task> GetDispatcherLookupsAsync() + { + var rows = await _boardDataService.GetDispatcherLookupsAsync(); + return rows.Select(r => new DispatcherLookupDto + { + Id = r.Id, + Name = FormatName(r.FirstName, r.LastName), + Initials = r.Initials, + Color = r.Color + }).ToList(); + } + + private static void ValidateWeekWindow(DateOnly weekStart, DateOnly weekEnd) + { + if (weekEnd < weekStart) + throw new ArgumentException("weekEnd must be on or after weekStart."); + + var spanDays = weekEnd.DayNumber - weekStart.DayNumber; + if (spanDays > 6) + throw new ArgumentException("Week window cannot exceed 7 days."); + } + + private static WorkOrderBoardRowDto MapRow(WorkOrderBoardRawRow row, DateTime utcNow) + { + var apptStart = row.ScheduledStart ?? row.DispatchApptDate; + var apptEnd = row.ScheduledEnd; + + return new WorkOrderBoardRowDto + { + Id = row.Id, + WoNumber = row.InternalWONumber, + RescheduleCount = row.RescheduleCount, + CarriedOver = row.CarriedOver, + WorkOrderType = row.WorkOrderType, + IsPastDue = WorkOrderDerivedFields.IsPastDue(row.ScheduledDate, row.LifecycleStatus, utcNow), + SiteCode = row.SiteCode, + LocationName = row.LocationName, + PocName = row.PocName, + PocPhone = row.PocPhone, + PocNotes = row.PocNotes, + LifecycleStatus = row.LifecycleStatus, + LifecycleStatusLabel = WorkOrderDerivedFields.GetLifecycleStatusLabel(row.LifecycleStatus), + LegacyStatus = row.LegacyStatus, + DispatcherId = row.AssignTo, + DispatcherName = FormatName(row.DispatcherFirstName, row.DispatcherLastName), + Initials = row.Initials, + Color = row.Color, + DueDate = row.DueDate, + ScheduledDate = row.ScheduledDate, + TargetWeek = row.TargetWeek, + ScheduleWeekOnly = row.ScheduleWeekOnly, + DayGroup = WorkOrderDerivedFields.GetDayGroup(row.ScheduledDate), + VendorId = row.VendorId, + VendorName = row.VendorName, + TechName = row.TechName, + TechPhone = row.TechPhone, + ApptDate = row.DispatchApptDate ?? row.ScheduledDate, + ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd), + Pm = row.Trade ?? row.Problem, + DocStatus = row.DocStatus, + RowVersion = row.RowVersion + }; + } + + private static string FormatName(string? firstName, string? lastName) + => $"{firstName ?? ""} {lastName ?? ""}".Trim(); + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs new file mode 100644 index 0000000..bc83567 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -0,0 +1,510 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderBoardUpdateService : IWorkOrderBoardUpdateService + { + private readonly ApplicationDbContext _context; + private readonly IWorkOrderBoardDataService _boardDataService; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderBoardUpdateService( + ApplicationDbContext context, + IWorkOrderBoardDataService boardDataService, + IWorkOrderAuditService auditService) + { + _context = context; + _boardDataService = boardDataService; + _auditService = auditService; + } + + public async Task PatchFieldAsync( + int workOrderId, + WorkOrderBoardPatchRequestDto request, + string? actorId) + { + if (string.IsNullOrWhiteSpace(request.Field)) + throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); + + var field = request.Field.Trim(); + if (!WorkOrderBoardFieldNames.IsEditable(field)) + throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable."); + + var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion"); + if (workOrderVersion == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + + var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion"); + + var workOrder = await _context.workOrders + .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status."); + + var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus); + if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(field, isPastDue)) + throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first."); + + SetOriginalRowVersion(_context.Entry(workOrder).Property(w => w.RowVersion), workOrderVersion); + + Dispatch? dispatch = null; + if (WorkOrderBoardFieldNames.IsDispatchField(field)) + { + var vendorIdHint = field.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase) + ? ParseVendorIdHint(request.Value) + : null; + + dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint); + + if (dispatchVersion != null) + SetOriginalRowVersion(_context.Entry(dispatch).Property(d => d.RowVersion), dispatchVersion); + else if (dispatch.RowVersion != null) + throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); + } + + var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(field); + var changes = ApplyFieldMutation(field, workOrder, dispatch, request.Value, auditField); + changes = changes.Where(c => c.Changed).ToList(); + + if (changes.Count == 0) + { + var unchanged = await LoadBoardRowAsync(workOrderId); + return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + + foreach (var change in changes) + { + switch (change.Action) + { + case AuditActionType.StatusChanged: + _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); + break; + case AuditActionType.AssignmentChanged: + _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); + break; + default: + _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId); + break; + } + } + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + _context.ChangeTracker.Clear(); + var currentState = await LoadBoardRowAsync(workOrderId); + throw new WorkOrderBoardConcurrencyException(currentState); + } + + var row = await LoadBoardRowAsync(workOrderId); + return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + } + + private List ApplyFieldMutation( + string field, + WorkOrder workOrder, + Dispatch? dispatch, + string? value, + string auditField) + { + return field.ToLowerInvariant() switch + { + WorkOrderBoardFieldNames.WoNumber => new List { 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) }, + WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField), + WorkOrderBoardFieldNames.DueDate => new List { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) }, + WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), + WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, + WorkOrderBoardFieldNames.VendorId => new List { ApplyVendorId(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, + WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, + _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") + }; + } + + private async Task ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate) + { + if (primaryDispatchId.HasValue) + { + var dispatch = await _context.Dispatches + .FirstOrDefaultAsync(d => d.Id == primaryDispatchId.Value && d.WorkOrderId == workOrder.Id); + + if (dispatch == null) + throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order."); + + workOrder.PrimaryDispatchId = dispatch.Id; + return dispatch; + } + + if (workOrder.PrimaryDispatchId.HasValue) + { + var existing = await _context.Dispatches + .FirstOrDefaultAsync(d => d.Id == workOrder.PrimaryDispatchId.Value); + + if (existing != null) + return existing; + } + + if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) + throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId."); + + var created = new Dispatch + { + WorkOrderId = workOrder.Id, + VendorId = vendorIdForCreate.Value, + Status = "Pending", + CreatedDate = DateTime.UtcNow + }; + _context.Dispatches.Add(created); + await _context.SaveChangesAsync(); + workOrder.PrimaryDispatchId = created.Id; + return created; + } + + private async Task ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) + { + if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error)) + throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number."); + + if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id)) + throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists."); + + var old = workOrder.InternalWONumber; + if (old == normalized) + return FieldChange.Unchanged(auditField); + + workOrder.InternalWONumber = normalized; + workOrder.WorkerOrderNumber = normalized; + return FieldChange.Changed(auditField, old, normalized); + } + + private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField) + { + if (string.IsNullOrWhiteSpace(value)) + throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required."); + + if (!Enum.TryParse(value, true, out var parsed)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); + + var old = workOrder.WorkOrderType?.ToString(); + if (workOrder.WorkOrderType == parsed) + return FieldChange.Unchanged(auditField); + + workOrder.WorkOrderType = parsed; + return FieldChange.Changed(auditField, old, parsed.ToString()); + } + + private static FieldChange ApplyStringField( + string? value, + string auditField, + Action setter, + Func getter) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + var old = getter(); + if (old == normalized) + return FieldChange.Unchanged(auditField); + + setter(normalized); + return FieldChange.Changed(auditField, old, normalized); + } + + private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) + { + var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value); + if (parsed == null) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid lifecycleStatus: {value}"); + + var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status; + if (workOrder.LifecycleStatus == parsed) + return FieldChange.Unchanged(auditField); + + workOrder.LifecycleStatus = parsed; + workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); + if (workOrder.LegacyStatus == null && workOrder.Status != null) + workOrder.LegacyStatus = workOrder.Status; + + return FieldChange.StatusChanged(auditField, old, parsed.ToString()); + } + + private static List ApplyAssignTo(WorkOrder workOrder, string? value, string auditField) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + var old = workOrder.AssignTo; + var changes = new List(); + + if (old != normalized) + { + workOrder.AssignTo = normalized; + changes.Add(FieldChange.AssignmentChanged(auditField, old, normalized)); + } + + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; + } + + private static FieldChange ApplyDateField( + string? value, + string auditField, + Action setter, + Func getter) + { + DateTime? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateTime.TryParse(value, out var dt)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid date for {auditField}: {value}"); + parsed = dt.Date; + } + + var old = FormatDate(getter()); + var newVal = FormatDate(parsed); + if (old == newVal) + return FieldChange.Unchanged(auditField); + + setter(parsed); + return FieldChange.Changed(auditField, old, newVal); + } + + private static List ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField) + { + var previousDate = workOrder.ScheduledDate; + DateTime? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateTime.TryParse(value, out var dt)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid scheduledDate: {value}"); + parsed = dt.Date; + } + + var changes = new List(); + var oldFormatted = FormatDate(previousDate); + var newFormatted = FormatDate(parsed); + + if (oldFormatted != newFormatted) + { + workOrder.ScheduledDate = parsed; + changes.Add(FieldChange.Changed(auditField, oldFormatted, newFormatted)); + + if (!workOrder.OriginalDate.HasValue && parsed.HasValue) + { + workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value); + var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed); + if (originalWeek.HasValue) + workOrder.OriginalWeek = originalWeek; + } + + if (WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed)) + { + var oldCount = workOrder.RescheduleCount.ToString(); + workOrder.RescheduleCount++; + changes.Add(FieldChange.Changed(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString())); + } + } + + changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); + return changes; + } + + private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField) + { + DateOnly? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateOnly.TryParse(value, out var d)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid targetWeek: {value}"); + parsed = d; + } + + var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd"); + var newVal = parsed?.ToString("yyyy-MM-dd"); + if (old == newVal) + return FieldChange.Unchanged(auditField); + + workOrder.TargetWeek = parsed; + return FieldChange.Changed(auditField, old, newVal); + } + + private static FieldChange ApplyBoolField( + string? value, + string auditField, + Action setter, + Func getter) + { + if (string.IsNullOrWhiteSpace(value)) + throw new WorkOrderBoardValidationException("InvalidValue", $"{auditField} value is required."); + + if (!bool.TryParse(value, out var parsed)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid boolean for {auditField}: {value}"); + + var old = getter()?.ToString(); + if (getter() == parsed) + return FieldChange.Unchanged(auditField); + + setter(parsed); + return FieldChange.Changed(auditField, old, parsed.ToString()); + } + + private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField) + { + var vendorId = ParseVendorIdHint(value); + if (!vendorId.HasValue) + throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer."); + + var old = dispatch.VendorId.ToString(); + if (dispatch.VendorId == vendorId.Value) + return FieldChange.Unchanged(auditField, dispatch.Id); + + dispatch.VendorId = vendorId.Value; + return FieldChange.Changed(auditField, old, vendorId.Value.ToString(), dispatch.Id); + } + + private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField) + { + DateTime? parsed = null; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!DateTime.TryParse(value, out var dt)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid apptDate: {value}"); + parsed = dt.Date; + } + + var old = FormatDate(dispatch.ScheduledDate); + var newVal = FormatDate(parsed); + if (old == newVal) + return FieldChange.Unchanged(auditField, dispatch.Id); + + dispatch.ScheduledDate = parsed; + return FieldChange.Changed(auditField, old, newVal, dispatch.Id); + } + + private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField) + { + if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error)) + throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime."); + + var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate; + var oldStart = FormatDateTime(workOrder.ScheduledStart); + var oldEnd = FormatDateTime(workOrder.ScheduledEnd); + + workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); + workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); + + var newStart = FormatDateTime(workOrder.ScheduledStart); + var newEnd = FormatDateTime(workOrder.ScheduledEnd); + var oldCombined = FormatApptRange(oldStart, oldEnd); + var newCombined = FormatApptRange(newStart, newEnd); + + if (oldCombined == newCombined) + return FieldChange.Unchanged(auditField, dispatch.Id); + + return FieldChange.Changed(auditField, oldCombined, newCombined, dispatch.Id); + } + + private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) + { + if (string.IsNullOrWhiteSpace(value)) + throw new WorkOrderBoardValidationException("InvalidValue", "docStatus value is required."); + + if (!Enum.TryParse(value, true, out var parsed)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid docStatus: {value}"); + + var old = workOrder.DocStatus?.ToString(); + if (workOrder.DocStatus == parsed) + return FieldChange.Unchanged(auditField); + + workOrder.DocStatus = parsed; + return FieldChange.Changed(auditField, old, parsed.ToString()); + } + + private static List 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(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); + return changes; + } + + private static void SetOriginalRowVersion(PropertyEntry property, byte[] version) + { + property.OriginalValue = version; + } + + private async Task LoadBoardRowAsync(int workOrderId) + { + var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId); + return raw == null ? null : WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow); + } + + private static byte[]? ParseRowVersion(string? base64, string fieldName) + { + if (string.IsNullOrWhiteSpace(base64)) + return null; + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new WorkOrderBoardValidationException("InvalidRowVersion", $"Invalid {fieldName} format."); + } + } + + private static int? ParseVendorIdHint(string? value) + { + if (string.IsNullOrWhiteSpace(value) || !int.TryParse(value, out var vendorId) || vendorId <= 0) + return null; + return vendorId; + } + + private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd"); + private static string? FormatDateTime(DateTime? value) => value?.ToString("O"); + private static string? FormatApptRange(string? start, string? end) + => start == null && end == null ? null : $"{start}|{end}"; + + private sealed record FieldChange( + string FieldName, + string? OldValue, + string? NewValue, + AuditActionType Action, + int? DispatchId, + bool Changed) + { + public static FieldChange Unchanged(string fieldName, int? dispatchId = null) + => new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false); + + public static FieldChange Changed(string fieldName, string? oldValue, string? newValue, int? dispatchId = null) + => new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true); + + public static FieldChange StatusChanged(string fieldName, string? oldValue, string? newValue) + => new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true); + + public static FieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue) + => new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs new file mode 100644 index 0000000..0b046e4 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs @@ -0,0 +1,59 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCommentService : IWorkOrderCommentService + { + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICommentDataService _commentData; + + public WorkOrderCommentService( + IWorkOrderDetailDataService detailData, + ICommentDataService commentData) + { + _detailData = detailData; + _commentData = commentData; + } + + public async Task> 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 new file mode 100644 index 0000000..50a9aac --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -0,0 +1,157 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCompletionService : IWorkOrderCompletionService + { + private readonly ApplicationDbContext _context; + private readonly ICompletionDocTemplateDataService _templateData; + private readonly IWorkOrderDetailDataService _detailData; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderCompletionService( + ApplicationDbContext context, + ICompletionDocTemplateDataService templateData, + IWorkOrderDetailDataService detailData, + IWorkOrderAuditService auditService) + { + _context = context; + _templateData = templateData; + _detailData = detailData; + _auditService = auditService; + } + + public async Task> 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 new file mode 100644 index 0000000..6723ec1 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -0,0 +1,149 @@ +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderDetailService : IWorkOrderDetailService + { + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICompletionDocTemplateDataService _templateData; + + public WorkOrderDetailService( + IWorkOrderBoardService boardService, + IWorkOrderDetailDataService detailData, + ICompletionDocTemplateDataService templateData) + { + _boardService = boardService; + _detailData = detailData; + _templateData = templateData; + } + + public async Task 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 new file mode 100644 index 0000000..f6bb5f5 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs @@ -0,0 +1,190 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderIngestService : IWorkOrderIngestService + { + private readonly ApplicationDbContext _db; + private readonly ISyncFieldMergePolicy _mergePolicy; + private readonly IWorkOrderFieldLockService _fieldLocks; + private readonly IWorkOrderAuditService _audit; + + public WorkOrderIngestService( + ApplicationDbContext db, + ISyncFieldMergePolicy mergePolicy, + IWorkOrderFieldLockService fieldLocks, + IWorkOrderAuditService audit) + { + _db = db; + _mergePolicy = mergePolicy; + _fieldLocks = fieldLocks; + _audit = audit; + } + + public async Task 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 new file mode 100644 index 0000000..610443c --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -0,0 +1,127 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderMediaService : IWorkOrderMediaService + { + private readonly ApplicationDbContext _context; + private readonly IWorkOrderDetailDataService _detailData; + + public WorkOrderMediaService(ApplicationDbContext context, IWorkOrderDetailDataService detailData) + { + _context = context; + _detailData = detailData; + } + + public async Task> 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 new file mode 100644 index 0000000..9e3d7ab --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs @@ -0,0 +1,82 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService + { + private readonly ApplicationDbContext _db; + private readonly WorkOrderJobRunStateAccessor _runState; + private readonly bool _syncEnabled; + private readonly bool _ingestEnabled; + private readonly bool _legacyDeprecationEnabled; + private readonly string? _legacySunsetDate; + + public WorkOrderOpsHealthService( + ApplicationDbContext db, + WorkOrderJobRunStateAccessor runState, + IOptions 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 new file mode 100644 index 0000000..bde6685 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderStatusMapper.cs @@ -0,0 +1,150 @@ +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 new file mode 100644 index 0000000..bf82574 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderWeekRolledService : IWorkOrderWeekRolledService + { + private const int BatchSize = 500; + + private readonly IWorkOrderDomainJobDataService _dataService; + private readonly IWorkOrderAuditService _auditService; + private readonly ILogger _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 new file mode 100644 index 0000000..e87f498 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IPastDueCacheService.cs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..8da8835 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderAdvancedSearchService.cs @@ -0,0 +1,11 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderAdvancedSearchService + { + Task> SearchAsync( + WorkOrderAdvancedSearchQueryDto query, + string? currentUserId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs new file mode 100644 index 0000000..d0b647b --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardCancelService.cs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..9818053 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..63afef7 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardMapper.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..d24e64a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardService.cs @@ -0,0 +1,10 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardService + { + Task GetBoardAsync(WorkOrderBoardQueryDto query, string? currentUserId); + Task> GetDispatcherLookupsAsync(); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs new file mode 100644 index 0000000..7631d35 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardUpdateService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderBoardUpdateService + { + Task PatchFieldAsync(int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs new file mode 100644 index 0000000..c08c10a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..1417306 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..385ff58 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..12b3e30 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..0242a38 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..13531f3 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderStatusMapper.cs @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..b8c1a6a --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderWeekRolledService.cs @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..3b00bed --- /dev/null +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..85ab2a3 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -0,0 +1,151 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCancelServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardCancelService Cancel, WorkOrderBoardUpdateService Update) CreateSut() + { + var options = new DbContextOptionsBuilder() + .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/WorkOrderBoardConcurrencyTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs new file mode 100644 index 0000000..7039963 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardConcurrencyTests.cs @@ -0,0 +1,184 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardConcurrencyTests +{ + private static ApplicationDbContext CreateContext(string dbName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderBoardUpdateService CreateService(ApplicationDbContext context) + { + var boardData = new WorkOrderBoardDataService(context); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + return new WorkOrderBoardUpdateService(context, boardData, audit); + } + + [Fact] + public async Task ParallelVendorAndStatusEdits_OnDifferentAggregates_Succeed() + { + var dbName = Guid.NewGuid().ToString(); + await using var seedContext = CreateContext(dbName); + seedContext.Vendors.Add(new Vendor { Id = 3, CompanyName = "Parallel Vendor" }); + var dispatch = new Dispatch + { + Id = 10, + VendorId = 3, + WorkOrderId = 1, + RowVersion = new byte[] { 9, 0, 0, 0, 0, 0, 0, 9 } + }; + seedContext.Dispatches.Add(dispatch); + seedContext.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 8, 0, 0, 0, 0, 0, 0, 8 } + }); + await seedContext.SaveChangesAsync(); + + await using var woContext = CreateContext(dbName); + await using var dispatchContext = CreateContext(dbName); + var woService = CreateService(woContext); + var dispatchService = CreateService(dispatchContext); + + var wo = await woContext.workOrders.FindAsync(1); + var disp = await dispatchContext.Dispatches.FindAsync(10); + + var statusTask = woService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Scheduled", + WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!) + }, "dispatcher-a"); + + var vendorTask = dispatchService.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "3", + WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!), + DispatchVersion = Convert.ToBase64String(disp!.RowVersion!), + PrimaryDispatchId = 10 + }, "dispatcher-b"); + + await Task.WhenAll(statusTask, vendorTask); + + Assert.Equal(LifecycleStatus.Scheduled, statusTask.Result.LifecycleStatus); + Assert.Equal(3, vendorTask.Result.VendorId); + } + + [Fact] + public async Task StaleWorkOrderVersion_ThrowsConcurrencyWithCurrentState() + { + var dbName = Guid.NewGuid().ToString(); + await using var context1 = CreateContext(dbName); + context1.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "A", + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context1.SaveChangesAsync(); + + await using var context2 = CreateContext(dbName); + var wo2 = await context2.workOrders.FirstAsync(); + wo2.SiteCode = "B"; + await context2.SaveChangesAsync(); + + await using var patchContext = CreateContext(dbName); + var service = CreateService(patchContext); + var staleVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "C", + WorkOrderVersion = staleVersion + }, "actor-1")); + + Assert.NotNull(ex.CurrentState); + Assert.Equal("B", ex.CurrentState!.SiteCode); + } + + [Fact] + public async Task RescheduleAuditCount_MatchesChangedFields() + { + var dbName = Guid.NewGuid().ToString(); + await using var context = CreateContext(dbName); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 20), + RescheduleCount = 0, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var service = CreateService(context); + var wo = await context.workOrders.FindAsync(1); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "2026-06-25", + WorkOrderVersion = Convert.ToBase64String(wo!.RowVersion!) + }, "actor-1"); + + var auditCount = await context.WorkOrderAuditLogs.CountAsync(); + Assert.Equal(2, auditCount); + } + + [Fact] + public async Task SecondEditWithStaleVersion_FailsAfterFirstEdit() + { + var dbName = Guid.NewGuid().ToString(); + await using var seed = CreateContext(dbName); + seed.workOrders.Add(new WorkOrder + { + Id = 1, + SiteCode = "A", + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 } + }); + await seed.SaveChangesAsync(); + + var originalVersion = Convert.ToBase64String(new byte[] { 4, 0, 0, 0, 0, 0, 0, 4 }); + + await using (var ctx1 = CreateContext(dbName)) + { + var svc1 = CreateService(ctx1); + await svc1.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "B", + WorkOrderVersion = originalVersion + }, "user-1"); + } + + await using var ctx2 = CreateContext(dbName); + var svc2 = CreateService(ctx2); + await Assert.ThrowsAsync(() => + svc2.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "C", + WorkOrderVersion = originalVersion + }, "user-2")); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs new file mode 100644 index 0000000..2920da9 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -0,0 +1,180 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardCreateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .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 new file mode 100644 index 0000000..309c74f --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -0,0 +1,101 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateSyncLockTests +{ + [Fact] + public async Task AfterShocCreate_SyncRejectsLockedSiteCode() + { + var options = new DbContextOptionsBuilder() + .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/WorkOrderBoardMutationRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs new file mode 100644 index 0000000..d57c27a --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardMutationRulesTests.cs @@ -0,0 +1,56 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardMutationRulesTests +{ + [Theory] + [InlineData(LifecycleStatus.Canceled, true)] + [InlineData(LifecycleStatus.Closed, true)] + [InlineData(LifecycleStatus.Complete, true)] + [InlineData(LifecycleStatus.Scheduled, false)] + [InlineData(LifecycleStatus.Incomplete, false)] + public void IsReadOnly_ReflectsTerminalStatuses(LifecycleStatus status, bool expected) + { + Assert.Equal(expected, WorkOrderBoardMutationRules.IsReadOnly(status)); + } + + [Fact] + public void ShouldBlockStatusChangeWhenPastDue_BlocksOnlyLifecycleStatus() + { + Assert.True(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( + WorkOrderBoardFieldNames.LifecycleStatus, isPastDue: true)); + Assert.False(WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue( + WorkOrderBoardFieldNames.ScheduledDate, isPastDue: true)); + } + + [Fact] + public void ShouldAutoSchedule_WhenIncompleteWithDateAndAssignee() + { + Assert.True(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Incomplete, + new DateTime(2026, 6, 25), + "dispatcher-1")); + } + + [Fact] + public void ShouldAutoSchedule_FalseWhenAlreadyScheduled() + { + Assert.False(WorkOrderBoardMutationRules.ShouldAutoSchedule( + LifecycleStatus.Scheduled, + new DateTime(2026, 6, 25), + "dispatcher-1")); + } + + [Fact] + public void IsReschedule_WhenPreviousDateExistsAndChanges() + { + Assert.True(WorkOrderBoardMutationRules.IsReschedule( + new DateTime(2026, 6, 20), + new DateTime(2026, 6, 25))); + Assert.False(WorkOrderBoardMutationRules.IsReschedule( + null, + new DateTime(2026, 6, 25))); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs new file mode 100644 index 0000000..e9e9fd5 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -0,0 +1,238 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardSearchFilterTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .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/WorkOrderBoardServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs new file mode 100644 index 0000000..587f8ec --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardServiceTests.cs @@ -0,0 +1,211 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardDataServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardRows_ReturnsScheduledInWeekAndUnscheduled() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC", ContactName = "Tech", Phone = "555-0100" }); + context.Dispatches.Add(new Dispatch + { + Id = 10, + VendorId = 1, + ScheduledDate = new DateTime(2026, 6, 24, 8, 0, 0) + }); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled, + PrimaryDispatchId = 10, + SiteCode = "BK5" + }, + new WorkOrder + { + Id = 2, + InternalWONumber = "10000000002", + ScheduledDate = null, + LifecycleStatus = LifecycleStatus.Incomplete, + SiteCode = "BK6" + }, + new WorkOrder + { + Id = 3, + InternalWONumber = "10000000003", + ScheduledDate = new DateTime(2026, 7, 1), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + false, + null, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal(1, result.ScheduledRows[0].Id); + Assert.Equal("Acme HVAC", result.ScheduledRows[0].VendorName); + Assert.Single(result.UnscheduledRows); + Assert.Equal(2, result.UnscheduledRows[0].Id); + Assert.Equal(1, result.ScheduledTotalBeforeSearch); + } + + [Fact] + public async Task GetBoardRows_FiltersByDispatcher() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + AssignTo = "user-a", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + AssignTo = "user-b", + ScheduledDate = new DateTime(2026, 6, 24), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + new[] { "user-a" }, + false, + null, + null, + null)); + + Assert.Single(result.ScheduledRows); + Assert.Equal("user-a", result.ScheduledRows[0].AssignTo); + } + + [Fact] + public async Task GetBoardRows_MyWorkOrders_FiltersCurrentUser() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + AssignTo = "me", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 2, + AssignTo = "other", + ScheduledDate = new DateTime(2026, 6, 23), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var service = new WorkOrderBoardDataService(context); + var result = await service.GetBoardRowsAsync(new WorkOrderBoardQuery( + weekStart, + weekStart.AddDays(4), + null, + true, + null, + null, + "me")); + + Assert.Single(result.ScheduledRows); + Assert.Equal(1, result.ScheduledRows[0].Id); + } +} + +public class WorkOrderBoardServiceTests +{ + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + [Fact] + public async Task GetBoardAsync_ComputesCountsAndPastDue() + { + await using var context = CreateContext(); + var weekStart = new DateOnly(2026, 6, 22); + var utcPast = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc); + + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + ScheduledDate = utcPast.AddDays(-2), + LifecycleStatus = LifecycleStatus.Scheduled + }); + + await context.SaveChangesAsync(); + + var dataService = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(dataService); + + var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = weekStart + }, null); + + Assert.Equal(1, response.Counts.Total); + Assert.Single(response.Scheduled); + Assert.True(response.Scheduled[0].IsPastDue); + Assert.Equal("wednesday", response.Scheduled[0].DayGroup); + } + + [Fact] + public async Task GetBoardAsync_InvalidWeekWindow_Throws() + { + await using var context = CreateContext(); + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(context)); + + await Assert.ThrowsAsync(() => boardService.GetBoardAsync(new WorkOrderBoardQueryDto + { + WeekStart = new DateOnly(2026, 6, 22), + WeekEnd = new DateOnly(2026, 6, 15) + }, null)); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs new file mode 100644 index 0000000..59b180c --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -0,0 +1,227 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardUpdateServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderBoardUpdateService(context, boardData, audit); + return (context, service); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + private static string ToVersion(Dispatch dispatch) + => Convert.ToBase64String(dispatch.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }); + + [Fact] + public async Task PatchField_AutoSchedulesIncompleteToScheduled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + AssignTo = "user-a", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "2026-06-25", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync()); + } + + [Fact] + public async Task PatchField_IncrementsRescheduleCount() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 20), + RescheduleCount = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ScheduledDate, + Value = "2026-06-25", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(2, result.RescheduleCount); + } + + [Fact] + public async Task PatchField_BlocksStatusChangeWhenPastDue() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.Date.AddDays(-3), + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.LifecycleStatus, + Value = "Complete", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("PastDueStatusBlocked", ex.Code); + } + + [Fact] + public async Task PatchField_BlocksEditWhenCanceled() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } + + [Fact] + public async Task PatchField_VendorCreatesPrimaryDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Vendor Co" }); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.VendorId, + Value = "5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.NotNull(result.PrimaryDispatchId); + Assert.Equal(5, result.VendorId); + Assert.Equal(1, await context.Dispatches.CountAsync()); + } + + [Fact] + public async Task PatchField_CreatesFieldLockAndAuditPerField() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + SiteCode = "OLD", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "BK5", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Single(await context.WorkOrderAuditLogs.Where(a => a.Action == AuditActionType.FieldChanged.ToString()).ToListAsync()); + Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode")); + } + + [Fact] + public async Task PatchField_NormalizesWoNumber() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "00000009999", + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WoNumber, + Value = "12345", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("00000012345", result.WoNumber); + } + + [Fact] + public async Task PatchField_RejectsDuplicateWoNumber() + { + var (context, service) = CreateSut(); + context.workOrders.AddRange( + new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }, + new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } }); + await context.SaveChangesAsync(); + + var wo = await context.workOrders.FindAsync(1); + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WoNumber, + Value = "22222", + WorkOrderVersion = ToVersion(wo!) + }, "actor-1")); + + Assert.Equal("DuplicateWoNumber", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs new file mode 100644 index 0000000..4b0e80d --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs @@ -0,0 +1,80 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.Helpers; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderDerivedFieldsTests +{ + private static readonly DateTime Today = new(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue() + { + var result = WorkOrderDerivedFields.IsPastDue( + Today.AddDays(-1), + LifecycleStatus.Scheduled, + Today); + + Assert.True(result); + } + + [Fact] + public void IsPastDue_WhenScheduledToday_ReturnsFalse() + { + var result = WorkOrderDerivedFields.IsPastDue( + Today, + LifecycleStatus.Scheduled, + Today); + + Assert.False(result); + } + + [Fact] + public void IsPastDue_WhenComplete_ReturnsFalse() + { + var result = WorkOrderDerivedFields.IsPastDue( + Today.AddDays(-3), + LifecycleStatus.Complete, + Today); + + Assert.False(result); + } + + [Fact] + public void IsPastDue_WhenNoScheduledDate_ReturnsFalse() + { + var result = WorkOrderDerivedFields.IsPastDue(null, LifecycleStatus.Incomplete, Today); + Assert.False(result); + } + + [Theory] + [InlineData(DayOfWeek.Monday, "monday")] + [InlineData(DayOfWeek.Friday, "friday")] + [InlineData(DayOfWeek.Saturday, null)] + public void GetDayGroup_MapsWeekdays(DayOfWeek day, string? expected) + { + var date = new DateTime(2026, 6, 22, 0, 0, 0, DateTimeKind.Utc); + while date.DayOfWeek != day) + date = date.AddDays(1); + + Assert.Equal(expected, WorkOrderDerivedFields.GetDayGroup(date)); + } + + [Fact] + public void FormatApptTime_WithStartAndEnd_ReturnsRange() + { + var start = new DateTime(2026, 6, 24, 7, 0, 0); + var end = new DateTime(2026, 6, 24, 9, 0, 0); + + var result = WorkOrderDerivedFields.FormatApptTime(start, end); + + Assert.Equal("07:00 – 09:00", result); + } + + [Fact] + public void FormatApptTime_WithStartOnly_ReturnsTime() + { + var start = new DateTime(2026, 6, 24, 7, 30, 0); + Assert.Equal("07:30", WorkOrderDerivedFields.FormatApptTime(start, null)); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs new file mode 100644 index 0000000..fbe2a14 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -0,0 +1,284 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderDocStatusPatchTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .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 new file mode 100644 index 0000000..31d4f23 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -0,0 +1,170 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderPhase7CoexistenceTests +{ + [Fact] + public void IngestFieldMapper_MapsDynamoStatus() + { + Assert.Equal("Open", WorkOrderIngestFieldMapper.MapStatus("new")); + Assert.Equal("In Progress", WorkOrderIngestFieldMapper.MapStatus("in_progress")); + Assert.Equal("Done", WorkOrderIngestFieldMapper.MapStatus("completed")); + } + + [Fact] + public async Task IngestService_CreatesThenUpdates_IdempotentByExternalId() + { + var options = new DbContextOptionsBuilder() + .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 new file mode 100644 index 0000000..478efb7 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs @@ -0,0 +1,291 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderWeekRolledTests +{ + private static readonly DateOnly SourceWeekStart = new(2026, 6, 16); + private static readonly DateOnly SourceWeekEnd = new(2026, 6, 20); + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .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 new file mode 100644 index 0000000..5c7bbf8 --- /dev/null +++ b/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..e368d9f --- /dev/null +++ b/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs @@ -0,0 +1,11 @@ +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/docs/adr/work-orders-board-api.md b/docs/adr/work-orders-board-api.md new file mode 100644 index 0000000..9501406 --- /dev/null +++ b/docs/adr/work-orders-board-api.md @@ -0,0 +1,194 @@ +# ADR: Work Orders Board API (Fase 1 read-only) + +**Status:** Proposed — spikes G0, G1, G3 concluídas +**Data:** 2026-06-19 +**Deciders:** Backend + FE (aprovação Fase 1 pending) + +--- + +## Contexto + +O Schedule Board (FE) organiza work orders por `ScheduledDate` em visão semanal. O backend atual expõe `GetWorkOrderList` — listagem CRUD paginada sem janela temporal, sem `siteCode`, sem vendor inline, sem `ScheduledDate` na projeção. + +Spikes de referência: + +- [Consumer Audit](../spikes/consumer-audit-getworkorderlist.md) +- [WorkOrderType](../spikes/work-order-type.md) +- [Search Strategy](../spikes/search-strategy.md) + +--- + +## Decisão + +### 1. Endpoint dedicado (sem `projection=`) + +Criar endpoint **separado** para o Schedule Board. **Não alterar** `GetWorkOrderList`. + +```http +GET /api/workorders/GetWorkOrdersBoard + ?scheduledFrom=2026-06-01 + &scheduledTo=2026-06-07 + &assignee=guid1&assignee=guid2 + &search=BK5 + &status=Open&status=InProgress + &locationId=42 +``` + +Rota alias: `GET /api/WorkOrder/GetWorkOrdersBoard` (consistente com controller existente). + +**Rejeitado:** + +- `GET /api/workorders/table` +- `GET /GetWorkOrderList?projection=table` + +### 2. Window-based fetch (sem paginação Fase 1) + +| Abordagem | Fase 1 | +|-----------|--------| +| `scheduledFrom` + `scheduledTo` | **Obrigatório** | +| `page` / `pageSize` | **Ausente** | +| Limite janela | Max **90 dias** (validação server-side) | + +Response envelope: + +```json +{ + "scheduledFrom": "2026-06-01", + "scheduledTo": "2026-06-07", + "totalCount": 42, + "items": [ + { + "row": { /* WorkOrderBoardRowDto */ }, + "presentation": { /* BoardRowPresentation */ } + } + ] +} +``` + +### 3. DTO — domínio vs presentation + +**`WorkOrderBoardRowDto`** — dados persistidos / joináveis: + +```csharp +public record WorkOrderBoardRowDto( + int Id, + string? WoNumber, + string? SiteCode, + string? LocationLabel, + DateOnly? ScheduledDate, + TimeOnly? ScheduledStart, + DateOnly? DueDate, + string? WorkOrderType, + string? ServiceType, + string? AssigneeId, + string? AssigneeName, + string? VendorCompany, + string? VendorTechnician, + string Status, + string? PocName, + string? PocPhone +); +``` + +**`BoardRowPresentation`** — calculado no mapper backend: + +```csharp +public record BoardRowPresentation( + string StatusDisplay, + bool IsPastDue, + string? WorkOrderTypeDisplay +); +``` + +Mapper: `IWorkOrderBoardMapper` centraliza Past Due e type display. + +### 4. Query EF (Fase 1) + +- Filtro base: `istemplate != true` +- Janela: `ScheduledDate >= from && ScheduledDate < to.AddDays(1)` +- Includes: `Locations`, `AssignToUser`, `Dispatches` → `Vendor` (1 vendor por WO — first active dispatch) +- POC: `WorkOrderContacts` (primary) +- Service type: `Trade` ou category join (mínimo: `Trade`) +- **Não incluir** subquery `lastUpdated` (Comments/AuditLog) + +### 5. Search (conforme spike) + +- Search scoped à janela temporal +- Servidor: `StartsWith` em `InternalWONumber`, `SiteCode` +- Count ≤ **300**: retornar semana inteira; FE filtra title/location/serviceType +- Count > 300: exigir termo indexável ou retornar 400 +- Assignee: `assigneeId[]` multi — **não** filtrar por nome + +Índices (migration separada pós-aprovação): + +- `IX_workOrders_ScheduledDate` (P1) +- `IX_workOrders_InternalWONumber`, `IX_workOrders_SiteCode` (P2, após fix tamanho coluna) + +### 6. WorkOrderType + +- Mapear coluna legada `WorkOrderType varchar(50)` na entidade EF +- Expor em `WorkOrderBoardRowDto` +- `IsPastDue` em presentation — **não** confundir com tipo +- Enum C# adiado até amostragem prod + +### 7. Status mapping + +- API retorna status **canônico** (`Open`, `InProgress`, `Completed`, `Cancelled`, `OnHold`) +- `StatusDisplay` via `IWorkOrderStatusMapper` (workshop G2 separado) +- Sem migration de enum DB + +--- + +## Consequências + +### Positivas + +- 1 HTTP call por mudança de semana (FE) +- Contrato estável para board sem acoplar CRUD legado +- Performance previsível (janela + threshold 300) +- Blazor legado inalterado (EF local) + +### Negativas / trade-offs + +- Duplicação parcial de lógica de listagem vs `GetWorkOrderList` (aceitável — bounded contexts distintos) +- Vendor technician pode ser null Fase 1 se dispatch spike incompleto +- Setup DB local necessário para validação integrada + +--- + +## Compliance com spikes + +| Spike | Gate | Resultado | +|-------|------|-----------| +| Consumer Audit | G0 | `GetWorkOrderList` órfão no repo — endpoint dedicado aprovado | +| WorkOrderType | G1 | Mapear coluna EF; Overdue = presentation | +| Search Strategy | G3 | Window-first, threshold 300, StartsWith identificadores | + +--- + +## Fora de escopo Fase 1 + +- PATCH inline edit +- Completion doc, media, flags, reorder +- Search vendor/tech server-side +- Paginação cross-week +- Alteração de `GetWorkOrderList` + +--- + +## Próximos passos (implementação) + +1. Aprovação explícita **Fase 1 only** +2. Migration: `WorkOrderType` + índice `ScheduledDate` +3. `GetWorkOrdersBoard` em `WorkOrderController` + `WorkOrderDataService` +4. DTOs + `IWorkOrderBoardMapper` +5. FE: `useWorkOrdersBoard({ from, to })` substituindo `RAW_ORDERS` + +--- + +## Referências + +- Gap analysis v3: `work_orders_api_gap_analysis_ce735946.plan.md` +- [`WorkOrderController.cs`](../../Api.SeaHavenIndustries/Controllers/WorkOrderController.cs) +- [`WorkOrderDataService.cs`](../../SeaHaven.DataServices/Implementation/WorkOrderDataService.cs) +- [`DispatchController.List`](../../Api.SeaHavenIndustries/Controllers/DispatchController.cs) — padrão janela temporal diff --git a/docs/spikes/search-strategy.md b/docs/spikes/search-strategy.md new file mode 100644 index 0000000..273e32d --- /dev/null +++ b/docs/spikes/search-strategy.md @@ -0,0 +1,181 @@ +# Spike Search Strategy — `GetWorkOrdersBoard` + +**Data:** 2026-06-19 +**Gate:** G3 (pré-requisito Fase 1) +**Objetivo:** Definir busca mínima viável para o Schedule Board sem degradar performance. + +--- + +## Estado atual (`GetWorkOrderList`) + +Implementação em `SeaHaven.DataServices/Implementation/WorkOrderDataService.cs` (`GetWorkOrderListPagedAsync`): + +| Aspecto | Comportamento atual | +|---------|---------------------| +| Paginação | `page` / `pageSize` (default 12), sem cap | +| Janela temporal | **Ausente** — sem filtro `ScheduledDate` | +| Search | `Contains` + `ToLower()` em 5 campos | +| Campos search | `InternalWONumber`, `WorkerOrderNumber`, `WorkerOrderTitle`, `Locations.Title`, `Locations.Name` | +| Campos omitidos | `SiteCode`, `Trade`, `Problem`, vendor, dispatcher por ID | +| Joins | `Locations`, `AssignToUser` (AspNetUsers) | +| Vendor/dispatch | **Não incluídos** | +| Projeção extra | Subquery `lastUpdated` via Comments + WorkOrderAuditLogs por linha | + +### Padrão SQL gerado (search) + +```sql +WHERE LOWER(InternalWONumber) LIKE '%term%' + OR LOWER(WorkerOrderNumber) LIKE '%term%' + OR LOWER(WorkerOrderTitle) LIKE '%term%' + OR LOWER(Locations.Name) LIKE '%term%' + ... +``` + +Leading wildcard → table scan em colunas `nvarchar(max)`. + +--- + +## Respostas às perguntas do spike + +| # | Pergunta | Decisão Fase 1 | +|---|----------|----------------| +| S1 | Volume típico por semana | Assumir **< 200 WOs/semana** até medir em prod; threshold fallback = **300** | +| S2 | Campos pesquisáveis mínimos | `internalWONumber`, `siteCode`, `assigneeId[]` (exato), `locationId` | +| S3 | LIKE vs full-text | **Sem full-text** Fase 1; `StartsWith` em WO# e siteCode; evitar `Contains` em title/location no servidor | +| S4 | Escopo | Search **sempre scoped** a `scheduledFrom` / `scheduledTo` (obrigatório no endpoint) | +| S5 | Vendor/tech no search | **Fase 2** — exige join `Dispatches → Vendor` | + +--- + +## Estratégia em 2 camadas + +``` +Request GetWorkOrdersBoard + │ + ├─► 1. Filtro janela ScheduledDate (obrigatório) + │ + ├─► 2. Filtros exatos: status, assigneeId[], locationId + │ + ├─► 3. COUNT no escopo + │ + ├─► count <= 300? + │ ├─ SIM → retornar semana inteira (sem paginação Fase 1) + │ │ search textual extra no FE: title, locationLabel, serviceType + │ └─ NÃO → exigir critério indexável no servidor + │ (woNumber ou siteCode StartsWith) + │ + └─► Response: { scheduledFrom, scheduledTo, totalCount, items[] } +``` + +### Campos Fase 1 vs Fase 2 + +| Campo | Fase 1 (servidor) | Fase 1 (client fallback) | Fase 2 | +|-------|-------------------|--------------------------|--------| +| `internalWONumber` / `workerOrderNumber` | `StartsWith` | — | — | +| `siteCode` | `StartsWith` ou igualdade | — | — | +| `assigneeId` | multi-select exato | — | — | +| `locationId` | exato | — | — | +| `status` | exato / multi | pills client-side | — | +| `workerOrderTitle` | — | client-side se count ≤ 300 | server `Contains` opcional | +| `locationLabel` | — | client-side | — | +| `serviceType` (trade/category) | — | client-side | server join | +| `vendorCompany` / `vendorTechnician` | — | — | join Dispatch→Vendor | +| `assigneeName` | — | client-side | evitar join por nome | + +--- + +## Query plan esperado (Fase 1) + +### Caminho feliz (semana típica, ≤ 300 rows) + +1. **Index seek** em `IX_workOrders_ScheduledDate` (proposto) com range `[scheduledFrom, scheduledTo+1day)` +2. Filtro `istemplate != true` +3. Filtros exatos em `AssignTo`, `LocationId`, `Status` (AND) +4. **Sem** search textual no servidor se `search` vazio +5. Join LEFT `Locations`, `AssignToUser`, LEFT `Dispatches` + `Vendor` (Fase 1 board — vendor no DTO, não no search) +6. Projeção flat para `WorkOrderBoardRowDto` — **sem** subquery `lastUpdated` + +### Caminho search servidor (count > 300 ou user digitou termo indexável) + +1. Mesma janela + filtros exatos +2. AND (`InternalWONumber LIKE 'term%'` OR `SiteCode LIKE 'term%'`) +3. Se count ainda > 300 → HTTP 400 com mensagem "Refine search or narrow date window" + +### Anti-padrões proibidos no board endpoint + +- `pageSize=500` como gambiarra de fetch semanal +- `Contains('%x%')` em múltiplas tabelas sem janela temporal +- Filtro assignee por nome concatenado (`FirstName + LastName`) — usar `assigneeId` +- Subquery correlated Comments/AuditLog por row + +--- + +## Índices recomendados + +Migration separada **após aprovação** desta spike (não incluir na Fase 1 code sem review): + +| Índice | Coluna(s) | Prioridade | Nota | +|--------|-----------|------------|------| +| `IX_workOrders_ScheduledDate` | `ScheduledDate` | **P1** | Filtro de janela do board | +| `IX_workOrders_InternalWONumber` | `InternalWONumber` | P2 | Requer alterar coluna de `nvarchar(max)` → `nvarchar(50)` | +| `IX_workOrders_SiteCode` | `SiteCode` | P2 | Idem — tamanho fixo ~50 | +| `IX_workOrders_AssignTo` | `AssignTo` | existente | Multi-select dispatcher | +| `IX_workOrders_LocationId` | `LocationId` | existente | Filtro location | + +**Full-text index:** não recomendado Fase 1 — volume semanal baixo + fallback client-side suficiente. + +--- + +## Referência de implementação + +Copiar padrão de janela temporal de `DispatchController.List`: + +```csharp +if (dateFrom.HasValue) + q = q.Where(d => d.DispatchedAt >= dateFrom.Value); +if (dateTo.HasValue) +{ + var end = dateTo.Value.Date.AddDays(1); + q = q.Where(d => d.DispatchedAt < end); +} +``` + +Adaptar para `WorkOrder.ScheduledDate` no board endpoint. + +--- + +## Parâmetros propostos — `GetWorkOrdersBoard` + +| Parâmetro | Tipo | Obrigatório | Descrição | +|-----------|------|-------------|-----------| +| `scheduledFrom` | `DateOnly` | **Sim** | Início da semana/janela | +| `scheduledTo` | `DateOnly` | **Sim** | Fim da janela (inclusivo) | +| `search` | `string` | Não | WO# ou siteCode prefix | +| `assignee` | `string[]` | Não | IDs de dispatcher (multi) | +| `status` | `string[]` | Não | Status canônico API | +| `locationId` | `int?` | Não | Filtro location | + +**Limite hard janela:** max **90 dias** (Fase 2 advanced search cross-week). + +--- + +## Threshold e fallback + +| Constante | Valor | Justificativa | +|-----------|-------|---------------| +| `BoardSearchClientSideThreshold` | **300** | Payload ~300 rows × ~500 bytes ≈ 150 KB — aceitável para 1 call/semana | +| Sem paginação Fase 1 | — | Janela semanal é o filtro natural | +| Paginação Fase 2 | cursor/offset | Só quando janela > 1 semana com search global | + +--- + +## Conclusão + +Fase 1 adota **window-first, search-second**: + +1. Janela `ScheduledDate` obrigatória + índice dedicado +2. Search servidor mínimo (`StartsWith` em identificadores) +3. Fallback client-side para campos ricos quando count ≤ 300 +4. Vendor/tech e full-text adiados para Fase 2 + +Esta estratégia desbloqueia `GetWorkOrdersBoard` sem replicar os anti-padrões de `GetWorkOrderListPagedAsync`. diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md new file mode 100644 index 0000000..5442c49 --- /dev/null +++ b/docs/work-orders/phase-1/README.md @@ -0,0 +1,155 @@ +# Fase 1 — Weekly Board (leitura) + +**Programa:** Work Orders Board +**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais. + +--- + +## Endpoints + +### `GET /api/workorders/board` + +Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada. + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Query params:** + +| Param | Obrigatório | Descrição | +|-------|-------------|-----------| +| `weekStart` | Sim | Segunda-feira da semana (`YYYY-MM-DD`) | +| `weekEnd` | Não | Sexta-feira (default: `weekStart + 4 dias`) | +| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos | +| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` | +| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) | +| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | + +**Exemplo:** + +```bash +curl -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true" +``` + +**Response:** + +```json +{ + "weekStart": "2026-06-22", + "weekEnd": "2026-06-26", + "counts": { "returned": 42, "total": 58 }, + "unscheduled": [ /* WorkOrderBoardRowDto[] */ ], + "scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ] +} +``` + +- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) +- `counts.returned` — WOs agendadas após aplicar search + +### `GET /api/workorders/lookups/dispatchers` + +Lista dispatchers para filtros do board. + +```json +[ + { "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" } +] +``` + +--- + +## Matriz coluna → campo API + +| Coluna board | Campos response | +|--------------|-----------------| +| WO# | `woNumber`, `rescheduleCount`, `carriedOver` | +| Type | `workOrderType`, `isPastDue` | +| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` | +| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` | +| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` | +| Due | `dueDate` | +| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` | +| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` | +| Appt | `apptDate`, `apptTime` | +| Past Due | `isPastDue` (derivado on-read) | +| Carried | `carriedOver` | +| Reschedule | `rescheduleCount` | +| Doc | `docStatus` | +| Actions | FE only | + +--- + +## Regras de derivação + +### isPastDue + +``` +ScheduledDate < UTC hoje +AND LifecycleStatus NOT IN (Complete, Canceled, Closed) +``` + +Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` + +### Vendor / Appt + +Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)). + +### Janela semanal + +WO entra em **scheduled** se: + +- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou** +- `ScheduleWeekOnly == true` e `TargetWeek == weekStart` + +**Unscheduled:** `ScheduledDate == null` e status não terminal. + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` | +| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` | +| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` | +| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) | +| Migration | `Data.SeaHavenIndustries/Migrations/20260624180343_Phase1_BoardIndexes.cs` | +| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` | + +--- + +## Gates de aceite + +### Desenvolvimento (concluído) + +- [x] Endpoint `GET board` — semana + Unscheduled +- [x] Projeção 12/14 colunas de dados +- [x] `isPastDue` derivado on-read +- [x] Vendor via dispatch primário +- [x] Filtros dispatcher / My WOs / tipo / search +- [x] Contador X of Y +- [x] Índices Tier S +- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total) + +### Staging / produção (pendente) + +- [ ] Sign-off CTO/PO (herda gates Fase 0) +- [ ] Dry-run migration Phase0 + Phase1 em staging +- [ ] Tier volume confirmado em staging/prod +- [ ] Smoke checklist Blazor / Sync / Portal +- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados) +- [ ] Latência aceitável com volume real (Tier S: < 25k WOs) +- [ ] Feature flag SHOC board em staging + +--- + +## Coexistência + +Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas. + +--- + +## Próximo passo + +**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular). diff --git a/docs/work-orders/phase-2/README.md b/docs/work-orders/phase-2/README.md new file mode 100644 index 0000000..584e8ad --- /dev/null +++ b/docs/work-orders/phase-2/README.md @@ -0,0 +1,154 @@ +# Fase 2 — Inline Edit + Concorrência + +**Programa:** Work Orders Board +**Objetivo:** Edição spreadsheet-style célula a célula no board SHOC, com dual RowVersion, audit granular e regras de domínio no backend. + +**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md) + +--- + +## Endpoints + +### `PATCH /api/workorders/{id}/board` + +Atualiza um único campo do board com validação otimista de concorrência. + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Request body:** + +```json +{ + "field": "scheduledDate", + "value": "2026-06-25", + "workOrderVersion": "", + "dispatchVersion": "", + "primaryDispatchId": 123 +} +``` + +| Campo | Obrigatório | Descrição | +|-------|-------------|-----------| +| `field` | Sim | Nome canônico do campo (case-insensitive) | +| `value` | Sim* | Valor serializado como string (*pode ser vazio para limpar datas) | +| `workOrderVersion` | Sim | `RowVersion` atual da WO (Base64) | +| `dispatchVersion` | Condicional | Obrigatório para campos Dispatch quando dispatch já existe | +| `primaryDispatchId` | Não | Valida que o dispatch pertence à WO | + +**Responses:** + +| HTTP | Body | Quando | +|------|------|--------| +| 200 | `WorkOrderBoardRowDto` | Sucesso — row completo com versões atualizadas | +| 409 | `WorkOrderBoardConflictDto` + `currentState` | Conflito de versão | +| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio violada | +| 400 | `Response` | Erro genérico / argumento inválido | + +**Exemplo:** + +```bash +curl -X PATCH -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"field":"siteCode","value":"BK5","workOrderVersion":"AQAAAAAAAAA="}' \ + "https://localhost:5001/api/workorders/1/board" +``` + +--- + +## Campos editáveis + +| field (API) | Agregado | Coluna board | Audit FieldName | +|-------------|----------|--------------|-----------------| +| `woNumber` | WorkOrder | WO# | InternalWONumber | +| `workOrderType` | WorkOrder | Type | WorkOrderType | +| `siteCode` | WorkOrder | Site | SiteCode | +| `lifecycleStatus` | WorkOrder | Status | LifecycleStatus | +| `assignTo` | WorkOrder | Assignee | AssignTo | +| `dueDate` | WorkOrder | Due | DueDate | +| `scheduledDate` | WorkOrder | Scheduled | ScheduledDate | +| `targetWeek` | WorkOrder | Scheduled (week-only) | TargetWeek | +| `scheduleWeekOnly` | WorkOrder | Scheduled | ScheduleWeekOnly | +| `vendorId` | Dispatch | Vendor | VendorId | +| `apptDate` | Dispatch | Appt | ApptDate | +| `apptTime` | Dispatch/WO | Appt | ApptTime | +| `docStatus` | WorkOrder | Doc | DocStatus | + +**Read-only:** `isPastDue`, `carriedOver`, `rescheduleCount` (badge derivado; count incrementado por regra). + +--- + +## Dual RowVersion + +| Agregado | Quando exigir versão | +|----------|---------------------| +| WorkOrder | Sempre (`workOrderVersion`) | +| Dispatch | Campos vendor/appt quando dispatch primário já existe (`dispatchVersion`) | + +O GET board retorna `rowVersion`, `dispatchRowVersion` e `primaryDispatchId` em cada row. + +**409 currentState:** inclui row recarregado do DB para refresh imediato no FE. + +--- + +## Regras de domínio + +| Regra | Comportamento | +|-------|---------------| +| Auto-schedule | `Incomplete` + `scheduledDate` + `assignTo` → `Scheduled` | +| Reschedule | Mudança de `scheduledDate` com data anterior → `rescheduleCount++` | +| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` | +| Past Due block | Não permite mudar `lifecycleStatus` quando `isPastDue` | +| Cancel read-only | Status terminal (`Canceled`, `Closed`, `Complete`) bloqueia PATCH | +| WO# 11 dígitos | Normalização numérica + unicidade | +| Vendor ADR | Mutação em `Dispatch`; cria dispatch primário se ausente | + +--- + +## Audit + +- 1 evento por campo alterado (`FieldChanged`, `StatusChanged`, ou `AssignmentChanged`) +- Side effects (auto-schedule, rescheduleCount) geram eventos adicionais +- Field lock (`WorkOrderFieldLocks`) criado automaticamente em edições manuais +- Eventos Dispatch incluem `dispatchId` + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| Helpers | `WorkOrderBoardMutationRules`, `WorkOrderNumberNormalizer`, `WorkOrderBoardFieldNames`, `WorkOrderBoardApptTimeParser` | +| Update service | `SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs` | +| Audit | `IWorkOrderAuditService.StageFieldChanged`, `LogFieldChangedAsync` | +| API | `WorkOrderController` — `PATCH {id}/board` | +| Testes | `WorkOrderBoardMutationRulesTests`, `WorkOrderNumberNormalizerTests`, `WorkOrderBoardUpdateServiceTests`, `WorkOrderBoardConcurrencyTests` | + +--- + +## Gates de aceite + +### Desenvolvimento + +- [x] `PATCH /api/workorders/{id}/board` para todos os campos da matriz +- [x] Dual RowVersion validado; 409 com `currentState` +- [x] Auto-schedule Incomplete→Scheduled +- [x] RescheduleCount++ em reagendamento +- [x] Bloqueio status quando PastDue; reagendar limpa flag on-read +- [x] WO cancelado/closed rejeita edição (422) +- [x] WO# normalizado 11 dígitos + unicidade +- [x] Vendor/appt muta Dispatch; cria primário se ausente +- [x] 1 audit event por campo; field lock criado +- [x] 51 testes unitários total (25 Fase 2 + 26 Fases 0–1) + +### Staging (pendente) + +- [ ] Smoke Sync durante edição SHOC +- [ ] UAT dispatcher: editar células reais no board +- [ ] Latência PATCH aceitável (<200ms p95 Tier S) + +--- + +## Próximo passo + +**Fase 3** — Criação wizard/inline, soft cancel dedicado, ManualEditFlag na criação. diff --git a/docs/work-orders/phase-3/README.md b/docs/work-orders/phase-3/README.md new file mode 100644 index 0000000..1e09aed --- /dev/null +++ b/docs/work-orders/phase-3/README.md @@ -0,0 +1,174 @@ +# Fase 3 — Criação e Cancelamento + +**Programa:** Work Orders Board +**Objetivo:** Criação wizard/inline via contrato do board SHOC, cancelamento soft dedicado, ManualEditFlag na criação, e delete físico restrito a Admin. + +**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md) + +--- + +## Endpoints + +### `POST /api/workorders/board` + +Cria uma WO com contrato alinhado ao board. Suporta wizard (payload completo) e inline row (subset mínimo). + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Request body:** + +```json +{ + "woNumber": "12345", + "workOrderType": "PM", + "siteCode": "BK5", + "assignTo": "dispatcher-guid", + "dueDate": "2026-07-01", + "scheduledDate": "2026-06-25", + "targetWeek": "2026-06-22", + "scheduleWeekOnly": false, + "vendorId": 5, + "apptDate": "2026-06-26", + "apptTime": "09:00 – 11:00", + "docStatus": "No", + "description": "Leak in break room", + "trade": "HVAC PM", + "locationId": 12, + "pocContactId": 3, + "pocNotes": "Call before arrival" +} +``` + +| Campo | Obrigatório | Descrição | +|-------|-------------|-----------| +| `workOrderType` | Sim | Enum `WorkOrderType` | +| `siteCode` | Sim | Código do site | +| `woNumber` | Não | Se omitido, auto-gera sequencial normalizado 11 dígitos | +| `scheduleWeekOnly` | Não | Se `true`, `targetWeek` é obrigatório | +| `vendorId` | Condicional | Obrigatório quando `apptDate` ou `apptTime` informados | + +**Responses:** + +| HTTP | Body | Quando | +|------|------|--------| +| 200 | `WorkOrderBoardRowDto` | Sucesso — row pronto para inserir no board | +| 400 | `Response` | Validação FluentValidation | +| 409 | `WorkOrderBoardValidationErrorDto` | WO# duplicado | +| 422 | `WorkOrderBoardValidationErrorDto` | Regra de domínio | + +**Exemplo (inline mínimo):** + +```bash +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"workOrderType":"PM","siteCode":"BK5"}' \ + "https://localhost:5001/api/workorders/board" +``` + +--- + +### `POST /api/workorders/{id}/cancel` + +Soft cancel — transição para `LifecycleStatus.Canceled` com WO read-only. + +**Autenticação:** Bearer JWT + +**Responses:** + +| HTTP | Body | Quando | +|------|------|--------| +| 200 | `WorkOrderBoardRowDto` | Cancelado (ou já estava cancelado — idempotente) | +| 422 | `WorkOrderBoardValidationErrorDto` | WO `Complete` ou `Closed` | + +**Exemplo:** + +```bash +curl -X POST -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/42/cancel" +``` + +--- + +## Regras de domínio na criação + +| Regra | Comportamento | +|-------|---------------| +| Status inicial | `Incomplete` | +| Auto-schedule | `scheduledDate` + `assignTo` → `Scheduled` | +| Set-once tracking | Primeira `scheduledDate` → `OriginalDate` / `OriginalWeek` | +| Week-only | `scheduleWeekOnly=true` + `targetWeek` → aparece na semana no GET board | +| WO# | Manual normalizado 11 dígitos; auto-gerado se omitido | +| Vendor | Cria dispatch primário quando `vendorId` informado | +| ManualEditFlag | Field locks criados para cada campo SHOC preenchido | + +--- + +## Soft cancel vs hard delete + +| Operação | Endpoint | Quem | Efeito | +|----------|----------|------|--------| +| Soft cancel | `POST /{id}/cancel` | Dispatcher | Status `Canceled`, WO permanece, PATCH bloqueado (422) | +| Hard delete | `DELETE DeleteWorkorder` | **Admin only** | Remove registro, anexos e contatos | + +O legado `POST AddWorkorder` (form/Blazor) permanece inalterado. + +--- + +## ManualEditFlag na criação + +Campos preenchidos na criação SHOC recebem lock em `WorkOrderFieldLocks` via audit `FieldChanged`. Sync subsequente em campo lockado gera `SyncRejected` (ver [manual-edit-flag-design.md](../phase-0/manual-edit-flag-design.md)). + +WO criada via Sync/Lambda continua sem locks até edição SHOC. + +--- + +## Audit + +| Evento | Action | +|--------|--------| +| Criação | 1× `Create` + `FieldChanged` por campo preenchido | +| Auto-schedule na criação | `StatusChanged` adicional | +| Cancel | `StatusChanged` → `Canceled` | +| Hard delete Admin | `Delete` | + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| Validação | `SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs` | +| Scheduling compartilhado | `SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs` | +| Create service | `SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs` | +| Cancel service | `SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs` | +| Audit | `IWorkOrderAuditService.StageCreated`, `LogCreatedAsync` | +| API | `WorkOrderController` — `POST board`, `POST {id}/cancel`, `DELETE` Admin | +| Testes | `WorkOrderBoardCreateServiceTests`, `WorkOrderBoardCancelServiceTests`, `WorkOrderBoardCreateSyncLockTests` | + +--- + +## Gates de aceite + +### Desenvolvimento + +- [x] `POST /api/workorders/board` — Incomplete / auto-schedule / week-only +- [x] WO# auto ou manual 11 dígitos + unicidade (409) +- [x] Vendor/dispatch primário na criação +- [x] Field locks + SyncRejected pós-create SHOC +- [x] `POST /{id}/cancel` idempotente; PATCH bloqueado após cancel +- [x] `DELETE DeleteWorkorder` restrito a Admin +- [x] Legado `AddWorkorder` inalterado +- [x] 68 testes unitários total (17 Fase 3 + 51 Fases 0–2) + +### Staging (pendente) + +- [ ] UAT dispatcher: wizard + inline row +- [ ] Smoke Sync durante criação SHOC +- [ ] Confirmar navegação FE para semana do WO criado + +--- + +## Próximo passo + +**Fase 4** — Busca contextual na semana + advanced search cross-week conforme tier volume. diff --git a/docs/work-orders/phase-4/README.md b/docs/work-orders/phase-4/README.md new file mode 100644 index 0000000..d9b602f --- /dev/null +++ b/docs/work-orders/phase-4/README.md @@ -0,0 +1,162 @@ +# Fase 4 — Search contextual + Advanced Search + +**Programa:** Work Orders Board +**Objetivo:** Hardening da busca contextual no board semanal, endpoint de advanced search cross-week (Tier S), índices de suporte e harness de load test. + +**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md), [Fase 3](../phase-3/README.md) + +**Tier vigente (dev):** **S (provisório)** — ver [search-tier-decision.md](./search-tier-decision.md) + +--- + +## Endpoints + +### `GET /api/workorders/board?search=` (hardening) + +Busca contextual na janela semanal. Campos cobertos: + +| Campo | Origem | +|-------|--------| +| Site | `SiteCode` | +| WO# | `InternalWONumber`, `WorkerOrderNumber` | +| Location | `Locations.Name` | +| Dispatcher | `AssignToUser` nome | +| PM | `Trade`, `Problem` | +| Vendor | `PrimaryDispatch.Vendor.CompanyName` | +| Tech | `PrimaryDispatch.Vendor.ContactName` | +| Status legado | `Status` | +| Lifecycle | label enum (`Scheduled`, `In Progress`, etc.) | + +**Regras:** + +- `search` com trim; ignorado se vazio ou < 2 caracteres +- `counts.total` — agendadas na semana **sem** search +- `counts.returned` — agendadas **com** search + +### `GET /api/workorders/board/search` + +Advanced search cross-week paginado. + +**Autenticação:** Bearer JWT + +**Query params:** + +| Param | Tipo | Descrição | +|-------|------|-----------| +| `search` | string | Texto livre (mesmos campos do contextual) | +| `datePreset` | enum | `thisWeek`, `lastWeek`, `thisMonth`, `last3Months`, `nextWeek`, `nextMonth`, `custom` | +| `dateFrom` / `dateTo` | date | Obrigatórios se `datePreset=custom` | +| `sites` | string[] | `SiteCode` | +| `types` | WorkOrderType[] | Tipo WO | +| `dispatchers` | string[] | GUIDs + `__unassigned__` | +| `statuses` | LifecycleStatus[] | Status operacional | +| `pmTypes` | string[] | Match em `Trade`/`Problem` (contains, case-insensitive) | +| `vendorIds` | int[] | Via `PrimaryDispatch.VendorId` | +| `docStatuses` | DocStatus[] | Enum `DocStatus` | +| `myWorkOrders` | bool | Filtro usuário logado | +| `page` | int | Default 1 | +| `pageSize` | int | Default 50; max 100 (Tier S/M) | +| `sortBy` | string | `scheduledDate` (default), `woNumber`, `dueDate` | +| `sortDir` | string | `asc` / `desc` | + +**Response:** `PagedResult` + +```json +{ + "items": [ /* WorkOrderBoardRowDto */ ], + "totalCount": 120, + "page": 1, + "pageSize": 50, + "totalPages": 3, + "hasNext": true, + "hasPrevious": false +} +``` + +**Exemplo:** + +```bash +curl -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/board/search?datePreset=thisMonth&sites=BK5&search=HVAC&page=1" +``` + +--- + +## Matriz preset → intervalo de datas + +Base: **segunda-feira ISO** (`WorkOrderSearchDateRangeResolver`). + +| Preset | Intervalo | +|--------|-----------| +| `thisWeek` | Seg–Dom da semana ISO corrente | +| `lastWeek` | Seg–Dom da semana ISO anterior | +| `nextWeek` | Seg–Dom da próxima semana ISO | +| `thisMonth` | 1º–último dia do mês corrente | +| `nextMonth` | 1º–último dia do mês seguinte | +| `last3Months` | Hoje − 3 meses → hoje | +| `custom` | `dateFrom` / `dateTo` (400 se ausentes) | + +**Filtro de data:** `ScheduledDate` no intervalo **OU** `ScheduleWeekOnly && TargetWeek` intersectando o intervalo. Exclui templates e `IsDeleted`. + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| Search filter | `SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs` | +| Query filters | `SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs` | +| Projeção | `SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs` | +| Date presets | `SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs` | +| Advanced data | `SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs` | +| Advanced service | `SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs` | +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| API | `WorkOrderController` — `GET board/search` | +| Migration | `20260624200000_Phase4_SearchIndexes.cs` | +| Testes | `SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs` | +| Load test | `scripts/load-test/work-order-search.k6.js` | +| Seed | `scripts/seed-work-orders-search.ps1` | + +--- + +## Gates de aceite + +### Dev (implementação) + +- [x] Helper de search compartilhado + testes por campo +- [x] `GET /board/search` paginado com filtros FE +- [x] Date presets alinhados ao SHOC (ISO Monday) +- [x] Migration índices Phase 4 +- [x] Harness k6 + seed sintético (Tier S local) +- [x] Endpoints legados inalterados +- [x] ~12 testes unitários novos (total ~80) + +### Staging/prod (GO — pendente) + +- [ ] Volume Discovery preenchido — [volume-discovery-report.md](../phase-0/volume-discovery-report.md) +- [ ] Tier assinado — [search-tier-decision.md](./search-tier-decision.md) +- [ ] Load test em staging com volume real +- [ ] Latência dentro do SLO do tier confirmado +- [ ] Smoke Portal/Blazor inalterados + +--- + +## SLOs Tier S (load test local) + +| Cenário | Endpoint | p95 | +|---------|----------|-----| +| Board sem search | `GET /board?weekStart=...` | < 800ms | +| Board com search | `GET /board?search=BK5` | < 1000ms | +| Advanced preset | `GET /board/search?datePreset=thisMonth` | < 1200ms | +| Advanced multi-filter | sites + types + search | < 1500ms | + +Ver [scripts/load-test/README.md](../../scripts/load-test/README.md). + +--- + +## Fora de escopo + +- Full-text search (Tier L) +- Search externo dedicado (Tier XL) +- Lookups PM catalog (`PM_TYPES`) — backlog #17 +- Alterações em endpoints legados diff --git a/docs/work-orders/phase-4/search-tier-decision.md b/docs/work-orders/phase-4/search-tier-decision.md new file mode 100644 index 0000000..da7dcfe --- /dev/null +++ b/docs/work-orders/phase-4/search-tier-decision.md @@ -0,0 +1,51 @@ +# Search Tier Decision — Fase 4 + +**Data:** 2026-06-24 +**Status:** Provisório (dev) — aguardando Volume Discovery staging/prod + +--- + +## Tier confirmado + +| Ambiente | Tier | Total WOs | Decisão | +|----------|------|-----------|---------| +| Dev local | **S (provisório)** | < 25k sintético | Implementar SQL filtros + paginação opcional | +| Staging | _pendente_ | _pendente acesso_ | Executar [volume-discovery-report.md](../phase-0/volume-discovery-report.md) | +| Produção | _pendente_ | _pendente acesso_ | Executar queries read-only | + +--- + +## Estratégia por tier + +| Tier | Volume | Estratégia advanced search | +|------|--------|----------------------------| +| **S** | < 25k | SQL filtros compostos + paginação default 50, max 100 | +| **M** | 25k–250k | Paginação **obrigatória** + índices covering | +| **L** | 250k–1M | Spike full-text SQL Server — **não implementar sem spike** | +| **XL** | > 1M | Documentar ARB; search externo — **não implementar** | + +--- + +## Implementação atual (Tier S) + +- `WorkOrderBoardSearchFilter` — filtro textual compartilhado (board + advanced) +- `WorkOrderAdvancedSearchDataService` — query composta com `CountAsync` + `Skip/Take` +- Índices Phase 4: `SiteCode`, `InternalWONumber`, `LifecycleStatus+ScheduledDate` +- Min 2 caracteres em `search` para evitar full-table scan + +--- + +## Próximos passos (gate staging) + +1. Executar `scripts/volume-discovery.ps1` contra staging read-only +2. Preencher tabela §3 do volume report +3. Atualizar este documento com tier real +4. Repetir load test k6 em staging; ajustar `pageSize` max se tier = M + +--- + +## Referências + +- [volume-discovery-report.md](../phase-0/volume-discovery-report.md) +- [phase-4/README.md](./README.md) +- Roadmap §10, §12 Fase 4 diff --git a/docs/work-orders/phase-5/README.md b/docs/work-orders/phase-5/README.md new file mode 100644 index 0000000..a1a3bfb --- /dev/null +++ b/docs/work-orders/phase-5/README.md @@ -0,0 +1,119 @@ +# Fase 5 — Scheduled Domain Events + +**Programa:** Work Orders Board +**Objetivo:** Jobs agendados in-process (`IHostedService`) para `WeekRolled` → `CarriedOver++` com idempotência WO+semana, audit System, cache opcional de `PastDue`, e garantia de que leituras do board continuam on-read como fonte da verdade. + +**Depende de:** [Fase 0](../phase-0/README.md), [Fase 1](../phase-1/README.md), [Fase 2](../phase-2/README.md), [Fase 3](../phase-3/README.md), [Fase 4](../phase-4/README.md) + +--- + +## Regra de elegibilidade WeekRolled + +```text +sourceWeek = semana operacional encerrada (segunda a sexta UTC) +Elegível se: + - ScheduledDate.Date ∈ [sourceWeekStart, sourceWeekEnd] + - LifecycleStatus ∉ {Complete, Canceled, Closed} + - istemplate != true (ou null) + - Ainda não processado em WorkOrderWeekRolledLedger (WorkOrderId + SourceWeekStart) +``` + +**Gate PO (pendente):** confirmar se WOs só com `TargetWeek` (sem `ScheduledDate`) entram no carry-over. A implementação atual considera **apenas** WOs com `ScheduledDate` na janela. + +--- + +## Idempotência + +Chave composta `(WorkOrderId, SourceWeekStart)` na tabela `WorkOrderWeekRolledLedger`. Re-execução do job ou endpoint admin na mesma semana não duplica `CarriedOver`. + +--- + +## Contrato de audit + +Um evento `WeekRolled` por WO processado: + +| Campo | Valor | +|-------|-------| +| `EventType` | `System` | +| `ActorType` | `System` | +| `Action` | `WeekRolled` | +| `FieldName` | `CarriedOver` | +| `OldValue` / `NewValue` | numéricos (string) | +| `CorrelationId` | `week:{yyyy-MM-dd}` (segunda da semana fonte) | + +Não aciona `ManualEditFlag` nem field locks. + +--- + +## Regra de ouro — isPastDue + +Jobs **nunca** são fonte da verdade para `isPastDue`. O board continua calculando via `WorkOrderDerivedFields.IsPastDue` on-read. O cache `OperationalFlags.PastDue` é otimização opcional (`WorkOrderJobs:PastDueCache:Enabled`, default **false**). + +--- + +## Configuração + +```json +"WorkOrderJobs": { + "WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" }, + "PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" } +} +``` + +Variáveis de ambiente (`.env.example`): + +```text +WorkOrderJobs__WeekRolled__Enabled=true +WorkOrderJobs__PastDueCache__Enabled=false +``` + +--- + +## Endpoints admin (ops-only) + +| Endpoint | Auth | Descrição | +|----------|------|-----------| +| `POST /api/workorders/jobs/week-rolled?sourceWeekStart=2026-06-16` | Admin | Reprocessa semana (segunda-feira). Idempotente. | +| `POST /api/workorders/jobs/past-due-cache` | Admin | Atualiza cache `OperationalFlags.PastDue` | + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| Ledger | `Data.SeaHavenIndustries/Models/WorkOrderWeekRolledLedger.cs` | +| Migration | `20260624210000_Phase5_DomainEvents.cs` | +| Data | `SeaHaven.DataServices/Implementation/WorkOrderDomainJobDataService.cs` | +| WeekRolled | `SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs` | +| PastDue cache | `SeaHaven.Services/Implementation/PastDueCacheService.cs` | +| Hosted | `Api.SeaHavenIndustries/HostedServices/*.cs` | +| API | `WorkOrderJobsController` | +| Testes | `SeaHavenIndustries.Tests/WorkOrderWeekRolledTests.cs` | + +--- + +## Monitoramento + +Logs estruturados com `CorrelationId` da semana. Ao concluir: `processed`, `skipped`, `failed`, `durationMs`. + +**Alerta ops:** se `failed > 0` ou job não executou em 8 dias → investigar + `POST /jobs/week-rolled` manual. + +--- + +## Critérios de aceite + +- [x] Job semanal incrementa `carriedOver` para WOs elegíveis da semana anterior +- [x] Re-execução na mesma semana não duplica (ledger WO+semana) +- [x] Audit `WeekRolled` System com old/new `CarriedOver` e `CorrelationId` +- [x] `GET /api/workorders/board` continua com `isPastDue` derivado on-read +- [x] Endpoint admin permite reprocessar semana específica +- [x] Testes cobrem idempotência, terminal skip, audit e on-read correctness + +--- + +## Fora de escopo + +- Message queue / worker externo +- Alteração do contrato REST do board para usar `OperationalFlags` +- WOs week-only (`TargetWeek` sem `ScheduledDate`) — gate PO diff --git a/docs/work-orders/phase-6/README.md b/docs/work-orders/phase-6/README.md new file mode 100644 index 0000000..7ec8117 --- /dev/null +++ b/docs/work-orders/phase-6/README.md @@ -0,0 +1,159 @@ +# Fase 6 — Completion Doc e Slide-over + +**Programa:** Work Orders Board +**Objetivo:** Contrato SHOC para slide-over (`WOSlideOver`): detalhe unificado, `docStatus` WO-level com templates por serviço, e tabs Comments/Audit/Media alinhadas ao frontend. + +**Depende de:** [Fase 0](../phase-0/README.md) … [Fase 5](../phase-5/README.md) + +--- + +## Endpoints + +### `GET /api/workorders/{id}/detail` + +Payload único para abrir o slide-over. + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Response:** + +```json +{ + "info": { /* WorkOrderDetailInfoDto — board row + description/trade/original* */ }, + "completion": { + "docStatus": "No", + "template": { "id": 1, "name": "HVAC PM Completion", "serviceKey": "HVAC PM", "templateUrl": "..." }, + "signOffName": null, + "signOffAttachment": null, + "signOffSignature": null, + "dispatchSignoffs": [] + }, + "comments": [{ "id": 1, "authorId": "guid", "text": "...", "time": "2026-06-01T12:00:00.0000000Z" }], + "audit": [{ "type": "manual", "dispatcherId": "guid", "action": "FieldChanged", "fieldName": "DocStatus", "oldValue": "No", "newValue": "Yes", "time": "..." }], + "media": [{ "id": 5, "category": "Extra", "url": "...", "isLegacy": false }] +} +``` + +```bash +curl -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/42/detail" +``` + +--- + +### `GET /api/workorders/{id}/audit?limit=50` + +Audit tab lazy refresh. Schema FE: `{ type, dispatcherId?, action, fieldName?, oldValue, newValue, time, dispatchId? }`. + +--- + +### `GET /api/workorders/{id}/comments` + +### `POST /api/workorders/{id}/comments` + +```json +{ "text": "Called vendor" } +``` + +Response: `{ id, authorId, text, time, documents? }`. + +--- + +### Completion templates + +| Método | Rota | Auth | Descrição | +|--------|------|------|-----------| +| GET | `/api/workorders/completion-templates` | JWT | Query `serviceKey`, `workOrderType` | +| GET | `/api/workorders/completion-templates/{id}` | JWT | Detalhe | +| POST | `/api/workorders/completion-templates` | Admin | Criar template | +| PUT | `/api/workorders/completion-templates/{id}` | Admin | Atualizar | +| DELETE | `/api/workorders/completion-templates/{id}` | Admin | Soft delete | + +Lookup na WO: `Trade` → fallback `WorkOrderType`. + +--- + +### `POST /api/workorders/{id}/completion-doc` + +Upload PDF preenchido (multipart). + +| Campo form | Obrigatório | +|------------|-------------| +| `file` | Sim | +| `signOffName` | Não | +| `signOffSignature` | Não | +| `workOrderVersion` | Recomendado (Base64 RowVersion) | + +**Regras:** +- WO read-only (`Canceled`/`Complete`/`Closed`) → 422 +- Sucesso → `SignOffAttachment` + `DocStatus=Yes` + audit `FieldChanged` +- Dispatch signoffs → somente leitura no slide-over + +```bash +curl -X POST -H "Authorization: Bearer " \ + -F "file=@completion.pdf" \ + -F "signOffName=Jane Doe" \ + "https://localhost:5001/api/workorders/42/completion-doc" +``` + +**Inline toggle:** `PATCH /api/workorders/{id}/board` com `field=docStatus` (Fase 2). + +--- + +### Media + +| Método | Rota | Descrição | +|--------|------|-----------| +| GET | `/api/workorders/{id}/media` | Lista unificada com `category` | +| POST | `/api/workorders/{id}/media` | multipart: `category` (Before/After/Extra/Completion) + `file` | +| DELETE | `/api/workorders/{id}/media/{mediaId}` | Soft delete (somente attachments com `id > 0`) | + +Legacy columns (`BeforPhotoAttachment`, `AfterPhotoAttachment`, `SignOffAttachment`) aparecem na projeção com `isLegacy: true`. + +--- + +## Schema + +Migration `Phase6_CompletionSlideOver`: + +- Tabela `CompletionDocTemplates` +- Coluna `Category` em `workOrderAttachments` +- Backfill SQL: `SignOffAttachment` preenchido → `DocStatus=Yes` + +Script ops: [`scripts/backfill-docstatus.ps1`](../../scripts/backfill-docstatus.ps1) + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| Model | `CompletionDocTemplate.cs`, `WorkOrderMediaCategory` enum | +| Migration | `20260625120000_Phase6_CompletionSlideOver.cs` | +| Data | `WorkOrderDetailDataService`, `CompletionDocTemplateDataService` | +| Services | `WorkOrderDetailService`, `WorkOrderCommentService`, `WorkOrderCompletionService`, `WorkOrderMediaService` | +| DTOs | `WorkOrderDetailDTOs.cs` | +| Helpers | `WorkOrderAuditProjection`, `WorkOrderCommentProjection`, `WorkOrderMediaProjection` | +| API | `WorkOrderController` — rotas `/detail`, `/comments`, `/audit`, `/completion-doc`, `/media`, `/completion-templates` | +| Testes | `WorkOrderPhase6Tests.cs` | + +--- + +## Critérios de aceite + +- [x] `GET /detail` retorna Info + Completion + Comments + Audit + Media no contrato FE +- [x] Coluna COMP DOC do board via `docStatus` no `WorkOrderBoardRowDto` (Fase 1) +- [x] `CompletionDocTemplate` CRUD admin + lookup por `serviceKey`/`workOrderType` +- [x] `POST completion-doc` persiste attachment, define `DocStatus=Yes`, audit +- [x] Comments/Audit tabs sem adapter no FE +- [x] Media com `category`; legado mapeado +- [x] Endpoints legados (`GetWorkorderById`, `GetCommentsByWorkorderId`) inalterados +- [x] Testes: DocStatus PATCH, detail, completion upload, comments, media + +--- + +## Fora de escopo + +- Geração de PDF server-side +- Alteração Vendor Portal checklist/signoff +- Rollout / sunset Blazor (Fase 7) diff --git a/docs/work-orders/phase-7/README.md b/docs/work-orders/phase-7/README.md new file mode 100644 index 0000000..61cfe2b --- /dev/null +++ b/docs/work-orders/phase-7/README.md @@ -0,0 +1,245 @@ +# Fase 7 — Rollout e Produção + +**Programa:** Work Orders Board +**Objetivo:** Piloto SHOC, rollout gradual, monitoramento de coexistência, sunset Blazor WO, cutover Lambda→API e deprecação legado. + +**Depende de:** [Fase 0](../phase-0/README.md) … [Fase 6](../phase-6/README.md) concluídas e validadas em staging. + +--- + +## Gates + +- **Entrada:** [phase-7-gates-signoff.md](phase-7-gates-signoff.md) +- **Saída:** piloto UAT, 100% SHOC, zero P1 × 2 semanas, Blazor sunset, Lambda cutover, ops health ativo + +--- + +## Runbook de rollout (ordem obrigatória) + +1. Fechar gates de entrada Fase 7 +2. Deploy API com ops health + ingest (ingest desabilitado em prod inicialmente) +3. SHOC: feature flag board em staging → piloto 1–2 dispatchers em prod +4. UAT dispatchers ([roteiros abaixo](#uat-dispatchers)) +5. Rollout gradual SHOC até 100% +6. Habilitar dual-run Lambda (`ingest` + Dynamo) +7. Validar drift → cutover Lambda só API +8. `Sync:Enabled=false` +9. `BlazorWorkOrderSunset:Enabled=true` +10. `LegacyEndpoints:DeprecationEnabled=true` + data Sunset +11. Remover Sync/legado após período de aviso (30 dias) + +--- + +## Endpoints Fase 7 + +### `GET /api/workorders/ops/health` + +Saúde operacional para coexistência multi-consumidor. + +**Auth:** Admin JWT + +```json +{ + "lastWeekRolledRunUtc": "2026-06-23T00:05:12Z", + "lastPastDueCacheRunUtc": null, + "lastWeekRolledError": null, + "lastPastDueCacheError": null, + "syncRejectedLast24h": 3, + "fieldLockCount": 142, + "syncEnabled": true, + "ingestEnabled": true, + "legacyDeprecationEnabled": false, + "legacySunsetDate": null, + "checkedAtUtc": "2026-06-25T12:00:00Z" +} +``` + +Ver [cloudwatch-alerts.md](cloudwatch-alerts.md). + +--- + +### `POST /api/workorders/ingest` + +Ingest direto idempotente (substitui ponte DynamoDB para WOs). + +**Auth:** header `X-Ingest-Key` (config `WorkOrderIngest:ApiKey`) + +**Body (single ou batch):** + +```json +{ + "externalWorkOrderId": "ext-wo-12345", + "description": "HVAC unit not cooling", + "woStatus": "new", + "severity": "2", + "customer": "Acme Corp", + "siteCode": "BK5", + "building": "Building A", + "address": "123 Main St", + "dueDate": "2026-07-01T00:00:00Z", + "dateReported": "2026-06-20T10:00:00Z", + "scheduledStart": null, + "sourceEmailS3Key": null, + "createdAt": "2026-06-20T10:00:00Z" +} +``` + +**Batch:** + +```json +{ + "items": [ { "externalWorkOrderId": "...", "description": "..." } ] +} +``` + +**Response:** + +```json +{ + "created": 1, + "updated": 0, + "results": [{ "externalWorkOrderId": "ext-wo-12345", "workOrderId": 42, "created": true }] +} +``` + +Mapeamento completo: [lambda-ingest-discovery.md](lambda-ingest-discovery.md) + +--- + +## Matriz de endpoints + +| Tipo | Exemplos | Política Fase 7 | +|------|----------|-----------------| +| Board (SHOC) | `GET board`, `PATCH {id}/board`, `POST board` | **Produção** | +| Slide-over | `GET {id}/detail`, completion-doc, media | **Produção** | +| Ingest | `POST ingest` | Dual-run → produção | +| Sync | `POST api/Sync/WorkOrders` | Desligar após cutover (`Sync:Enabled`) | +| Legado | `GetWorkOrderList`, `AddWorkorder`, `ChangeStatus` | Deprecation headers | +| Jobs | `POST jobs/week-rolled` | Ops Admin | + +--- + +## Configuração + +### API (`Api.SeaHavenIndustries`) + +```json +{ + "FrontendBaseUrl": "https://shoc.seahaven.com", + "WorkOrderIngest": { + "Enabled": true, + "ApiKey": "${WORKORDER_INGEST_API_KEY}" + }, + "Sync": { + "Enabled": true + }, + "LegacyEndpoints": { + "DeprecationEnabled": false, + "SunsetDate": "2026-12-31" + } +} +``` + +Env vars: ver [.env.example](../../../.env.example) + +### Blazor (`SeaHavenIndustries`) + +```json +{ + "BlazorWorkOrderSunset": { + "Enabled": false, + "ShocBaseUrl": "https://shoc.seahaven.com" + } +} +``` + +Quando `Enabled=true`: nav Work Orders aponta para SHOC; mutações EF bloqueadas; banner nas páginas WO. + +--- + +## UAT dispatchers + +Roteiro manual — marcar cada item antes de expandir rollout. + +| # | Cenário | Pass | +|---|---------|------| +| 1 | Login SHOC; carregar semana (Mon–Fri + Unscheduled) | [ ] | +| 2 | Filtros: dispatcher, My WOs, tipo, busca contextual | [ ] | +| 3 | Inline edit célula (status, assignee, due) | [ ] | +| 4 | Conflito 409 — outro usuário editou; mensagem clara | [ ] | +| 5 | Criar WO wizard (Incomplete → Scheduled auto) | [ ] | +| 6 | Cancel soft; campos read-only pós-cancel | [ ] | +| 7 | Advanced search cross-week | [ ] | +| 8 | Slide-over: detail, comments, audit, media | [ ] | +| 9 | Upload completion doc; docStatus no board | [ ] | +| 10 | Badge PastDue / CarriedOver após segunda-feira | [ ] | + +**Sign-off UAT** + +| Dispatcher | Data | PO | +|------------|------|-----| +| | | | + +--- + +## Piloto e rollout SHOC + +Controle **somente no frontend** (feature flags). Backend não filtra dispatchers. + +Critérios para avançar: + +- Zero P1 na semana do piloto +- `GET board` latência aceitável (Tier S) +- Vendor Portal regression verde + +--- + +## Sunset Blazor + +Ver [data-ownership-model.md](../phase-0/data-ownership-model.md) §3. + +1. Congelar `WorkorderService` (bugfix only) até data PO +2. Habilitar `BlazorWorkOrderSunset:Enabled` +3. Smoke final [smoke-checklist.md](../phase-0/smoke-checklist.md) Blazor EF + +--- + +## Cutover Lambda + +Ver [lambda-ingest-discovery.md](lambda-ingest-discovery.md). + +--- + +## Testes automatizados + +```powershell +dotnet test SeaHavenIndustries.Tests --filter "FullyQualifiedName~WorkOrderPhase7" +``` + +Suite: `WorkOrderPhase7CoexistenceTests.cs` + +--- + +## Critérios de aceite Fase 7 + +- [x] `GET /api/workorders/ops/health` +- [x] `POST /api/workorders/ingest` com API key +- [x] `Sync:Enabled` feature flag +- [x] Legacy deprecation middleware +- [x] Blazor sunset config + guard mutações +- [x] Documentação gates, UAT, Lambda mapping +- [ ] Piloto prod (ops/PO) +- [ ] 100% dispatchers SHOC (ops/PO) +- [ ] Zero P1 × 2 semanas (ops/PO) +- [ ] Lambda cutover executado (ops) + +--- + +## Comunicação + +| Audiência | Mensagem | Quando | +|-----------|----------|--------| +| Dispatchers piloto | Novo board SHOC; suporte dedicado | Início piloto | +| Todos dispatchers | Rollout gradual; treinamento | Durante rollout | +| Usuários Blazor | WO migrou para SHOC; link direto | Sunset Blazor | +| Engenharia | Sync desligado; usar ingest API | Pós-cutover | diff --git a/docs/work-orders/phase-7/cloudwatch-alerts.md b/docs/work-orders/phase-7/cloudwatch-alerts.md new file mode 100644 index 0000000..d83e929 --- /dev/null +++ b/docs/work-orders/phase-7/cloudwatch-alerts.md @@ -0,0 +1,40 @@ +# Alertas Operacionais — Fase 7 + +**Endpoint de saúde:** `GET /api/workorders/ops/health` (Admin JWT) + +--- + +## Métricas expostas + +| Campo | Fonte | Uso | +|-------|-------|-----| +| `lastWeekRolledRunUtc` | `WorkOrderJobRunState` + ledger | Job segunda-feira | +| `lastPastDueCacheRunUtc` | `WorkOrderJobRunState` | Cache opcional PastDue | +| `syncRejectedLast24h` | `WorkOrderAuditLogs` Action=SyncRejected | Conflito SHOC vs ingest | +| `fieldLockCount` | `WorkOrderFieldLocks` | Adoção edição manual | +| `syncEnabled` | `Sync:Enabled` | Estado ponte Dynamo | +| `ingestEnabled` | `WorkOrderIngest:Enabled` | Ingest direto ativo | + +--- + +## Alertas recomendados (Elastic Beanstalk / CloudWatch) + +| Alerta | Condição | Severidade | +|--------|----------|------------| +| WeekRolled stale | `lastWeekRolledRunUtc` > 8 dias | P1 | +| WeekRolled job error | `lastWeekRolledError` não nulo | P1 | +| SyncRejected spike | `syncRejectedLast24h` > 50 | P2 | +| API 5xx board | ALB/Beanstalk 5xx rate > 1% em `/api/workorders/board` | P1 | +| Ingest auth failures | 401 em `/api/workorders/ingest` > 10/h | P2 | +| Sync disabled em prod sem cutover | `syncEnabled=false` e ingest não validado | P2 | + +--- + +## Verificação manual (ops) + +```powershell +# Com token Admin +curl -H "Authorization: Bearer " https:///api/workorders/ops/health +``` + +Agendar checagem diária durante piloto e rollout. diff --git a/docs/work-orders/phase-7/lambda-ingest-discovery.md b/docs/work-orders/phase-7/lambda-ingest-discovery.md new file mode 100644 index 0000000..b07d789 --- /dev/null +++ b/docs/work-orders/phase-7/lambda-ingest-discovery.md @@ -0,0 +1,93 @@ +# Lambda Ingest — Descoberta e Mapeamento de Campos + +**Fase:** 7 +**Status:** Documentação de cutover (código Lambda fora deste repositório) + +--- + +## 1. Localização da Lambda + +| Item | Valor | +|------|-------| +| Nome referenciado | `workorder-ingest` | +| Referência código | [TODO.md](../../../TODO.md) L50–51 | +| Fluxo atual | Lambda → DynamoDB (`WorkOrders`) → `POST api/Sync/WorkOrders` | +| Fluxo alvo | Lambda → `POST api/workorders/ingest` (API key) | + +**Ação pendente ops:** localizar repositório/infra AWS (SAM, Terraform, console Lambda) e preencher owner na [phase-7-gates-signoff.md](phase-7-gates-signoff.md). + +--- + +## 2. Tabelas DynamoDB (SyncController) + +| Tabela | Endpoint sync | Uso | +|--------|---------------|-----| +| `WorkOrders` | `POST api/Sync/WorkOrders` | Upsert WO por `work_order_id` | +| `WorkOrderComments` | `POST api/Sync/Comments` | Comentários cliente | +| `VendorReplies` | `POST api/Sync/VendorReplies` | Respostas vendor | + +Cutover Fase 7 foca em **WorkOrders**; comments/replies permanecem no Sync até migração separada. + +--- + +## 3. Mapeamento DynamoDB → API ingest + +Campos lidos em [SyncController.cs](../../../Api.SeaHavenIndustries/Controllers/SyncController.cs) e espelhados em `WorkOrderIngestPayload`: + +| Campo Dynamo | Campo API ingest | Campo SQL | Merge policy (update) | +|--------------|------------------|-----------|------------------------| +| `work_order_id` | `externalWorkOrderId` | `ExternalWorkOrderId` | Chave idempotente | +| `description` | `description` | `Description`, `WorkerOrderTitle` | Sim se não locked | +| `wo_status` | `woStatus` | `Status` (mapeado) | Sim se não locked | +| `severity` | `severity` | `Priority`, `Severity` | Sim se não locked | +| `customer` | `customer` | `Customer` | Direto na criação | +| `site_code` | `siteCode` | `SiteCode` | Sim se não locked | +| `building` | `building` | `Building` | Sim se não locked | +| `address` | `address` | `Locations` (resolve/create) | LocationId | +| `due_date` | `dueDate` | `DueDate` | Sim se não locked | +| `date_reported` | `dateReported` | `DateReported` | Direto | +| `scheduled_start` | `scheduledStart` | `ScheduledStart` | Direto | +| `source_email_s3_key` | `sourceEmailS3Key` | `SourceEmailS3Key` | Direto | +| `created_at` | `createdAt` | `CreatedDate` | Criação only | + +### Mapeamento `wo_status` → SQL `Status` + +| Dynamo | SQL | +|--------|-----| +| `new`, `assigned`, `unknown` | `Open` | +| `in_progress` | `In Progress` | +| `on_hold` | `On Hold` | +| `completed` | `Done` | +| `cancelled` | `Cancelled` | + +### Mapeamento `severity` → `Priority` + +`Sev {severity}` (ex.: `3` → `Sev 3`) + +--- + +## 4. Auth serviço-a-serviço + +| Header | Config | +|--------|--------| +| `X-Ingest-Key` | `WorkOrderIngest:ApiKey` (env `WorkOrderIngest__ApiKey`) | + +Lambda deve enviar o header em cada `POST /api/workorders/ingest`. Não usar JWT de usuário dispatcher. + +--- + +## 5. Estratégia dual-run + +1. **Semana 1–2:** Lambda grava DynamoDB **e** chama API ingest +2. **Validação:** `GET /api/workorders/ops/health` + script amostra `ExternalWorkOrderId` +3. **Cutover:** Lambda só API; `Sync:Enabled=false` +4. **Retire:** backup DynamoDB → desativar tabelas + +--- + +## 6. Critérios de cutover + +- [ ] `POST /api/workorders/board` estável (criação SHOC) +- [ ] Auth ingest testada em staging +- [ ] Zero drift em amostra de 100 WOs +- [ ] Owner Lambda assinou runbook diff --git a/docs/work-orders/phase-7/phase-7-gates-signoff.md b/docs/work-orders/phase-7/phase-7-gates-signoff.md new file mode 100644 index 0000000..16021e0 --- /dev/null +++ b/docs/work-orders/phase-7/phase-7-gates-signoff.md @@ -0,0 +1,55 @@ +# Phase 7 — Gates Sign-Off Checklist + +**Programa:** Work Orders Board +**Fase:** 7 — Rollout e Produção + +--- + +## Gate de entrada (GO Fase 7) + +Bloqueia piloto em produção até todos os itens estarem verdes. + +| # | Gate | Artefato / evidência | Status | +|---|------|----------------------|--------| +| 1 | Fases 0–6 validadas em staging | `docs/work-orders/phase-0` … `phase-6` READMEs | [ ] | +| 2 | Sign-off CTO/PO Fase 0 | [phase-0-gates-signoff.md](../phase-0/phase-0-gates-signoff.md) | [ ] | +| 3 | Dry-run migrations Phase0–Phase6 | [migration-dry-run-report.md](../phase-0/migration-dry-run-report.md) | [ ] | +| 4 | Smoke multi-consumidor | [smoke-checklist.md](../phase-0/smoke-checklist.md) | [ ] | +| 5 | Vendor Portal regression | Checklist dispatch checklist/signoff | [ ] | +| 6 | SHOC E2E staging | Contratos phase-1 … phase-6 integrados | [ ] | +| 7 | Tier volume confirmado | [volume-discovery-report.md](../phase-0/volume-discovery-report.md) | [ ] | + +### Checklist PO ([consumer-inventory.md](../phase-0/consumer-inventory.md)) + +- [ ] App mobile externo confirmado (sim/não/N/A) +- [ ] Data sunset Blazor WO definida: _______________ +- [ ] `FrontendBaseUrl` produção → SHOC configurado +- [ ] Owner Lambda cutover nomeado: _______________ + +**Assinatura GO Fase 7** + +| Papel | Nome | Data | +|-------|------|------| +| CTO | | | +| PO | | | + +--- + +## Gates de saída (DONE Fase 7) + +| Gate | Critério | +|------|----------| +| Piloto | 1–2 dispatchers SHOC em prod; UAT assinado | +| Rollout | 100% dispatchers no board SHOC | +| Estabilidade | Zero P1 por 2 semanas consecutivas | +| Blazor | Módulo WO sunset (`BlazorWorkOrderSunset:Enabled`) | +| Lambda | Ingest direto `POST /api/workorders/ingest`; Dynamo/Sync desligados | +| Monitoramento | `GET /api/workorders/ops/health` + alertas documentados | +| Legado | Headers `Sunset`/`Deprecation` nos endpoints legado | + +**Assinatura DONE Fase 7** + +| Papel | Nome | Data | +|-------|------|------| +| CTO | | | +| PO | | | diff --git a/scripts/backfill-docstatus.ps1 b/scripts/backfill-docstatus.ps1 new file mode 100644 index 0000000..4bcc0e0 --- /dev/null +++ b/scripts/backfill-docstatus.ps1 @@ -0,0 +1,23 @@ +# Backfill DocStatus from legacy SignOffAttachment (idempotent) +# Run against target SQL Server after Phase6_CompletionSlideOver migration. + +param( + [string]$ConnectionString = $env:ConnectionStrings__DefaultConnection +) + +if ([string]::IsNullOrWhiteSpace($ConnectionString)) { + Write-Error "Set ConnectionStrings__DefaultConnection or pass -ConnectionString" + exit 1 +} + +$sql = @" +UPDATE workOrders +SET DocStatus = 1 +WHERE DocStatus IS NULL + AND SignOffAttachment IS NOT NULL + AND LTRIM(RTRIM(SignOffAttachment)) <> ''; +"@ + +Write-Host "Backfilling DocStatus=Yes where SignOffAttachment exists..." +Invoke-Sqlcmd -ConnectionString $ConnectionString -Query $sql +Write-Host "Done." diff --git a/scripts/load-test/README.md b/scripts/load-test/README.md new file mode 100644 index 0000000..c068043 --- /dev/null +++ b/scripts/load-test/README.md @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 0000000..ff69d67 --- /dev/null +++ b/scripts/load-test/work-order-search.k6.js @@ -0,0 +1,60 @@ +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); +} diff --git a/scripts/seed-work-orders-search.ps1 b/scripts/seed-work-orders-search.ps1 new file mode 100644 index 0000000..a08b18a --- /dev/null +++ b/scripts/seed-work-orders-search.ps1 @@ -0,0 +1,56 @@ +# Seed sintético de Work Orders para load test Tier S +# +# Uso: +# $env:SQL_CONNECTION_STRING = "Server=localhost;Database=SeaHaven;..." +# .\scripts\seed-work-orders-search.ps1 -Count 15000 + +param( + [int]$Count = 5000, + [string]$ConnectionString = $env:SQL_CONNECTION_STRING +) + +if ([string]::IsNullOrWhiteSpace($ConnectionString)) { + Write-Error "Defina SQL_CONNECTION_STRING ou passe -ConnectionString." + exit 1 +} + +$sites = @("BK5", "BK6", "BK7", "BK8", "BK9", "LA1", "LA2", "NY1") +$trades = @("HVAC PM", "Plumbing PM", "Electrical PM", "Roof Inspection", "Fire Safety") +$baseDate = Get-Date "2026-01-06" + +Write-Host "Inserindo $Count WOs sintéticas..." -ForegroundColor Cyan + +$batchSize = 500 +$inserted = 0 + +while ($inserted -lt $Count) { + $batch = [Math]::Min($batchSize, $Count - $inserted) + $values = @() + + for ($i = 0; $i -lt $batch; $i++) { + $id = $inserted + $i + 1 + $site = $sites[$id % $sites.Length] + $trade = $trades[$id % $trades.Length] + $weekOffset = $id % 52 + $scheduled = $baseDate.AddDays($weekOffset * 7 + ($id % 5)) + $woNumber = ("1{0:D10}" -f $id) + + $values += "('$woNumber', '$site', '$trade', '$($scheduled.ToString('yyyy-MM-dd'))', 2, 0)" + } + + $sql = @" +INSERT INTO workOrders (InternalWONumber, SiteCode, Trade, ScheduledDate, LifecycleStatus, istemplate, RescheduleCount, CarriedOver, CreatedDate) +VALUES $($values -join ','); +"@ + + sqlcmd -Q $sql -b + if ($LASTEXITCODE -ne 0) { + Write-Error "Falha no batch $inserted" + exit 1 + } + + $inserted += $batch + Write-Host " $inserted / $Count" +} + +Write-Host "Seed concluído." -ForegroundColor Green diff --git a/scripts/spike-status-mapping.sql b/scripts/spike-status-mapping.sql new file mode 100644 index 0000000..14e371b --- /dev/null +++ b/scripts/spike-status-mapping.sql @@ -0,0 +1,90 @@ +-- Spike Status Mapping (G2): seed legado + enum compacto + queries de análise +-- Executar APÓS dotnet ef database update +-- Uso: sqlcmd -S localhost,1433 -U sa -P "SeaHaven_Dev_2026!" -d SeahavenIndustries -C -i scripts/spike-status-mapping.sql + +SET NOCOUNT ON; + +-- --------------------------------------------------------------------------- +-- 1. Seed status spike (prefixo SPIKE-STATUS-) +-- Cobre formatos legado (com espaço) e canônico API (enum ToString) +-- --------------------------------------------------------------------------- +IF NOT EXISTS (SELECT 1 FROM workOrders WHERE InternalWONumber LIKE 'SPIKE-STATUS-%') +BEGIN + DECLARE @weekStart date = CAST(GETDATE() AS date); + + INSERT INTO workOrders ( + InternalWONumber, WorkerOrderTitle, SiteCode, Status, Priority, + ScheduledDate, DueDate, Trade, Problem, AssignTo, istemplate, CreatedDate + ) + VALUES + -- Legado Blazor / Sync (com espaço) + ('SPIKE-STATUS-001', 'Status spike - Open legado', 'SS1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()), + ('SPIKE-STATUS-002', 'Status spike - In Progress legado','SS2', 'In Progress', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', NULL, 0, GETDATE()), + ('SPIKE-STATUS-003', 'Status spike - On Hold legado', 'SS3', 'On Hold', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 10, @weekStart), 'Electrical', 'Hold', NULL, 0, GETDATE()), + ('SPIKE-STATUS-004', 'Status spike - Done legado', 'SS4', 'Done', 'Low', DATEADD(day, 3, @weekStart), DATEADD(day, 4, @weekStart), 'General', 'Complete', NULL, 0, GETDATE()), + ('SPIKE-STATUS-005', 'Status spike - Cancelled legado', 'SS5', 'Cancelled', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'HVAC', 'Cancelled', NULL, 0, GETDATE()), + ('SPIKE-STATUS-006', 'Status spike - UnAssigned legado','SS6', 'UnAssigned', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 7, @weekStart), 'Plumbing', 'Unassigned', NULL, 0, GETDATE()), + -- Canônico API (enum ToString) + ('SPIKE-STATUS-007', 'Status spike - Open canônico', 'SS7', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()), + ('SPIKE-STATUS-008', 'Status spike - InProgress canônico','SS8', 'InProgress', 'High', DATEADD(day, 1, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', NULL, 0, GETDATE()), + ('SPIKE-STATUS-009', 'Status spike - OnHold canônico', 'SS9', 'OnHold', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 10, @weekStart), 'Electrical', 'Hold', NULL, 0, GETDATE()), + ('SPIKE-STATUS-010', 'Status spike - Completed canônico','SS10','Completed', 'Low', DATEADD(day, 3, @weekStart), DATEADD(day, 4, @weekStart), 'General', 'Complete', NULL, 0, GETDATE()), + ('SPIKE-STATUS-011', 'Status spike - Cancelled canônico','SS11','Cancelled', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'HVAC', 'Cancelled', NULL, 0, GETDATE()), + -- Open sem assignee (derivado Unassigned no board) + ('SPIKE-STATUS-012', 'Status spike - Open unassigned', 'SS12','Open', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 3, @weekStart), 'HVAC', 'Repair', NULL, 0, GETDATE()), + -- Past due candidato (scheduled no passado, status não terminal) + ('SPIKE-STATUS-013', 'Status spike - past due candidate','SS13','In Progress', 'High', DATEADD(day, -3, @weekStart), DATEADD(day, -1, @weekStart), 'HVAC', 'Overdue test', NULL, 0, GETDATE()); + + PRINT 'Seed status spike: 13 work orders inseridos (prefixo SPIKE-STATUS-).'; +END +ELSE + PRINT 'Seed status spike: já existente — pulando INSERT.'; +GO + +-- --------------------------------------------------------------------------- +-- 2. Distribuição Status (todos os WOs não-template) +-- --------------------------------------------------------------------------- +PRINT '--- Distribuição Status ---'; +SELECT Status, COUNT(*) AS Cnt +FROM workOrders +WHERE istemplate IS NULL OR istemplate = 0 +GROUP BY Status +ORDER BY Cnt DESC; + +-- --------------------------------------------------------------------------- +-- 3. Status x assignee (detectar Unassigned implícito) +-- --------------------------------------------------------------------------- +PRINT '--- Status x AssignTo (Unassigned implícito) ---'; +SELECT + Status, + CASE WHEN AssignTo IS NULL OR LTRIM(RTRIM(AssignTo)) = '' THEN 1 ELSE 0 END AS IsUnassigned, + COUNT(*) AS Cnt +FROM workOrders +WHERE istemplate IS NULL OR istemplate = 0 +GROUP BY Status, + CASE WHEN AssignTo IS NULL OR LTRIM(RTRIM(AssignTo)) = '' THEN 1 ELSE 0 END +ORDER BY Status, IsUnassigned; + +-- --------------------------------------------------------------------------- +-- 4. Órfãos — valores não mapeáveis pelo mapper G2 (ajustar lista conforme spike) +-- --------------------------------------------------------------------------- +PRINT '--- Status órfãos (fora do vocabulário conhecido) ---'; +SELECT Status, COUNT(*) AS Cnt +FROM workOrders +WHERE (istemplate IS NULL OR istemplate = 0) + AND Status IS NOT NULL + AND LTRIM(RTRIM(Status)) NOT IN ( + 'Open', 'In Progress', 'On Hold', 'Done', 'Cancelled', 'UnAssigned', 'Unassigned', + 'InProgress', 'OnHold', 'Completed', + 'new', 'assigned', 'in_progress', 'on_hold', 'completed', 'cancelled', 'unknown' + ) +GROUP BY Status +ORDER BY Cnt DESC; + +-- --------------------------------------------------------------------------- +-- 5. Template query PRODUÇÃO (rodar manualmente em prod/staging) +-- --------------------------------------------------------------------------- +PRINT '--- Template query PRODUÇÃO ---'; +-- SELECT Status, COUNT(*) AS Cnt FROM workOrders WHERE istemplate IS NULL OR istemplate = 0 GROUP BY Status ORDER BY Cnt DESC; + +GO diff --git a/scripts/spike-work-order-type.sql b/scripts/spike-work-order-type.sql new file mode 100644 index 0000000..0c18465 --- /dev/null +++ b/scripts/spike-work-order-type.sql @@ -0,0 +1,96 @@ +-- Spike WorkOrderType: coluna legada + seed local + queries de análise +-- Executar APÓS dotnet ef database update +-- Uso: sqlcmd -S localhost,1433 -U sa -P "SeaHaven_Dev_2026!" -d SeahavenIndustries -C -i scripts/spike-work-order-type.sql + +SET NOCOUNT ON; + +-- --------------------------------------------------------------------------- +-- 1. Colunas legadas (existem em prod/db.txt, ausentes nas migrations EF) +-- --------------------------------------------------------------------------- +IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL + ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL; + +IF COL_LENGTH('workOrders', 'AvettaTask') IS NULL + ALTER TABLE workOrders ADD AvettaTask varchar(max) NULL; + +IF COL_LENGTH('workOrders', 'AssignDate') IS NULL + ALTER TABLE workOrders ADD AssignDate date NULL; + +GO + +-- --------------------------------------------------------------------------- +-- 2. Seed spike (somente se ainda não existir prefixo SPIKE-WO-) +-- --------------------------------------------------------------------------- +IF NOT EXISTS (SELECT 1 FROM workOrders WHERE InternalWONumber LIKE 'SPIKE-WO-%') +BEGIN + DECLARE @weekStart date = CAST(GETDATE() AS date); + DECLARE @weekEnd date = DATEADD(day, 6, @weekStart); + + INSERT INTO workOrders ( + InternalWONumber, WorkerOrderTitle, SiteCode, Status, Priority, + ScheduledDate, DueDate, Trade, Problem, WorkOrderType, istemplate, CreatedDate + ) + VALUES + -- NULL WorkOrderType (5) + ('SPIKE-WO-001', 'Spike seed - null type A', 'BK5', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 3, @weekStart), 'HVAC', 'Cooling', NULL, 0, GETDATE()), + ('SPIKE-WO-002', 'Spike seed - null type B', 'BK6', 'Open', 'Low', DATEADD(day, 1, @weekStart), DATEADD(day, 5, @weekStart), 'Plumbing', 'Leak', NULL, 0, GETDATE()), + ('SPIKE-WO-003', 'Spike seed - null type C', 'BK7', 'InProgress', 'Medium', DATEADD(day, 2, @weekStart), DATEADD(day, 4, @weekStart), 'Electrical', 'Panel', NULL, 0, GETDATE()), + ('SPIKE-WO-004', 'Spike seed - null type D', 'BK8', 'Open', 'High', DATEADD(day, 3, @weekStart), DATEADD(day, 7, @weekStart), 'HVAC', 'Filter', NULL, 0, GETDATE()), + ('SPIKE-WO-005', 'Spike seed - null type E', 'BK9', 'OnHold', 'Low', DATEADD(day, 4, @weekStart), DATEADD(day, 10, @weekStart), 'General', 'Inspection', NULL, 0, GETDATE()), + -- PM (5) + ('SPIKE-WO-006', 'Spike seed - PM A', 'PM1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 14, @weekStart), 'HVAC', 'Preventive Maintenance', 'PM', 0, GETDATE()), + ('SPIKE-WO-007', 'Spike seed - PM B', 'PM2', 'Open', 'Medium', DATEADD(day, 1, @weekStart), DATEADD(day, 14, @weekStart), 'Plumbing', 'PM Schedule', 'PM', 0, GETDATE()), + ('SPIKE-WO-008', 'Spike seed - PM C', 'PM3', 'InProgress', 'Low', DATEADD(day, 2, @weekStart), DATEADD(day, 21, @weekStart), 'Electrical', 'Quarterly PM', 'PM', 0, GETDATE()), + ('SPIKE-WO-009', 'Spike seed - PM D', 'PM4', 'Open', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 30, @weekStart), 'HVAC', 'Annual PM', 'PM', 0, GETDATE()), + ('SPIKE-WO-010', 'Spike seed - PM E', 'PM5', 'Open', 'Low', DATEADD(day, 6, @weekStart), DATEADD(day, 7, @weekStart), 'General', 'PM', 'PM', 0, GETDATE()), + -- Standard (4) + ('SPIKE-WO-011', 'Spike seed - Standard A', 'ST1', 'Open', 'Medium', DATEADD(day, 0, @weekStart), DATEADD(day, 5, @weekStart), 'HVAC', 'Repair', 'Standard', 0, GETDATE()), + ('SPIKE-WO-012', 'Spike seed - Standard B', 'ST2', 'InProgress', 'High', DATEADD(day, 2, @weekStart), DATEADD(day, 6, @weekStart), 'Plumbing', 'Repair', 'Standard', 0, GETDATE()), + ('SPIKE-WO-013', 'Spike seed - Standard C', 'ST3', 'Open', 'Medium', DATEADD(day, 4, @weekStart), DATEADD(day, 8, @weekStart), 'Electrical', 'Repair', 'Standard', 0, GETDATE()), + ('SPIKE-WO-014', 'Spike seed - Standard D', 'ST4', 'Completed', 'Low', DATEADD(day, 1, @weekStart), DATEADD(day, 2, @weekStart), 'General', 'Repair', 'Standard', 0, GETDATE()), + -- Add-On (3) + ('SPIKE-WO-015', 'Spike seed - Add-On A', 'AO1', 'Open', 'Medium', DATEADD(day, 3, @weekStart), DATEADD(day, 9, @weekStart), 'HVAC', 'Add work', 'Add-On', 0, GETDATE()), + ('SPIKE-WO-016', 'Spike seed - Add-On B', 'AO2', 'Open', 'High', DATEADD(day, 4, @weekStart), DATEADD(day, 10, @weekStart), 'Plumbing', 'Add work', 'Add-On', 0, GETDATE()), + ('SPIKE-WO-017', 'Spike seed - Add-On C', 'AO3', 'InProgress', 'Medium', DATEADD(day, 5, @weekStart), DATEADD(day, 11, @weekStart), 'Electrical', 'Add work', 'Add-On', 0, GETDATE()), + -- Emergency + Corrective (3) + ('SPIKE-WO-018', 'Spike seed - Emergency', 'EM1', 'Open', 'Critical', DATEADD(day, 0, @weekStart), DATEADD(day, 1, @weekStart), 'HVAC', 'Emergency', 'Emergency', 0, GETDATE()), + ('SPIKE-WO-019', 'Spike seed - Corrective A', 'CR1', 'Open', 'High', DATEADD(day, 2, @weekStart), DATEADD(day, 4, @weekStart), 'Plumbing', 'Corrective', 'Corrective', 0, GETDATE()), + ('SPIKE-WO-020', 'Spike seed - Corrective B', 'CR2', 'Open', 'Medium', DATEADD(day, 6, @weekStart), DATEADD(day, 8, @weekStart), 'General', 'Corrective', 'Corrective', 0, GETDATE()); + + PRINT 'Seed spike: 20 work orders inseridos (prefixo SPIKE-WO-).'; +END +ELSE + PRINT 'Seed spike: já existente — pulando INSERT.'; +GO + +-- --------------------------------------------------------------------------- +-- 3. Queries de análise (entregável spike) +-- --------------------------------------------------------------------------- +PRINT '--- Distribuição WorkOrderType (seed + existentes) ---'; +SELECT WorkOrderType, COUNT(*) AS Cnt +FROM workOrders +WHERE istemplate IS NULL OR istemplate = 0 +GROUP BY WorkOrderType +ORDER BY Cnt DESC; + +PRINT '--- % populado ---'; +SELECT + COUNT(*) AS TotalRows, + SUM(CASE WHEN WorkOrderType IS NOT NULL AND LTRIM(RTRIM(WorkOrderType)) <> '' THEN 1 ELSE 0 END) AS Populated, + CAST(100.0 * SUM(CASE WHEN WorkOrderType IS NOT NULL AND LTRIM(RTRIM(WorkOrderType)) <> '' THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0) AS decimal(5,2)) AS PopulatedPct +FROM workOrders +WHERE istemplate IS NULL OR istemplate = 0; + +PRINT '--- Cruzamento Problem/Trade (hipótese derivação) ---'; +SELECT WorkOrderType, Problem, Trade, COUNT(*) AS Cnt +FROM workOrders +WHERE (istemplate IS NULL OR istemplate = 0) + AND (InternalWONumber LIKE 'SPIKE-WO-%' OR WorkOrderType IS NOT NULL) +GROUP BY WorkOrderType, Problem, Trade +ORDER BY WorkOrderType, Cnt DESC; + +PRINT '--- Template query PRODUÇÃO (rodar manualmente em prod/staging) ---'; +-- SELECT WorkOrderType, COUNT(*) FROM workOrders GROUP BY WorkOrderType; +-- SELECT COUNT(*) AS Total, SUM(CASE WHEN WorkOrderType IS NOT NULL THEN 1 ELSE 0 END) AS WithType FROM workOrders; + +GO diff --git a/scripts/volume-discovery.ps1 b/scripts/volume-discovery.ps1 new file mode 100644 index 0000000..90612a9 --- /dev/null +++ b/scripts/volume-discovery.ps1 @@ -0,0 +1,61 @@ +# Volume Discovery — Work Orders +# Executa queries do volume-discovery-report.md contra staging/prod read-only. +# +# Uso: +# $env:SQL_CONNECTION_STRING = "Server=...;Database=...;User Id=...;Password=...;TrustServerCertificate=True" +# .\scripts\volume-discovery.ps1 + +param( + [string]$ConnectionString = $env:SQL_CONNECTION_STRING +) + +function Get-SqlConnectionArgs([string]$cs) { + return @("-C", $cs) +} + +if ([string]::IsNullOrWhiteSpace($ConnectionString)) { + Write-Error "Defina SQL_CONNECTION_STRING ou passe -ConnectionString." + exit 1 +} + +$queries = @{ + TotalWorkOrders = @" +SELECT COUNT(*) AS TotalWorkOrders +FROM workOrders +WHERE IsDeleted IS NULL OR IsDeleted = 0; +"@ + P95ProxyMaxPerWeek = @" +WITH WeeklyCounts AS ( + SELECT DATEPART(iso_week, ScheduledDate) AS IsoWeek, + YEAR(ScheduledDate) AS IsoYear, + COUNT(*) AS Cnt + FROM workOrders + WHERE ScheduledDate IS NOT NULL + AND (IsDeleted IS NULL OR IsDeleted = 0) + GROUP BY DATEPART(iso_week, ScheduledDate), YEAR(ScheduledDate) +) +SELECT MAX(Cnt) AS P95ProxyMaxPerWeek, + AVG(Cnt * 1.0) AS AvgPerWeek +FROM WeeklyCounts; +"@ + UnscheduledCount = @" +SELECT COUNT(*) AS UnscheduledCount +FROM workOrders +WHERE ScheduledDate IS NULL + AND (IsDeleted IS NULL OR IsDeleted = 0) + AND Status NOT IN ('Completed', 'Complete', 'Cancelled', 'Canceled'); +"@ +} + +$connArgs = Get-SqlConnectionArgs $ConnectionString + +Write-Host "=== Volume Discovery ===" -ForegroundColor Cyan +Write-Host "" + +foreach ($name in $queries.Keys) { + Write-Host "--- $name ---" -ForegroundColor Yellow + sqlcmd @connArgs -Q $queries[$name] -W + Write-Host "" +} + +Write-Host "Preencha docs/work-orders/phase-0/volume-discovery-report.md e docs/work-orders/phase-4/search-tier-decision.md com os resultados."