using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderBoardUpdateService : IWorkOrderBoardUpdateService { private readonly ApplicationDbContext _context; private readonly IWorkOrderBoardDataService _boardDataService; private readonly IWorkOrderAuditService _auditService; public WorkOrderBoardUpdateService( ApplicationDbContext context, IWorkOrderBoardDataService boardDataService, IWorkOrderAuditService auditService) { _context = context; _boardDataService = boardDataService; _auditService = auditService; } public async Task PatchFieldAsync( int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId) { if (string.IsNullOrWhiteSpace(request.Field)) throw new WorkOrderBoardValidationException("InvalidField", "Field is required."); var field = request.Field.Trim(); var canonicalField = WorkOrderBoardFieldNames.Canonicalize(field); if (canonicalField == null) throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable."); var workOrderVersion = ParseRowVersion(request.WorkOrderVersion, "workOrderVersion"); if (workOrderVersion == null) throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); var dispatchVersion = ParseRowVersion(request.DispatchVersion, "dispatchVersion"); 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 (!RowVersionsMatch(workOrder.RowVersion, workOrderVersion)) { var staleState = await LoadBoardRowAsync(workOrderId); throw new WorkOrderBoardConcurrencyException(staleState); } if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status."); var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.ScheduledDate, workOrder.LifecycleStatus); if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue)) throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Reschedule first."); _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion; Dispatch? dispatch = null; if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField)) { var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase) ? ParseVendorIdHint(request.Value) : null; var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; dispatch = await ResolveDispatchAsync(workOrder, request.PrimaryDispatchId, vendorIdHint); if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion)) { var staleState = await LoadBoardRowAsync(workOrderId); throw new WorkOrderBoardConcurrencyException(staleState); } if (dispatchVersion != null) _context.Entry(dispatch).Property(d => d.RowVersion).OriginalValue = dispatchVersion; else if (hadPrimaryDispatch && dispatch.RowVersion != null) throw new WorkOrderBoardValidationException("DispatchVersionRequired", "dispatchVersion is required for dispatch fields."); } var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField); var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField); changes = changes.Where(c => c.HasChanged).ToList(); if (changes.Count == 0 && !_context.ChangeTracker.HasChanges()) { var unchanged = await LoadBoardRowAsync(workOrderId); return unchanged ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } if (changes.Count == 0) { await _context.SaveChangesAsync(); var persisted = await LoadBoardRowAsync(workOrderId); return persisted ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } foreach (var change in changes) { switch (change.Action) { case AuditActionType.StatusChanged: _auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId); break; case AuditActionType.AssignmentChanged: _auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId); break; default: _auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId); break; } } try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { _context.ChangeTracker.Clear(); var currentState = await LoadBoardRowAsync(workOrderId); throw new WorkOrderBoardConcurrencyException(currentState); } var row = await LoadBoardRowAsync(workOrderId); return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); } private async Task> ApplyFieldMutationAsync( string field, WorkOrder workOrder, Dispatch? dispatch, string? value, string auditField) { return field switch { WorkOrderBoardFieldNames.WoNumber => new List { await ApplyWoNumber(workOrder, value, auditField) }, WorkOrderBoardFieldNames.WorkOrderType => new List { ApplyWorkOrderType(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField), WorkOrderBoardFieldNames.DueDate => new List { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) }, WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField), WorkOrderBoardFieldNames.TargetWeek => new List { ApplyTargetWeek(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ScheduleWeekOnly => new List { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) }, WorkOrderBoardFieldNames.VendorId => new List { ApplyVendorId(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; } private async Task ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate) { if (primaryDispatchId.HasValue) { var dispatch = await _context.Dispatches .FirstOrDefaultAsync(d => d.Id == primaryDispatchId.Value && d.WorkOrderId == workOrder.Id); if (dispatch == null) throw new WorkOrderBoardValidationException("InvalidDispatch", "Primary dispatch does not belong to this work order."); workOrder.PrimaryDispatchId = dispatch.Id; return dispatch; } if (workOrder.PrimaryDispatchId.HasValue) { var existing = await _context.Dispatches .FirstOrDefaultAsync(d => d.Id == workOrder.PrimaryDispatchId.Value); if (existing != null) return existing; } if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId."); var created = new Dispatch { WorkOrderId = workOrder.Id, VendorId = vendorIdForCreate.Value, Status = "Pending", CreatedDate = DateTime.UtcNow }; _context.Dispatches.Add(created); await _context.SaveChangesAsync(); workOrder.PrimaryDispatchId = created.Id; return created; } private async Task ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) { if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error)) throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number."); if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id)) throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists."); var old = workOrder.InternalWONumber; if (old == normalized) return FieldChange.Unchanged(auditField); workOrder.InternalWONumber = normalized; workOrder.WorkerOrderNumber = normalized; return FieldChange.ForField(auditField, old, normalized); } private static FieldChange ApplyWorkOrderType(WorkOrder workOrder, string? value, string auditField) { if (string.IsNullOrWhiteSpace(value)) throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required."); if (!Enum.TryParse(value, true, out var parsed)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); var old = workOrder.WorkOrderType?.ToString(); if (workOrder.WorkOrderType == parsed) return FieldChange.Unchanged(auditField); workOrder.WorkOrderType = parsed; return FieldChange.ForField(auditField, old, parsed.ToString()); } private static FieldChange ApplyStringField( string? value, string auditField, Action setter, Func getter) { var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); var old = getter(); if (old == normalized) return FieldChange.Unchanged(auditField); setter(normalized); return FieldChange.ForField(auditField, old, normalized); } private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) { var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value); if (parsed == null) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid lifecycleStatus: {value}"); var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status; if (workOrder.LifecycleStatus == parsed) return FieldChange.Unchanged(auditField); workOrder.LifecycleStatus = parsed; workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed); if (workOrder.LegacyStatus == null && workOrder.Status != null) workOrder.LegacyStatus = workOrder.Status; return FieldChange.StatusChanged(auditField, old, parsed.ToString()); } private static List ApplyAssignTo(WorkOrder workOrder, string? value, string auditField) { var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); var old = workOrder.AssignTo; var changes = new List(); if (old != normalized) { workOrder.AssignTo = normalized; changes.Add(FieldChange.AssignmentChanged(auditField, old, normalized)); } changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); return changes; } private static FieldChange ApplyDateField( string? value, string auditField, Action setter, Func getter) { DateTime? parsed = null; if (!string.IsNullOrWhiteSpace(value)) { if (!DateTime.TryParse(value, out var dt)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid date for {auditField}: {value}"); parsed = dt.Date; } var old = FormatDate(getter()); var newVal = FormatDate(parsed); if (old == newVal) return FieldChange.Unchanged(auditField); setter(parsed); return FieldChange.ForField(auditField, old, newVal); } private static List ApplyScheduledDate(WorkOrder workOrder, string? value, string auditField) { var previousDate = workOrder.ScheduledDate; DateTime? parsed = null; if (!string.IsNullOrWhiteSpace(value)) { if (!DateTime.TryParse(value, out var dt)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid scheduledDate: {value}"); parsed = dt.Date; } var changes = new List(); var oldFormatted = FormatDate(previousDate); var newFormatted = FormatDate(parsed); if (oldFormatted != newFormatted) { workOrder.ScheduledDate = parsed; changes.Add(FieldChange.ForField(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 (WorkOrderBoardMutationRules.IsReschedule(previousDate, parsed)) { var oldCount = workOrder.RescheduleCount.ToString(); workOrder.RescheduleCount++; changes.Add(FieldChange.ForField(WorkOrderBoardFieldNames.RescheduleCount, oldCount, workOrder.RescheduleCount.ToString())); } } changes.AddRange(ApplyAutoScheduleSideEffects(workOrder)); return changes; } private static FieldChange ApplyTargetWeek(WorkOrder workOrder, string? value, string auditField) { DateOnly? parsed = null; if (!string.IsNullOrWhiteSpace(value)) { if (!DateOnly.TryParse(value, out var d)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid targetWeek: {value}"); parsed = d; } var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd"); var newVal = parsed?.ToString("yyyy-MM-dd"); if (old == newVal) return FieldChange.Unchanged(auditField); workOrder.TargetWeek = parsed; return FieldChange.ForField(auditField, old, newVal); } private static FieldChange ApplyBoolField( string? value, string auditField, Action setter, Func getter) { if (string.IsNullOrWhiteSpace(value)) throw new WorkOrderBoardValidationException("InvalidValue", $"{auditField} value is required."); if (!bool.TryParse(value, out var parsed)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid boolean for {auditField}: {value}"); var old = getter()?.ToString(); if (getter() == parsed) return FieldChange.Unchanged(auditField); setter(parsed); return FieldChange.ForField(auditField, old, parsed.ToString()); } private static FieldChange ApplyVendorId(Dispatch dispatch, string? value, string auditField) { var vendorId = ParseVendorIdHint(value); if (!vendorId.HasValue) throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer."); var old = dispatch.VendorId.ToString(); if (dispatch.VendorId == vendorId.Value) return FieldChange.Unchanged(auditField, dispatch.Id); dispatch.VendorId = vendorId.Value; return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id); } private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField) { DateTime? parsed = null; if (!string.IsNullOrWhiteSpace(value)) { if (!DateTime.TryParse(value, out var dt)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid apptDate: {value}"); parsed = dt.Date; } var old = FormatDate(dispatch.ScheduledDate); var newVal = FormatDate(parsed); if (old == newVal) return FieldChange.Unchanged(auditField, dispatch.Id); dispatch.ScheduledDate = parsed; return FieldChange.ForField(auditField, old, newVal, dispatch.Id); } private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField) { 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 FieldChange.Unchanged(auditField, dispatch.Id); return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id); } private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField) { if (string.IsNullOrWhiteSpace(value)) throw new WorkOrderBoardValidationException("InvalidValue", "docStatus value is required."); if (!Enum.TryParse(value, true, out var parsed)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid docStatus: {value}"); var old = workOrder.DocStatus?.ToString(); if (workOrder.DocStatus == parsed) return FieldChange.Unchanged(auditField); workOrder.DocStatus = parsed; return FieldChange.ForField(auditField, old, parsed.ToString()); } private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) { var changes = new List(); if (!WorkOrderBoardMutationRules.ShouldAutoSchedule(workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo)) return changes; var old = workOrder.LifecycleStatus?.ToString(); workOrder.LifecycleStatus = LifecycleStatus.Scheduled; workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); changes.Add(FieldChange.StatusChanged("LifecycleStatus", old, LifecycleStatus.Scheduled.ToString())); return changes; } private static bool RowVersionsMatch(byte[]? current, byte[] expected) => current != null && current.AsSpan().SequenceEqual(expected); private async Task LoadBoardRowAsync(int workOrderId) { var raw = await _boardDataService.GetBoardRowByIdAsync(workOrderId); return raw == null ? null : WorkOrderBoardService.MapRawRow(raw, DateTime.UtcNow); } private static byte[]? ParseRowVersion(string? base64, string fieldName) { if (string.IsNullOrWhiteSpace(base64)) return null; try { return Convert.FromBase64String(base64); } catch (FormatException) { throw new WorkOrderBoardValidationException("InvalidRowVersion", $"Invalid {fieldName} format."); } } private static int? ParseVendorIdHint(string? value) { if (string.IsNullOrWhiteSpace(value) || !int.TryParse(value, out var vendorId) || vendorId <= 0) return null; return vendorId; } 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}"; private sealed record FieldChange( string FieldName, string? OldValue, string? NewValue, AuditActionType Action, int? DispatchId, bool HasChanged) { public static FieldChange Unchanged(string fieldName, int? dispatchId = null) => new(fieldName, null, null, AuditActionType.FieldChanged, dispatchId, false); public static FieldChange ForField(string fieldName, string? oldValue, string? newValue, int? dispatchId = null) => new(fieldName, oldValue, newValue, AuditActionType.FieldChanged, dispatchId, true); public static FieldChange StatusChanged(string fieldName, string? oldValue, string? newValue) => new(fieldName, oldValue, newValue, AuditActionType.StatusChanged, null, true); public static FieldChange AssignmentChanged(string fieldName, string? oldValue, string? newValue) => new(fieldName, oldValue, newValue, AuditActionType.AssignmentChanged, null, true); } } }