using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Helpers; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; 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 IWorkOrderBoardDataService _boardDataService; private readonly IWorkOrderBoardMutationDataService _mutationData; private readonly IWorkOrderAuditService _auditService; public WorkOrderBoardUpdateService( IWorkOrderBoardDataService boardDataService, IWorkOrderBoardMutationDataService mutationData, IWorkOrderAuditService auditService) { _boardDataService = boardDataService; _mutationData = mutationData; _auditService = auditService; } public async Task PatchFieldAsync( int workOrderId, WorkOrderBoardPatchRequestDto request, string? actorId) { await _mutationData.ExecuteTransactionalAsync(async ct => { 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 _mutationData.GetTrackedWorkOrderAsync(workOrderId, ct); 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.CanPatchBoardField(workOrder.LifecycleStatus, canonicalField)) { var code = WorkOrderBoardMutationRules.IsFullyLocked(workOrder.LifecycleStatus) ? "CanceledReadOnly" : "ReadOnly"; throw new WorkOrderBoardValidationException(code, "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. Update Schedule On first."); _mutationData.SetExpectedWorkOrderVersion(workOrder, workOrderVersion); Dispatch? dispatch = null; ResolvedDispatch? resolved = null; if (WorkOrderBoardFieldNames.IsDispatchField(canonicalField)) { var vendorIdHint = canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase) ? ParseVendorIdHint(request.Value) : null; var hadPrimaryDispatch = workOrder.PrimaryDispatchId.HasValue; var replaceInactivePrimary = vendorIdHint.HasValue; resolved = await ResolveDispatchAsync( workOrder, request.PrimaryDispatchId, vendorIdHint, replaceInactivePrimary, ct); dispatch = resolved.Dispatch; var versionSource = resolved.Created ? resolved.Previous : dispatch; if (dispatchVersion != null) { if (versionSource != null && !RowVersionsMatch(versionSource.RowVersion, dispatchVersion)) { var staleState = await LoadBoardRowAsync(workOrderId); throw new WorkOrderBoardConcurrencyException(staleState); } if (!resolved.Created) _mutationData.SetExpectedDispatchVersion(dispatch, dispatchVersion); } else if (hadPrimaryDispatch && versionSource?.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); if (resolved is { Created: true, Dispatch: var createdDispatch } && canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase)) { var previousVendor = resolved.Previous?.VendorId.ToString() ?? ""; changes = new List { FieldChange.ForField( auditField, previousVendor, createdDispatch.VendorId.ToString(), createdDispatch.Id) }; } else { changes = changes.Where(c => c.HasChanged).ToList(); } if (changes.Count == 0 && !_mutationData.HasPendingChanges()) return; if (changes.Count == 0) { await SaveTrackedOrThrowAsync(workOrderId, ct); return; } foreach (var change in changes) { 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; } } await SaveTrackedOrThrowAsync(workOrderId, ct); }, CancellationToken.None); 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 => ApplyTargetWeek(workOrder, value, auditField), WorkOrderBoardFieldNames.ScheduleWeekOnly => ApplyScheduleWeekOnly(workOrder, value, auditField), WorkOrderBoardFieldNames.VendorId => new List { await ApplyVendorIdAsync(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, WorkOrderBoardFieldNames.CompletedDate => new List { ApplyDateField(value, auditField, v => workOrder.CompletedDate = v, () => workOrder.CompletedDate) }, WorkOrderBoardFieldNames.Pm => new List { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, WorkOrderBoardFieldNames.ServiceNotes => new List { ApplyServiceNotes(workOrder, value, auditField) }, WorkOrderBoardFieldNames.ExtraServices => new List { ApplyExtraServices(workOrder, value, auditField) }, WorkOrderBoardFieldNames.AdditionalContacts => new List { ApplyAdditionalContacts(workOrder, value, auditField) }, WorkOrderBoardFieldNames.FlagColor => new List { ApplyFlagColor(workOrder, value, auditField) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; } private sealed record ResolvedDispatch(Dispatch Dispatch, bool Created, Dispatch? Previous); private async Task ResolveDispatchAsync( WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate, bool replaceInactivePrimary, CancellationToken cancellationToken) { var existing = await LoadExistingDispatchAsync(workOrder, primaryDispatchId, cancellationToken); if (existing != null && !(replaceInactivePrimary && WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status))) { workOrder.PrimaryDispatchId = existing.Id; return new ResolvedDispatch(existing, Created: false, Previous: null); } if (!vendorIdForCreate.HasValue || vendorIdForCreate.Value <= 0) throw new WorkOrderBoardValidationException("DispatchRequired", "A primary dispatch is required. Set vendorId first or provide primaryDispatchId."); await EnsureVendorExistsAsync(vendorIdForCreate.Value); var created = new Dispatch { WorkOrderId = workOrder.Id, VendorId = vendorIdForCreate.Value, Status = "Pending", CreatedDate = DateTime.UtcNow }; _mutationData.TrackNewDispatch(created); await SaveTrackedOrThrowAsync(workOrder.Id, cancellationToken); workOrder.PrimaryDispatchId = created.Id; return new ResolvedDispatch(created, Created: true, Previous: existing); } private async Task LoadExistingDispatchAsync( WorkOrder workOrder, int? primaryDispatchId, CancellationToken cancellationToken) { if (primaryDispatchId.HasValue) { var dispatch = await _mutationData.GetTrackedDispatchAsync( primaryDispatchId.Value, workOrder.Id, cancellationToken); if (dispatch == null) throw new WorkOrderBoardValidationException( "InvalidDispatch", "Primary dispatch does not belong to this work order."); return dispatch; } if (!workOrder.PrimaryDispatchId.HasValue) return null; return await _mutationData.GetTrackedDispatchByIdAsync( workOrder.PrimaryDispatchId.Value, cancellationToken); } private async Task EnsureVendorExistsAsync(int vendorId) { if (!await _mutationData.VendorExistsAsync(vendorId, CancellationToken.None)) throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); } private async Task SaveTrackedOrThrowAsync(int workOrderId, CancellationToken cancellationToken) { var outcome = await _mutationData.SaveAsync(cancellationToken); if (outcome != BoardSaveOutcome.ConcurrencyConflict) return; var currentState = await LoadBoardRowAsync(workOrderId); throw new WorkOrderBoardConcurrencyException(currentState); } 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 (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)) throw new WorkOrderBoardValidationException( "InvalidValue", "Overdue is not a work order type; it is derived from dueDate (isPastDue)."); if (!WorkOrderTypeMapper.TryParseFeLabel(value, 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 ApplyServiceNotes(WorkOrder workOrder, string? value, string auditField) { var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); if (normalized != null && normalized.Length > 4000) throw new WorkOrderBoardValidationException("InvalidValue", "serviceNotes must be at most 4000 characters."); var old = workOrder.ServiceNotes; if (old == normalized) return FieldChange.Unchanged(auditField); workOrder.ServiceNotes = normalized; return FieldChange.ForField(auditField, old, normalized); } private static FieldChange ApplyExtraServices(WorkOrder workOrder, string? value, string auditField) { string? normalizedJson = null; if (!string.IsNullOrWhiteSpace(value)) { List? parsed; try { parsed = JsonSerializer.Deserialize>(value.Trim()); } catch (JsonException) { throw new WorkOrderBoardValidationException("InvalidValue", "extraServices must be a JSON string array."); } var items = NormalizeExtraServiceList(parsed); if (items.Count > 0) { if (string.IsNullOrWhiteSpace(workOrder.Trade)) { throw new WorkOrderBoardValidationException( "InvalidValue", "primaryService (pm/trade) is required when extraServices is provided."); } items = items .Where(s => !string.Equals(s, workOrder.Trade, StringComparison.OrdinalIgnoreCase)) .ToList(); normalizedJson = items.Count > 0 ? JsonSerializer.Serialize(items) : null; if (normalizedJson != null && normalizedJson.Length > 2000) { throw new WorkOrderBoardValidationException( "InvalidValue", "extraServices must serialize to at most 2000 characters."); } } } var old = workOrder.ExtraServices; if (old == normalizedJson) return FieldChange.Unchanged(auditField); workOrder.ExtraServices = normalizedJson; return FieldChange.ForField(auditField, old, normalizedJson); } private static FieldChange ApplyAdditionalContacts(WorkOrder workOrder, string? value, string auditField) { var normalizedJson = WorkOrderAdditionalContactsMapper.SerializeForPatch(value); var old = workOrder.AdditionalContacts; if (old == normalizedJson) return FieldChange.Unchanged(auditField); workOrder.AdditionalContacts = normalizedJson; return FieldChange.ForField(auditField, old, normalizedJson); } private static List NormalizeExtraServiceList(List? extras) { var normalized = new List(); if (extras == null || extras.Count == 0) return normalized; 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); } return 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}"); if (parsed == LifecycleStatus.Completed && WorkOrderNumberRules.IsProvisional(workOrder.InternalWONumber)) { throw new WorkOrderBoardValidationException( "ProvisionalWoNumber", "Replace the provisional SH work order number before marking the work order Completed."); } 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)); AppendIsAddOnRecalculation(workOrder, changes); return changes; } private static List 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 changes = new List(); var old = workOrder.TargetWeek?.ToString("yyyy-MM-dd"); var newVal = parsed?.ToString("yyyy-MM-dd"); if (old != newVal) { workOrder.TargetWeek = parsed; changes.Add(FieldChange.ForField(auditField, old, newVal)); } AppendIsAddOnRecalculation(workOrder, changes); return changes; } private static List ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField) { var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly); var changes = new List { change }; if (change.HasChanged) AppendIsAddOnRecalculation(workOrder, changes); return changes; } private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List changes) { var createdAt = workOrder.CreatedDate ?? DateTime.UtcNow; var oldValue = workOrder.IsAddOn.ToString(); var newIsAddOn = WorkOrderAddonCutoff.RecalculateIsAddOnFromSchedule( createdAt, workOrder.ScheduledDate, workOrder.TargetWeek); if (workOrder.IsAddOn == newIsAddOn) return; workOrder.IsAddOn = newIsAddOn; changes.Add(FieldChange.ForField("IsAddOn", oldValue, newIsAddOn.ToString())); } 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 async Task ApplyVendorIdAsync(Dispatch dispatch, string? value, string auditField) { var vendorId = ParseVendorIdHint(value); if (!vendorId.HasValue) throw new WorkOrderBoardValidationException("InvalidValue", "vendorId must be a positive integer."); await EnsureVendorExistsAsync(vendorId.Value); var old = dispatch.VendorId.ToString(); if (dispatch.VendorId == vendorId.Value) return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch)); dispatch.VendorId = vendorId.Value; return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch)); } 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, ResolveDispatchIdForAudit(dispatch)); dispatch.ScheduledDate = parsed; return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch)); } // apptTime is stored on WorkOrder (ScheduledStart/End). Lock those sync field names so // SyncFieldMergePolicy honors the manual edit (not a synthetic "ApptTime" lock). private static List 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); var dispatchId = ResolveDispatchIdForAudit(dispatch); workOrder.ScheduledStart = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, start); workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.CombineDateAndTime(apptDate, end); var newStart = FormatDateTime(workOrder.ScheduledStart); var newEnd = FormatDateTime(workOrder.ScheduledEnd); var changes = new List(); if (oldStart != newStart) changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledStart, oldStart, newStart, dispatchId)); if (oldEnd != newEnd) changes.Add(FieldChange.ForField(WorkOrderFieldNames.ScheduledEnd, oldEnd, newEnd, dispatchId)); if (changes.Count == 0) changes.Add(FieldChange.Unchanged(WorkOrderFieldNames.ScheduledStart, dispatchId)); return changes; } 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 FieldChange ApplyFlagColor(WorkOrder workOrder, string? value, string auditField) { if (!WorkOrderFlagColors.TryNormalize(value, out var canonical)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid flagColor: {value}"); if (canonical != null && !WorkOrderFlagColors.IsAllowed(canonical)) throw new WorkOrderBoardValidationException( "InvalidValue", $"flagColor must be one of the allowed palette values or empty to clear."); var old = workOrder.FlagColor; if (string.Equals(old, canonical, StringComparison.OrdinalIgnoreCase)) return FieldChange.Unchanged(auditField); workOrder.FlagColor = canonical; return FieldChange.ForField(auditField, old, canonical); } private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) { var changes = new List(); if (!WorkOrderBoardMutationRules.ShouldAutoSchedule( workOrder.LifecycleStatus, workOrder.ScheduledDate, workOrder.AssignTo, workOrder.ScheduleWeekOnly)) return changes; var old = workOrder.LifecycleStatus?.ToString(); workOrder.LifecycleStatus = LifecycleStatus.Scheduled; workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(LifecycleStatus.Scheduled); changes.Add(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 int? ResolveDispatchIdForAudit(Dispatch dispatch) => dispatch.Id > 0 ? dispatch.Id : null; 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); } } }