mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
330 lines
16 KiB
C#
330 lines
16 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using FluentValidation;
|
|
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 IWorkOrderBoardDataService _boardDataService;
|
|
private readonly IWorkOrderBoardMutationDataService _mutationData;
|
|
private readonly IWorkOrderBoardService _boardService;
|
|
private readonly IWorkOrderAuditService _auditService;
|
|
private readonly IWorkOrderBoardCreateValidation _validator;
|
|
private readonly IWorkOrderAccountResolver _accountResolver;
|
|
|
|
public WorkOrderBoardCreateService(
|
|
IWorkOrderBoardDataService boardDataService,
|
|
IWorkOrderBoardMutationDataService mutationData,
|
|
IWorkOrderBoardService boardService,
|
|
IWorkOrderAuditService auditService,
|
|
IWorkOrderBoardCreateValidation validator,
|
|
IWorkOrderAccountResolver accountResolver)
|
|
{
|
|
_boardDataService = boardDataService;
|
|
_mutationData = mutationData;
|
|
_boardService = boardService;
|
|
_auditService = auditService;
|
|
_validator = validator;
|
|
_accountResolver = accountResolver;
|
|
}
|
|
|
|
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
|
WorkOrderBoardCreateRequestDto request,
|
|
ClaimsPrincipal user,
|
|
string? actorId)
|
|
{
|
|
var validationResult = await _validator.ValidateAsync(request);
|
|
if (!validationResult.IsValid)
|
|
throw new ValidationException(validationResult.Errors);
|
|
|
|
var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync(
|
|
user,
|
|
request.Customer,
|
|
CancellationToken.None);
|
|
|
|
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
|
var siteCode = request.SiteCode!.Trim();
|
|
var primaryService = ResolvePrimaryService(request);
|
|
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
|
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
|
var pocName = TrimOrNull(request.PocName);
|
|
var pocPhone = TrimOrNull(request.PocPhone);
|
|
var pocNotes = TrimOrNull(request.PocNotes);
|
|
var additionalContactsJson = WorkOrderAdditionalContactsMapper.SerializeForStorage(request.AdditionalContacts);
|
|
var techPhone = TrimOrNull(request.TechPhone);
|
|
var vendorNotes = TrimOrNull(request.VendorNotes);
|
|
var customer = TrimOrNull(request.Customer);
|
|
var hasVendorDispatch = request.VendorId.HasValue
|
|
|| request.ApptDate.HasValue
|
|
|| !string.IsNullOrWhiteSpace(request.ApptTime);
|
|
|
|
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
|
{
|
|
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, CancellationToken.None))
|
|
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
|
}
|
|
|
|
var workOrder = new WorkOrder
|
|
{
|
|
InternalWONumber = woNumber,
|
|
WorkerOrderNumber = woNumber,
|
|
WorkOrderType = request.WorkOrderType,
|
|
SiteCode = siteCode,
|
|
Customer = customer,
|
|
AccountId = accountId,
|
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
|
Trade = primaryService,
|
|
ExtraServices = extraServicesJson,
|
|
ServiceNotes = serviceNotes,
|
|
PocName = pocName,
|
|
PocPhone = pocPhone,
|
|
PocNotes = pocNotes,
|
|
AdditionalContacts = additionalContactsJson,
|
|
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
|
|
};
|
|
|
|
if (!hasVendorDispatch)
|
|
{
|
|
workOrder.TechPhone = techPhone;
|
|
workOrder.VendorNotes = vendorNotes;
|
|
}
|
|
|
|
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
|
|
|
workOrder.IsAddOn = WorkOrderAddonCutoff.ComputeIsAddOnAtCreation(
|
|
workOrder.CreatedDate!.Value,
|
|
request.ScheduledDate,
|
|
request.TargetWeek,
|
|
request.IsAddOn);
|
|
|
|
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.ExtraServices != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
|
if (workOrder.ServiceNotes != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes));
|
|
if (workOrder.PocName != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName));
|
|
if (workOrder.PocPhone != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone));
|
|
if (workOrder.PocNotes != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes));
|
|
if (workOrder.AdditionalContacts != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("AdditionalContacts", "", workOrder.AdditionalContacts));
|
|
if (workOrder.TechPhone != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone));
|
|
if (workOrder.VendorNotes != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", workOrder.VendorNotes));
|
|
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 (hasVendorDispatch)
|
|
{
|
|
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,
|
|
TechPhone = techPhone,
|
|
VendorNotes = vendorNotes
|
|
};
|
|
_mutationData.TrackNewDispatch(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);
|
|
}
|
|
|
|
if (techPhone != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", techPhone));
|
|
if (vendorNotes != null)
|
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes));
|
|
}
|
|
|
|
// 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 _mutationData.ExecuteTransactionalAsync(async ct =>
|
|
{
|
|
_mutationData.TrackNewWorkOrder(workOrder);
|
|
var firstOutcome = await _mutationData.SaveAsync(ct);
|
|
|
|
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)
|
|
{
|
|
_mutationData.TrackWorkOrderContact(workOrder.Id, request.PocContactId.Value,
|
|
string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim());
|
|
}
|
|
|
|
await _mutationData.SaveAsync(ct);
|
|
}, CancellationToken.None);
|
|
|
|
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
|
|
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
|
}
|
|
|
|
private static string? ResolvePrimaryService(WorkOrderBoardCreateRequestDto request)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(request.PrimaryService))
|
|
return request.PrimaryService.Trim();
|
|
if (!string.IsNullOrWhiteSpace(request.Trade))
|
|
return request.Trade.Trim();
|
|
return null;
|
|
}
|
|
|
|
private static string? SerializeExtraServices(List<string>? extras)
|
|
{
|
|
if (extras == null || extras.Count == 0)
|
|
return null;
|
|
|
|
var normalized = new List<string>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var item in extras)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(item))
|
|
continue;
|
|
var trimmed = item.Trim();
|
|
if (trimmed.Length > 128)
|
|
throw new WorkOrderBoardValidationException(
|
|
"InvalidValue",
|
|
"Each extra service must be at most 128 characters.");
|
|
if (!seen.Add(trimmed))
|
|
continue;
|
|
normalized.Add(trimmed);
|
|
}
|
|
|
|
if (normalized.Count == 0)
|
|
return null;
|
|
|
|
var json = JsonSerializer.Serialize(normalized);
|
|
if (json.Length > 2000)
|
|
throw new WorkOrderBoardValidationException(
|
|
"InvalidValue",
|
|
"extraServices must serialize to at most 2000 characters.");
|
|
|
|
return json;
|
|
}
|
|
|
|
private static string? TrimOrNull(string? value)
|
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
|
|
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 _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|