feat(work-orders): add Phase 3 board create and soft cancel API

Expose POST /api/workorders/board and POST /api/workorders/{id}/cancel for SHOC wizard/inline creation and soft cancel, with field locks, WO# normalization, and Admin-only hard delete.
This commit is contained in:
Arthur Bassi 2026-07-08 10:17:31 -03:00
parent 91122d753d
commit e5bda0714a
11 changed files with 980 additions and 1 deletions

View file

@ -45,6 +45,8 @@ namespace Api.SeaHavenIndustries.Controllers
private readonly IConfiguration _config;
private readonly IWorkOrderBoardService _workOrderBoardService;
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
private readonly IWorkOrderBoardCreateService _boardCreateService;
private readonly IWorkOrderBoardCancelService _boardCancelService;
public WorkOrderController(
UserManager<ApplicationUser> userManager,
@ -63,7 +65,9 @@ namespace Api.SeaHavenIndustries.Controllers
Helper.VendorPortalTokenService vendorTokens,
IConfiguration config,
IWorkOrderBoardService workOrderBoardService,
IWorkOrderBoardUpdateService boardUpdateService)
IWorkOrderBoardUpdateService boardUpdateService,
IWorkOrderBoardCreateService boardCreateService,
IWorkOrderBoardCancelService boardCancelService)
{
_userManager = userManager;
_workOrderService = workOrderService;
@ -82,6 +86,8 @@ namespace Api.SeaHavenIndustries.Controllers
_config = config;
_workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
_boardCreateService = boardCreateService;
_boardCancelService = boardCancelService;
}
[HttpGet("board")]
@ -145,6 +151,57 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPost("board")]
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCreateService.CreateAsync(request, actorId);
return Ok(row);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber")
{
return Conflict(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
[HttpPost("{id:int}/cancel")]
public async Task<IActionResult> CancelBoardWorkOrder(int id)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCancelService.CancelAsync(id, actorId);
return Ok(row);
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
[HttpPost]
[Route("AddWorkorder")]
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
@ -364,6 +421,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
[Authorize(Roles = "Admin")]
[HttpDelete]
[Route("DeleteWorkorder")]
public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model)

View file

@ -0,0 +1,26 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.DTOs
{
/// <summary>Payload for creating a board work order (SHOC create wizard).</summary>
public class WorkOrderBoardCreateRequestDto
{
public string? WoNumber { get; set; }
public WorkOrderType WorkOrderType { get; set; }
public string? SiteCode { get; set; }
public string? Description { get; set; }
public string? Trade { get; set; }
public int? LocationId { get; set; }
public DateTime? DueDate { get; set; }
public string? AssignTo { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateOnly? TargetWeek { get; set; }
public bool? ScheduleWeekOnly { get; set; }
public DocStatus? DocStatus { get; set; }
public int? VendorId { get; set; }
public DateTime? ApptDate { get; set; }
public string? ApptTime { get; set; }
public int? PocContactId { get; set; }
public string? PocNotes { get; set; }
}
}

View file

@ -0,0 +1,132 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderBoardFieldMutations
{
public static List<BoardFieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField = "AssignTo")
{
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
var old = workOrder.AssignTo;
var changes = new List<BoardFieldChange>();
if (old != normalized)
{
workOrder.AssignTo = normalized;
changes.Add(BoardFieldChange.AssignmentChanged(auditField, old, normalized));
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
public static List<BoardFieldChange> ApplyScheduledDate(
WorkOrder workOrder,
DateTime? parsed,
string auditField = "ScheduledDate",
bool incrementRescheduleOnChange = true)
{
var previousDate = workOrder.ScheduledDate;
var changes = new List<BoardFieldChange>();
var oldFormatted = FormatDate(previousDate);
var newFormatted = FormatDate(parsed);
if (oldFormatted == newFormatted)
return changes;
workOrder.ScheduledDate = parsed;
changes.Add(BoardFieldChange.Changed(auditField, oldFormatted, newFormatted));
if (!workOrder.OriginalDate.HasValue && parsed.HasValue)
{
workOrder.OriginalDate = DateOnly.FromDateTime(parsed.Value);
var originalWeek = WorkOrderBoardMutationRules.ComputeOriginalWeek(parsed);
if (originalWeek.HasValue)
workOrder.OriginalWeek = originalWeek;
}
if (incrementRescheduleOnChange && WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed))
{
var oldCount = workOrder.RescheduleCount.ToString();
workOrder.RescheduleCount++;
changes.Add(BoardFieldChange.Changed(
WorkOrderBoardFieldNames.RescheduleCount,
oldCount,
workOrder.RescheduleCount.ToString()));
}
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
return changes;
}
public static List<BoardFieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<BoardFieldChange>();
if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo))
return changes;
var old = workOrder.LifecycleStatus?.ToString();
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled);
changes.Add(BoardFieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString()));
return changes;
}
public static BoardFieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value)
{
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
throw new WorkOrderBoardValidationException("InvalidValue", error ?? "Invalid apptTime.");
var apptDate = dispatch.ScheduledDate ?? workOrder.ScheduledDate;
var oldStart = FormatDateTime(workOrder.ScheduledStart);
var oldEnd = FormatDateTime(workOrder.ScheduledEnd);
workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start);
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end);
var newStart = FormatDateTime(workOrder.ScheduledStart);
var newEnd = FormatDateTime(workOrder.ScheduledEnd);
var oldCombined = FormatApptRange(oldStart, oldEnd);
var newCombined = FormatApptRange(newStart, newEnd);
if (oldCombined == newCombined)
return BoardFieldChange.Unchanged("ApptTime", dispatch.Id);
return BoardFieldChange.Changed("ApptTime", oldCombined, newCombined, dispatch.Id);
}
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
{
workOrder.LifecycleStatus = LifecycleStatus.Incomplete;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Incomplete);
}
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
private static string? FormatApptRange(string? start, string? end)
=> start == null && end == null ? null : $"{start}|{end}";
public sealed record BoardFieldChange(
string FieldName,
string? OldValue,
string? NewValue,
AuditActionType Action,
int? DispatchId,
bool HasChanged)
{
public static BoardFieldChange Unchanged(string fieldName, int? dispatchId = null)
=> new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false);
public static BoardFieldChange Changed(string fieldName, string? oldValue, string? newValue, int? dispatchId = null)
=> new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true);
public static BoardFieldChange StatusChanged(string fieldName, string? oldValue, string? newValue)
=> new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true);
public static BoardFieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue)
=> new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true);
}
}
}

View file

@ -0,0 +1,57 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderBoardCancelService : IWorkOrderBoardCancelService
{
private readonly ApplicationDbContext _context;
private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderAuditService _auditService;
public WorkOrderBoardCancelService(
ApplicationDbContext context,
IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService)
{
_context = context;
_boardService = boardService;
_auditService = auditService;
}
public async Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId)
{
var workOrder = await _context.workOrders
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
if (workOrder == null)
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
if (workOrder.LifecycleStatus == LifecycleStatus.Canceled)
{
var existing = await _boardService.GetBoardRowAsync(workOrderId);
return existing ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
if (workOrder.LifecycleStatus == LifecycleStatus.Completed)
throw new WorkOrderBoardValidationException("CancelNotAllowed", "Work order cannot be canceled in its current status.");
var oldStatus = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status ?? "";
workOrder.LifecycleStatus = LifecycleStatus.Canceled;
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Canceled);
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
_auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _context.SaveChangesAsync();
var row = await _boardService.GetBoardRowAsync(workOrderId);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
}
}

View file

@ -0,0 +1,210 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderBoardCreateService : IWorkOrderBoardCreateService
{
private readonly ApplicationDbContext _context;
private readonly IWorkOrderBoardDataService _boardDataService;
private readonly IWorkOrderBoardService _boardService;
private readonly IWorkOrderAuditService _auditService;
private readonly IWorkOrderBoardCreateValidation _validator;
public WorkOrderBoardCreateService(
ApplicationDbContext context,
IWorkOrderBoardDataService boardDataService,
IWorkOrderBoardService boardService,
IWorkOrderAuditService auditService,
IWorkOrderBoardCreateValidation validator)
{
_context = context;
_boardDataService = boardDataService;
_boardService = boardService;
_auditService = auditService;
_validator = validator;
}
public async Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId)
{
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors);
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
var siteCode = request.SiteCode!.Trim();
var workOrder = new WorkOrder
{
InternalWONumber = woNumber,
WorkerOrderNumber = woNumber,
WorkOrderType = request.WorkOrderType,
SiteCode = siteCode,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(),
LocationId = request.LocationId,
DueDate = request.DueDate?.Date,
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
TargetWeek = request.TargetWeek,
ScheduleWeekOnly = request.ScheduleWeekOnly,
DocStatus = request.DocStatus,
createdby = actorId,
CreatedDate = DateTime.UtcNow,
istemplate = false
};
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
{
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("InternalWONumber", "", woNumber),
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("WorkOrderType", "", request.WorkOrderType.ToString()),
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("SiteCode", "", siteCode),
WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LifecycleStatus", "", LifecycleStatus.Incomplete.ToString())
};
if (workOrder.Description != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
if (workOrder.Trade != null)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
if (workOrder.LocationId.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
if (workOrder.DueDate.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DueDate", "", workOrder.DueDate.Value.ToString("yyyy-MM-dd")));
if (workOrder.TargetWeek.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TargetWeek", "", workOrder.TargetWeek.Value.ToString("yyyy-MM-dd")));
if (workOrder.ScheduleWeekOnly.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ScheduleWeekOnly", "", workOrder.ScheduleWeekOnly.Value.ToString()));
if (workOrder.DocStatus.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("DocStatus", "", workOrder.DocStatus.Value.ToString()));
if (!string.IsNullOrWhiteSpace(request.AssignTo))
changes.AddRange(WorkOrderBoardFieldMutations.ApplyAssignTo(workOrder, request.AssignTo));
else if (!string.IsNullOrWhiteSpace(workOrder.AssignTo))
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.AssignmentChanged("AssignTo", "", workOrder.AssignTo));
if (request.ScheduledDate.HasValue)
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
Dispatch? dispatch = null;
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
{
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
dispatch = new Dispatch
{
VendorId = request.VendorId.Value,
Status = "Pending",
CreatedDate = DateTime.UtcNow,
ScheduledDate = request.ApptDate?.Date
};
_context.Dispatches.Add(dispatch);
workOrder.PrimaryDispatch = dispatch;
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorId", "", request.VendorId.Value.ToString()));
if (request.ApptDate.HasValue)
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ApptDate", "", request.ApptDate.Value.ToString("yyyy-MM-dd")));
if (!string.IsNullOrWhiteSpace(request.ApptTime))
{
var apptChange = WorkOrderBoardFieldMutations.ApplyApptTime(workOrder, dispatch, request.ApptTime);
if (apptChange.HasChanged)
changes.Add(apptChange);
}
}
_context.workOrders.Add(workOrder);
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
StageChanges(workOrder.Id, changes, actorId, dispatch);
await _context.SaveChangesAsync();
if (dispatch != null && workOrder.PrimaryDispatchId == null)
{
workOrder.PrimaryDispatchId = dispatch.Id;
dispatch.WorkOrderId = workOrder.Id;
await _context.SaveChangesAsync();
}
if (request.PocContactId.HasValue)
{
_context.WorkOrderContacts.Add(new WorkOrderContacts
{
WorkorderId = workOrder.Id,
ContactId = request.PocContactId,
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
});
await _context.SaveChangesAsync();
}
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
}
private async Task<string> ResolveWoNumberAsync(string? requested)
{
if (!string.IsNullOrWhiteSpace(requested))
{
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0))
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
return normalized;
}
for (var attempt = 0; attempt < 20; attempt++)
{
var candidate = await GenerateNextSequentialWoNumberAsync();
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
return candidate;
}
throw new WorkOrderBoardValidationException("WoNumberGenerationFailed", "Could not generate a unique WO number.");
}
private async Task<string> GenerateNextSequentialWoNumberAsync()
{
var maxId = await _context.workOrders.MaxAsync(w => (int?)w.Id) ?? 0;
var seed = Math.Max(maxId + 1, 1);
if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _))
normalized = seed.ToString().PadLeft(11, '0');
return normalized;
}
private void StageChanges(
int workOrderId,
IEnumerable<WorkOrderBoardFieldMutations.BoardFieldChange> changes,
string? actorId,
Dispatch? dispatch)
{
foreach (var change in changes.Where(c => c.HasChanged))
{
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
switch (change.Action)
{
case AuditActionType.StatusChanged:
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
break;
}
}
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,46 @@
using FluentValidation;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Validation
{
public interface IWorkOrderBoardCreateValidation : IValidator<WorkOrderBoardCreateRequestDto>
{
}
public class WorkOrderBoardCreateValidation : AbstractValidator<WorkOrderBoardCreateRequestDto>, IWorkOrderBoardCreateValidation
{
public WorkOrderBoardCreateValidation()
{
RuleFor(x => x.WorkOrderType)
.IsInEnum().WithMessage("workOrderType is required and must be a valid value.");
RuleFor(x => x.SiteCode)
.NotEmpty().WithMessage("siteCode is required.")
.MaximumLength(32);
RuleFor(x => x.TargetWeek)
.NotNull().WithMessage("targetWeek is required when scheduleWeekOnly is true.")
.When(x => x.ScheduleWeekOnly == true);
RuleFor(x => x.Description)
.MaximumLength(2000)
.When(x => !string.IsNullOrEmpty(x.Description));
RuleFor(x => x.Trade)
.MaximumLength(128)
.When(x => !string.IsNullOrEmpty(x.Trade));
RuleFor(x => x.LocationId)
.GreaterThan(0)
.When(x => x.LocationId.HasValue);
RuleFor(x => x.VendorId)
.GreaterThan(0)
.When(x => x.VendorId.HasValue);
RuleFor(x => x.PocContactId)
.GreaterThan(0)
.When(x => x.PocContactId.HasValue);
}
}
}

View file

@ -0,0 +1,151 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardCancelServiceTests
{
private static (ApplicationDbContext Context, WorkOrderBoardCancelService Cancel, WorkOrderBoardUpdateService Update) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var cancel = new WorkOrderBoardCancelService(context, boardService, audit);
var update = new WorkOrderBoardUpdateService(context, boardData, audit);
return (context, cancel, update);
}
private static string ToVersion(WorkOrder workOrder)
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
[Fact]
public async Task Cancel_SetsLifecycleStatusCanceled()
{
var (context, cancel, _) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Status = "Incomplete",
SiteCode = "BK5",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var result = await cancel.CancelAsync(1, "actor-1");
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
Assert.Equal("Canceled", result.LifecycleStatusLabel);
}
[Fact]
public async Task Cancel_IsIdempotentWhenAlreadyCanceled()
{
var (context, cancel, _) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Canceled,
Status = "Canceled",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var before = await context.WorkOrderAuditLogs.CountAsync();
var result = await cancel.CancelAsync(1, "actor-1");
var after = await context.WorkOrderAuditLogs.CountAsync();
Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus);
Assert.Equal(before, after);
}
[Fact]
public async Task Cancel_BlocksWhenCompleted()
{
var (context, cancel, _) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Completed,
Status = "Completed"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
Assert.Equal("CancelNotAllowed", ex.Code);
}
[Fact]
public async Task Cancel_BlocksWhenClosedLabel()
{
var (context, cancel, _) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Completed,
Status = "Closed"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() => cancel.CancelAsync(1, "actor-1"));
Assert.Equal("CancelNotAllowed", ex.Code);
}
[Fact]
public async Task Cancel_WritesStatusChangedAudit()
{
var (context, cancel, _) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
Status = "Scheduled",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
await cancel.CancelAsync(1, "actor-1");
var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged");
Assert.Equal("Canceled", log.NewValue);
}
[Fact]
public async Task Patch_AfterCancel_IsBlocked()
{
var (context, cancel, update) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Incomplete,
Status = "Incomplete",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await cancel.CancelAsync(1, "actor-1");
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "NEW",
WorkOrderVersion = ToVersion(wo)
}, "actor-1"));
Assert.Equal("CanceledReadOnly", ex.Code);
}
}

View file

@ -0,0 +1,180 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardCreateServiceTests
{
private static (ApplicationDbContext Context, WorkOrderBoardCreateService Service) CreateSut()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var validator = new WorkOrderBoardCreateValidation();
var service = new WorkOrderBoardCreateService(context, boardData, boardService, audit, validator);
return (context, service);
}
[Fact]
public async Task Create_Minimal_ReturnsIncomplete()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
}, "actor-1");
Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus);
Assert.Equal("BK5", result.SiteCode);
Assert.Equal(WorkOrderType.PM, result.WorkOrderType);
Assert.False(string.IsNullOrWhiteSpace(result.WoNumber));
Assert.Equal(11, result.WoNumber!.Length);
}
[Fact]
public async Task Create_WithManualWoNumber_NormalizesTo11Digits()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WoNumber = "12345",
WorkOrderType = WorkOrderType.PO,
SiteCode = "DAL"
}, "actor-1");
Assert.Equal("00000012345", result.WoNumber);
}
[Fact]
public async Task Create_DuplicateWoNumber_Throws()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder { InternalWONumber = "00000099999", WorkerOrderNumber = "00000099999" });
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WoNumber = "99999",
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
}, "actor-1"));
Assert.Equal("DuplicateWoNumber", ex.Code);
}
[Fact]
public async Task Create_AutoSchedulesWhenDateAndAssignee()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
AssignTo = "dispatcher-1",
ScheduledDate = new DateTime(2026, 6, 25)
}, "actor-1");
Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate);
}
[Fact]
public async Task Create_WeekOnlyScheduling_SetsTargetWeek()
{
var (_, service) = CreateSut();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22)
}, "actor-1");
Assert.True(result.ScheduleWeekOnly);
Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek);
}
[Fact]
public async Task Create_WeekOnlyWithoutTargetWeek_ThrowsValidation()
{
var (_, service) = CreateSut();
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
ScheduleWeekOnly = true
}, "actor-1"));
}
[Fact]
public async Task Create_WithVendor_CreatesPrimaryDispatch()
{
var (context, service) = CreateSut();
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
await context.SaveChangesAsync();
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
VendorId = 5,
ApptDate = new DateTime(2026, 6, 26)
}, "actor-1");
Assert.Equal(5, result.VendorId);
Assert.NotNull(result.PrimaryDispatchId);
}
[Fact]
public async Task Create_CreatesFieldLocksForPopulatedFields()
{
var (context, service) = CreateSut();
await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
Description = "Test WO"
}, "actor-1");
var locks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Contains(locks, l => l.FieldName == "SiteCode");
Assert.Contains(locks, l => l.FieldName == "WorkOrderType");
Assert.Contains(locks, l => l.FieldName == "Description");
}
[Fact]
public async Task Create_WritesCreateAuditEvent()
{
var (context, service) = CreateSut();
await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
}, "actor-1");
var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(logs, l => l.Action == "Create");
Assert.Contains(logs, l => l.Action == "FieldChanged" && l.FieldName == "SiteCode");
}
}

View file

@ -0,0 +1,101 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardCreateSyncLockTests
{
[Fact]
public async Task AfterShocCreate_SyncRejectsLockedSiteCode()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var createService = new WorkOrderBoardCreateService(
context,
boardData,
boardService,
audit,
new WorkOrderBoardCreateValidation());
var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
}, "dispatcher-1");
var wo = await context.workOrders.SingleAsync();
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = fieldLocks,
Audit = audit
};
var applied = await policy.TryApplyAsync(syncContext, "SiteCode", "SYNC-SITE");
Assert.False(applied);
Assert.Equal("BK5", wo.SiteCode);
var rejected = await context.WorkOrderAuditLogs
.SingleAsync(l => l.Action == "SyncRejected" && l.FieldName == "SiteCode");
Assert.Equal("SYNC-SITE", rejected.NewValue);
}
[Fact]
public async Task AfterShocCreate_SyncCanUpdateUnlockedField_NotInShocSet()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new ApplicationDbContext(options);
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var createService = new WorkOrderBoardCreateService(
context,
boardData,
boardService,
audit,
new WorkOrderBoardCreateValidation());
var policy = new SyncFieldMergePolicy(fieldLocks);
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
}, "dispatcher-1");
var wo = await context.workOrders.SingleAsync();
wo.Customer = "Original";
await context.SaveChangesAsync();
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = fieldLocks,
Audit = audit
};
// Customer is not in SyncFieldMergePolicy apply switch — use Description which wasn't set on create
var applied = await policy.TryApplyAsync(syncContext, "Description", "From sync");
Assert.True(applied);
Assert.Equal("From sync", wo.Description);
}
}