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