shoc-backend/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
Arthur Bassi 05dd262e80 fix(work-orders): derive Past Due from DueDate instead of ScheduledDate
Past Due must track the deadline (Due Date), not Schedule On. Keep dueDate and scheduledDate PATCH mutations independent so rescheduling alone does not clear Past Due.
2026-08-13 13:28:25 -03:00

658 lines
31 KiB
C#

using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
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<WorkOrderBoardRowDto> 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.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("CanceledReadOnly", "Work order is read-only in its current status.");
var isPastDue = WorkOrderDerivedFields.IsPastDue(workOrder.DueDate, workOrder.LifecycleStatus);
if (WorkOrderBoardMutationRules.ShouldBlockStatusChangeWhenPastDue(canonicalField, isPastDue))
throw new WorkOrderBoardValidationException("PastDueStatusBlocked", "Status cannot be changed while work order is past due. Update due date first.");
_mutationData.SetExpectedWorkOrderVersion(workOrder, 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, ct);
if (dispatchVersion != null && !RowVersionsMatch(dispatch.RowVersion, dispatchVersion))
{
var staleState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(staleState);
}
if (dispatchVersion != null)
_mutationData.SetExpectedDispatchVersion(dispatch, 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 && !_mutationData.HasPendingChanges())
return;
if (changes.Count == 0)
{
await _mutationData.SaveAsync(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;
}
}
var outcome = await _mutationData.SaveAsync(ct);
if (outcome == BoardSaveOutcome.ConcurrencyConflict)
{
var currentState = await LoadBoardRowAsync(workOrderId);
throw new WorkOrderBoardConcurrencyException(currentState);
}
}, CancellationToken.None);
var row = await LoadBoardRowAsync(workOrderId);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private async Task<List<FieldChange>> ApplyFieldMutationAsync(
string field,
WorkOrder workOrder,
Dispatch? dispatch,
string? value,
string auditField)
{
return field switch
{
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField) },
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.AssignTo => ApplyAssignTo(workOrder, value, auditField),
WorkOrderBoardFieldNames.DueDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.DueDate = v, () => workOrder.DueDate) },
WorkOrderBoardFieldNames.ScheduledDate => ApplyScheduledDate(workOrder, value, auditField),
WorkOrderBoardFieldNames.TargetWeek => new List<FieldChange> { ApplyTargetWeek(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ScheduleWeekOnly => new List<FieldChange> { ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly) },
WorkOrderBoardFieldNames.VendorId => new List<FieldChange> { await ApplyVendorIdAsync(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
WorkOrderBoardFieldNames.CompletedDate => new List<FieldChange> { ApplyDateField(value, auditField, v => workOrder.CompletedDate = v, () => workOrder.CompletedDate) },
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },
WorkOrderBoardFieldNames.FlagColor => new List<FieldChange> { ApplyFlagColor(workOrder, value, auditField) },
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
};
}
private async Task<Dispatch> ResolveDispatchAsync(WorkOrder workOrder, int? primaryDispatchId, int? vendorIdForCreate, 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.");
workOrder.PrimaryDispatchId = dispatch.Id;
return dispatch;
}
if (workOrder.PrimaryDispatchId.HasValue)
{
var existing = await _mutationData.GetTrackedDispatchByIdAsync(workOrder.PrimaryDispatchId.Value, cancellationToken);
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.");
await EnsureVendorExistsAsync(vendorIdForCreate.Value);
var created = new Dispatch
{
WorkOrderId = workOrder.Id,
VendorId = vendorIdForCreate.Value,
Status = "Pending",
CreatedDate = DateTime.UtcNow
};
_mutationData.TrackNewDispatch(created);
await _mutationData.SaveAsync(cancellationToken);
workOrder.PrimaryDispatchId = created.Id;
return created;
}
private async Task EnsureVendorExistsAsync(int vendorId)
{
if (!await _mutationData.VendorExistsAsync(vendorId, CancellationToken.None))
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
}
private async Task<FieldChange> 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<string?> setter,
Func<string?> 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<string>? parsed;
try
{
parsed = JsonSerializer.Deserialize<List<string>>(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 List<string> NormalizeExtraServiceList(List<string>? extras)
{
var normalized = new List<string>();
if (extras == null || extras.Count == 0)
return normalized;
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);
}
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}");
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<FieldChange> ApplyAssignTo(WorkOrder workOrder, string? value, string auditField)
{
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
var old = workOrder.AssignTo;
var changes = new List<FieldChange>();
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<DateTime?> setter,
Func<DateTime?> 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<FieldChange> 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<FieldChange>();
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<bool?> setter,
Func<bool?> 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<FieldChange> 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<FieldChange> 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<FieldChange>();
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<DocStatus>(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<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<FieldChange>();
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<WorkOrderBoardRowDto?> 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);
}
}
}