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; public WorkOrderBoardCreateService( IWorkOrderBoardDataService boardDataService, IWorkOrderBoardMutationDataService mutationData, IWorkOrderBoardService boardService, IWorkOrderAuditService auditService, IWorkOrderBoardCreateValidation validator) { _boardDataService = boardDataService; _mutationData = mutationData; _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 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 techPhone = TrimOrNull(request.TechPhone); var vendorNotes = TrimOrNull(request.VendorNotes); 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, Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Trade = primaryService, ExtraServices = extraServicesJson, ServiceNotes = serviceNotes, PocName = pocName, PocPhone = pocPhone, PocNotes = pocNotes, 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); 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.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.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); 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? extras) { if (extras == null || extras.Count == 0) return null; var normalized = new List(); var seen = new HashSet(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 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 _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 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; } } } } }