mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #15 from Sea-Haven-Industries/feat/work-orders-phase-3
Feat/work orders Phase 3 board create and soft cancel
This commit is contained in:
commit
8192da7790
13 changed files with 1173 additions and 1 deletions
|
|
@ -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<ApplicationUser> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -177,6 +183,73 @@ 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) 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<IActionResult> 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<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
|
||||
|
|
@ -396,6 +469,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete]
|
||||
[Route("DeleteWorkorder")]
|
||||
public async Task<IActionResult> Deleteworkorder([FromBody] EditWorkorder_DTO model)
|
||||
|
|
|
|||
26
SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
Normal file
26
SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
136
SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs
Normal file
136
SeaHaven.Services/Helpers/WorkOrderBoardFieldMutations.cs
Normal file
|
|
@ -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<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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
227
SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
Normal file
227
SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs
Normal file
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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<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 async Task StageChangesAsync(
|
||||
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:
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardCancelService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> CancelAsync(int workOrderId, string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderBoardCreateService
|
||||
{
|
||||
Task<WorkOrderBoardRowDto> CreateAsync(WorkOrderBoardCreateRequestDto request, string? actorId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
|
@ -20,8 +21,10 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
|
||||
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
|
||||
<ProjectReference Include="..\SeaHaven.DataServices\SeaHaven.DataServices.csproj" />
|
||||
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
151
SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
Normal file
151
SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
150
SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs
Normal file
150
SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs
Normal file
|
|
@ -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<ApplicationDbContext>()
|
||||
.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<ApplicationDbContext>()
|
||||
.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<DbUpdateException>(() =>
|
||||
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<ApplicationDbContext> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
183
SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
Normal file
183
SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs
Normal file
|
|
@ -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<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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
101
SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs
Normal file
101
SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue