diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 610d102..7f09d53 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -47,6 +47,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 userManager, @@ -65,7 +67,9 @@ namespace Api.SeaHavenIndustries.Controllers Helper.VendorPortalTokenService vendorTokens, IConfiguration config, IWorkOrderBoardService workOrderBoardService, - IWorkOrderBoardUpdateService boardUpdateService) + IWorkOrderBoardUpdateService boardUpdateService, + IWorkOrderBoardCreateService boardCreateService, + IWorkOrderBoardCancelService boardCancelService) { _userManager = userManager; _workOrderService = workOrderService; @@ -84,6 +88,8 @@ namespace Api.SeaHavenIndustries.Controllers _config = config; _workOrderBoardService = workOrderBoardService; _boardUpdateService = boardUpdateService; + _boardCreateService = boardCreateService; + _boardCancelService = boardCancelService; } /// @@ -177,6 +183,73 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpPost("board")] + public async Task 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) when (ex.Code == "NotFound") + { + return NotFound(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 CancelBoardWorkOrder(int id) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardCancelService.CancelAsync(id, actorId); + return Ok(row); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + } + [HttpPost] [Route("AddWorkorder")] public async Task Addworkorder([FromForm] Workorder_DTO model) @@ -396,6 +469,7 @@ namespace Api.SeaHavenIndustries.Controllers } + [Authorize(Roles = "Admin")] [HttpDelete] [Route("DeleteWorkorder")] public async Task Deleteworkorder([FromBody] EditWorkorder_DTO model) diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs new file mode 100644 index 0000000..d3ac591 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -0,0 +1,26 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + /// Payload for creating a board work order (SHOC create wizard). + 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; } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs new file mode 100644 index 0000000..2477cb3 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs @@ -0,0 +1,136 @@ +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, + workOrder.ScheduleWeekOnly)) + 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/Implementation/WorkOrderBoardCancelService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCancelService.cs new file mode 100644 index 0000000..78c86bd --- /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 == 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; + + await _auditService.StageStatusChangedAsync(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..e55f9e2 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -0,0 +1,227 @@ +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); + } + } + + // Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and + // audit/lock/contact rows. A transaction keeps the create atomic across both saves. + await using var transaction = _context.Database.IsRelational() + ? await _context.Database.BeginTransactionAsync() + : null; + + try + { + _context.workOrders.Add(workOrder); + await _context.SaveChangesAsync(); + + if (dispatch != null && workOrder.PrimaryDispatchId == null) + { + workOrder.PrimaryDispatchId = dispatch.Id; + dispatch.WorkOrderId = workOrder.Id; + } + + await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber); + await StageChangesAsync(workOrder.Id, changes, actorId, dispatch); + + 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(); + + if (transaction is not null) + await transaction.CommitAsync(); + } + catch + { + if (transaction is not null) + await transaction.RollbackAsync(); + throw; + } + + 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 async Task StageChangesAsync( + 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: + await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + case AuditActionType.AssignmentChanged: + await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId); + break; + default: + await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId); + break; + } + } + } + } +} 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/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs new file mode 100644 index 0000000..3b00bed --- /dev/null +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -0,0 +1,46 @@ +using FluentValidation; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Validation +{ + public interface IWorkOrderBoardCreateValidation : IValidator + { + } + + public class WorkOrderBoardCreateValidation : AbstractValidator, IWorkOrderBoardCreateValidation + { + public WorkOrderBoardCreateValidation() + { + RuleFor(x => x.WorkOrderType) + .IsInEnum().WithMessage("workOrderType is required and must be a valid value."); + + RuleFor(x => x.SiteCode) + .NotEmpty().WithMessage("siteCode is required.") + .MaximumLength(32); + + RuleFor(x => x.TargetWeek) + .NotNull().WithMessage("targetWeek is required when scheduleWeekOnly is true.") + .When(x => x.ScheduleWeekOnly == true); + + RuleFor(x => x.Description) + .MaximumLength(2000) + .When(x => !string.IsNullOrEmpty(x.Description)); + + RuleFor(x => x.Trade) + .MaximumLength(128) + .When(x => !string.IsNullOrEmpty(x.Trade)); + + RuleFor(x => x.LocationId) + .GreaterThan(0) + .When(x => x.LocationId.HasValue); + + RuleFor(x => x.VendorId) + .GreaterThan(0) + .When(x => x.VendorId.HasValue); + + RuleFor(x => x.PocContactId) + .GreaterThan(0) + .When(x => x.PocContactId.HasValue); + } + } +} diff --git a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj index 5eb8d5d..e5dff3a 100644 --- a/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj +++ b/SeaHavenIndustries.Tests/SeaHavenIndustries.Tests.csproj @@ -10,6 +10,7 @@ + @@ -20,8 +21,10 @@ + + diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs new file mode 100644 index 0000000..cfb7874 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -0,0 +1,151 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCancelServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardCancelService Cancel, WorkOrderBoardUpdateService Update) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var cancel = new WorkOrderBoardCancelService(context, boardService, audit); + var update = new WorkOrderBoardUpdateService(context, boardData, audit); + return (context, cancel, update); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + [Fact] + public async Task Cancel_SetsLifecycleStatusCanceled() + { + var (context, cancel, _) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + SiteCode = "BK5", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await cancel.CancelAsync(1, "actor-1"); + + Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); + Assert.Equal("Canceled", result.LifecycleStatusLabel); + } + + [Fact] + public async Task Cancel_IsIdempotentWhenAlreadyCanceled() + { + var (context, cancel, _) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + Status = "Canceled", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var before = await context.WorkOrderAuditLogs.CountAsync(); + var result = await cancel.CancelAsync(1, "actor-1"); + var after = await context.WorkOrderAuditLogs.CountAsync(); + + Assert.Equal(LifecycleStatus.Canceled, result.LifecycleStatus); + Assert.Equal(before, after); + } + + [Fact] + public async Task Cancel_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(() => 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(() => cancel.CancelAsync(1, "actor-1")); + Assert.Equal("CancelNotAllowed", ex.Code); + } + + [Fact] + public async Task Cancel_WritesStatusChangedAudit() + { + var (context, cancel, _) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + Status = "Scheduled", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + await cancel.CancelAsync(1, "actor-1"); + + var log = await context.WorkOrderAuditLogs.SingleAsync(l => l.Action == "StatusChanged"); + Assert.Equal("Canceled", log.NewValue); + } + + [Fact] + public async Task Patch_AfterCancel_IsBlocked() + { + var (context, cancel, update) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Status = "Incomplete", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await cancel.CancelAsync(1, "actor-1"); + + var ex = await Assert.ThrowsAsync(() => + update.PatchFieldAsync(1, new SeaHaven.Services.DTOs.WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.SiteCode, + Value = "NEW", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs new file mode 100644 index 0000000..0e85674 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -0,0 +1,150 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateRelationalTests +{ + [Fact] + public async Task Create_AuditAndFieldLocks_UseGeneratedWorkOrderId() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteBoardTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + const string actorId = "actor-1"; + context.Users.Add(new ApplicationUser + { + Id = actorId, + UserName = "actor-1", + NormalizedUserName = "ACTOR-1", + Email = "actor@test.local", + NormalizedEmail = "ACTOR@TEST.LOCAL" + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderBoardCreateService( + context, + boardData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Description = "Relational create" + }, actorId); + + Assert.True(result.Id > 0); + + var logs = await context.WorkOrderAuditLogs.AsNoTracking().ToListAsync(); + var locks = await context.WorkOrderFieldLocks.AsNoTracking().ToListAsync(); + + Assert.NotEmpty(logs); + Assert.NotEmpty(locks); + Assert.Contains(logs, l => l.Action == "Create"); + Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId)); + Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId)); + Assert.DoesNotContain(logs, l => l.WorkOrderId == 0); + Assert.DoesNotContain(locks, l => l.WorkOrderId == 0); + } + + [Fact] + public async Task Create_WhenContactPersistenceFails_RollsBackWorkOrder() + { + await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new SqliteBoardTestDbContext(options); + await context.Database.EnsureCreatedAsync(); + + const string actorId = "actor-1"; + context.Users.Add(new ApplicationUser + { + Id = actorId, + UserName = "actor-1", + NormalizedUserName = "ACTOR-1", + Email = "actor@test.local", + NormalizedEmail = "ACTOR@TEST.LOCAL" + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderBoardCreateService( + context, + boardData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Description = "Should roll back", + PocContactId = 999_999 + }, actorId)); + + // Fresh context on the same connection so we observe committed state only. + await using var verify = new SqliteBoardTestDbContext(options); + Assert.Empty(await verify.workOrders.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderFieldLocks.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.WorkOrderContacts.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync()); + } + + private sealed class SqliteBoardTestDbContext : ApplicationDbContext + { + public SqliteBoardTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // SQL Server filtered index syntax is invalid on SQLite. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes())) + { + if (index.GetFilter() != null) + index.SetFilter(null); + } + + // SQLite has no rowversion type; treat as plain nullable blobs. + foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) }) + { + var property = builder.Entity(entityType).Property("RowVersion").Metadata; + property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs new file mode 100644 index 0000000..6910f7a --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -0,0 +1,183 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardCreateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var validator = new WorkOrderBoardCreateValidation(); + var service = new WorkOrderBoardCreateService(context, boardData, boardService, audit, validator); + return (context, service); + } + + [Fact] + public async Task Create_Minimal_ReturnsIncomplete() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); + Assert.Equal("BK5", result.SiteCode); + Assert.Equal(WorkOrderType.PM, result.WorkOrderType); + Assert.False(string.IsNullOrWhiteSpace(result.WoNumber)); + Assert.Equal(11, result.WoNumber!.Length); + } + + [Fact] + public async Task Create_WithManualWoNumber_NormalizesTo11Digits() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WoNumber = "12345", + WorkOrderType = WorkOrderType.PO, + SiteCode = "DAL" + }, "actor-1"); + + Assert.Equal("00000012345", result.WoNumber); + } + + [Fact] + public async Task Create_DuplicateWoNumber_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder { InternalWONumber = "00000099999", WorkerOrderNumber = "00000099999" }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WoNumber = "99999", + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "actor-1")); + + Assert.Equal("DuplicateWoNumber", ex.Code); + } + + [Fact] + public async Task Create_AutoSchedulesWhenDateAndAssignee() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + AssignTo = "dispatcher-1", + ScheduledDate = new DateTime(2026, 6, 25) + }, "actor-1"); + + Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus); + Assert.Equal(new DateTime(2026, 6, 25), result.ScheduledDate); + } + + [Fact] + public async Task Create_WeekOnlyScheduling_SetsTargetWeek() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ScheduleWeekOnly = true, + TargetWeek = new DateOnly(2026, 6, 22) + }, "actor-1"); + + Assert.True(result.ScheduleWeekOnly); + Assert.Equal(new DateOnly(2026, 6, 22), result.TargetWeek); + } + + [Fact] + public async Task Create_WeekOnlyWithoutTargetWeek_ThrowsValidation() + { + var (_, service) = CreateSut(); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ScheduleWeekOnly = true + }, "actor-1")); + } + + [Fact] + public async Task Create_WithVendor_CreatesPrimaryDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" }); + await context.SaveChangesAsync(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + VendorId = 5, + ApptDate = new DateTime(2026, 6, 26) + }, "actor-1"); + + Assert.Equal(5, result.VendorId); + Assert.NotNull(result.PrimaryDispatchId); + } + + [Fact] + public async Task Create_CreatesFieldLocksForPopulatedFields() + { + var (context, service) = CreateSut(); + + var result = 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"); + Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId)); + } + + [Fact] + public async Task Create_WritesCreateAuditEvent() + { + var (context, service) = CreateSut(); + + var result = 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"); + Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId)); + Assert.True(result.Id > 0); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs new file mode 100644 index 0000000..6abde14 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -0,0 +1,101 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderBoardCreateSyncLockTests +{ + [Fact] + public async Task AfterShocCreate_SyncRejectsLockedSiteCode() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var createService = new WorkOrderBoardCreateService( + context, + boardData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + var policy = new SyncFieldMergePolicy(fieldLocks); + + await createService.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "dispatcher-1"); + + var wo = await context.workOrders.SingleAsync(); + var syncContext = new WorkOrderSyncContext + { + WorkOrder = wo, + FieldLocks = fieldLocks, + Audit = audit + }; + + var applied = await policy.TryApplyAsync(syncContext, "SiteCode", "SYNC-SITE"); + + Assert.False(applied); + Assert.Equal("BK5", wo.SiteCode); + + var rejected = await context.WorkOrderAuditLogs + .SingleAsync(l => l.Action == "SyncRejected" && l.FieldName == "SiteCode"); + Assert.Equal("SYNC-SITE", rejected.NewValue); + } + + [Fact] + public async Task AfterShocCreate_SyncCanUpdateUnlockedField_NotInShocSet() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var createService = new WorkOrderBoardCreateService( + context, + boardData, + boardService, + audit, + new WorkOrderBoardCreateValidation()); + var policy = new SyncFieldMergePolicy(fieldLocks); + + 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); + } +}