mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 15:23:12 +00:00
Some checks are pending
Validate and deploy / Validate deployable source bundle (push) Waiting to run
Validate and deploy / Deploy shoc-backend-dev through Terraform (push) Blocked by required conditions
Validate and deploy / Deploy shoc-backend-staging to Elastic Beanstalk (push) Blocked by required conditions
SH-320: the WO number normalizer stripped every non-digit, so a manually entered SH placeholder (e.g. SH00001) was saved as 00000000001 on both create and patch. Keep the SH prefix, and reject replacing a saved real APM number with an SH placeholder.
878 lines
40 KiB
C#
878 lines
40 KiB
C#
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<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.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,
|
|
relaxInvalidDispatch: vendorIdHint.HasValue,
|
|
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.");
|
|
}
|
|
|
|
if (resolved is { Created: true, Previous: { } previousDispatch })
|
|
_mutationData.DetachWorkOrderFromDispatch(previousDispatch, workOrder.Id);
|
|
}
|
|
|
|
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
|
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
|
|
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
|
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var previousVendor = resolved.Previous?.VendorId.ToString() ?? "";
|
|
changes = new List<FieldChange>
|
|
{
|
|
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<List<FieldChange>> ApplyFieldMutationAsync(
|
|
string field,
|
|
WorkOrder workOrder,
|
|
Dispatch? dispatch,
|
|
string? value,
|
|
string auditField,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return field switch
|
|
{
|
|
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
|
WorkOrderBoardFieldNames.WorkOrderType => ApplyWorkOrderType(workOrder, value, auditField),
|
|
WorkOrderBoardFieldNames.Severity => new List<FieldChange> { ApplySeverity(workOrder, value, auditField) },
|
|
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
|
WorkOrderBoardFieldNames.LifecycleStatus => 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 => ApplyTargetWeek(workOrder, value, auditField),
|
|
WorkOrderBoardFieldNames.ScheduleWeekOnly => ApplyScheduleWeekOnly(workOrder, value, auditField),
|
|
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.AdditionalContacts => new List<FieldChange> { ApplyAdditionalContacts(workOrder, value, auditField) },
|
|
WorkOrderBoardFieldNames.FlagColor => new List<FieldChange> { ApplyFlagColor(workOrder, value, auditField) },
|
|
WorkOrderBoardFieldNames.AvetaRequired => new List<FieldChange> { ApplyAvetaRequired(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<ResolvedDispatch> ResolveDispatchAsync(
|
|
WorkOrder workOrder,
|
|
int? primaryDispatchId,
|
|
int? vendorIdForCreate,
|
|
bool replaceInactivePrimary,
|
|
bool relaxInvalidDispatch,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var existing = await LoadExistingDispatchAsync(
|
|
workOrder,
|
|
primaryDispatchId,
|
|
relaxInvalidDispatch,
|
|
cancellationToken);
|
|
var vendorChanged = existing != null
|
|
&& vendorIdForCreate.HasValue
|
|
&& existing.VendorId != vendorIdForCreate.Value;
|
|
var mustReplace = existing != null
|
|
&& replaceInactivePrimary
|
|
&& (WorkOrderBoardDispatchAssignment.IsInactivePrimaryDispatch(existing.Status)
|
|
|| (IsSharedDispatch(existing, workOrder.Id) && vendorChanged));
|
|
if (existing != null && !mustReplace)
|
|
{
|
|
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<Dispatch?> LoadExistingDispatchAsync(
|
|
WorkOrder workOrder,
|
|
int? primaryDispatchId,
|
|
bool relaxInvalidDispatch,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var clientId = NormalizeClientPrimaryDispatchId(primaryDispatchId);
|
|
if (clientId.HasValue)
|
|
{
|
|
var dispatch = await _mutationData.GetTrackedDispatchAsync(
|
|
clientId.Value,
|
|
workOrder.Id,
|
|
cancellationToken);
|
|
|
|
if (dispatch != null)
|
|
return dispatch;
|
|
|
|
if (!relaxInvalidDispatch)
|
|
throw new WorkOrderBoardValidationException(
|
|
"InvalidDispatch",
|
|
"Primary dispatch does not belong to this work order.");
|
|
}
|
|
|
|
var storedId = NormalizeClientPrimaryDispatchId(workOrder.PrimaryDispatchId);
|
|
if (!storedId.HasValue)
|
|
return null;
|
|
|
|
var stored = await _mutationData.GetTrackedDispatchByIdAsync(
|
|
storedId.Value,
|
|
cancellationToken);
|
|
if (stored == null || !DispatchBelongsToWorkOrder(stored, workOrder.Id))
|
|
return null;
|
|
|
|
return stored;
|
|
}
|
|
|
|
private static int? NormalizeClientPrimaryDispatchId(int? primaryDispatchId)
|
|
=> primaryDispatchId is > 0 ? primaryDispatchId : null;
|
|
|
|
private static bool DispatchBelongsToWorkOrder(Dispatch dispatch, int workOrderId)
|
|
=> dispatch.WorkOrderId == workOrderId
|
|
|| dispatch.DispatchWorkOrders?.Any(link => link.WorkOrderId == workOrderId) == true;
|
|
|
|
private static bool IsSharedDispatch(Dispatch dispatch, int workOrderId)
|
|
{
|
|
if (dispatch.WorkOrderId.HasValue && dispatch.WorkOrderId.Value != workOrderId)
|
|
return true;
|
|
|
|
var linkedIds = dispatch.DispatchWorkOrders?
|
|
.Select(link => link.WorkOrderId)
|
|
.Distinct()
|
|
.ToList();
|
|
if (linkedIds == null || linkedIds.Count == 0)
|
|
return false;
|
|
if (linkedIds.Count > 1)
|
|
return true;
|
|
return dispatch.WorkOrderId.HasValue && linkedIds[0] != dispatch.WorkOrderId.Value;
|
|
}
|
|
|
|
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<FieldChange> ApplyWoNumber(
|
|
WorkOrder workOrder,
|
|
string? value,
|
|
string auditField,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
|
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
|
|
|
// SH-320: a saved real APM number can never be downgraded to an SH placeholder.
|
|
if (WorkOrderNumberRules.IsProvisional(normalized)
|
|
&& !string.IsNullOrWhiteSpace(workOrder.InternalWONumber)
|
|
&& !WorkOrderNumberRules.IsProvisional(workOrder.InternalWONumber))
|
|
throw new WorkOrderBoardValidationException(
|
|
"WoNumberDowngrade",
|
|
"Cannot replace an APM work order number with an internal number.");
|
|
|
|
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
|
|
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 List<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 changes = new List<FieldChange>();
|
|
var old = workOrder.WorkOrderType?.ToString();
|
|
if (workOrder.WorkOrderType != parsed)
|
|
{
|
|
workOrder.WorkOrderType = parsed;
|
|
changes.Add(FieldChange.ForField(auditField, old, parsed.ToString()));
|
|
}
|
|
|
|
if (!WorkOrderSeverityRules.TypeRequiresSeverity(parsed) && workOrder.Severity != null)
|
|
{
|
|
var previousSeverity = workOrder.Severity;
|
|
workOrder.Severity = null;
|
|
changes.Add(FieldChange.ForField("Severity", previousSeverity, null));
|
|
}
|
|
|
|
if (changes.Count == 0)
|
|
changes.Add(FieldChange.Unchanged(auditField));
|
|
|
|
return changes;
|
|
}
|
|
|
|
private static FieldChange ApplySeverity(WorkOrder workOrder, string? value, string auditField)
|
|
{
|
|
if (!WorkOrderSeverityRules.TryNormalize(value, out var normalized))
|
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid severity: {value}");
|
|
|
|
var old = workOrder.Severity;
|
|
if (old == normalized)
|
|
return FieldChange.Unchanged(auditField);
|
|
|
|
workOrder.Severity = normalized;
|
|
return FieldChange.ForField(auditField, old, normalized);
|
|
}
|
|
|
|
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 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<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 List<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.");
|
|
}
|
|
|
|
WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(
|
|
workOrder.LifecycleStatus,
|
|
parsed.Value,
|
|
workOrder.ScheduledDate,
|
|
workOrder.ScheduleWeekOnly);
|
|
|
|
var changes = new List<FieldChange>();
|
|
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
|
|
if (workOrder.LifecycleStatus != parsed)
|
|
{
|
|
if (parsed == LifecycleStatus.Completed)
|
|
WorkOrderCompletionSnapshotMapper.Capture(workOrder);
|
|
|
|
workOrder.LifecycleStatus = parsed;
|
|
workOrder.Status = WorkOrderDerivedFields.GetLifecycleStatusLabel(parsed);
|
|
if (workOrder.LegacyStatus == null && workOrder.Status != null)
|
|
workOrder.LegacyStatus = workOrder.Status;
|
|
|
|
changes.Add(FieldChange.StatusChanged(auditField, old, parsed.ToString()));
|
|
}
|
|
|
|
// why: Incomplete/Pending patched while a concrete date already exists must still promote.
|
|
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
|
return changes;
|
|
}
|
|
|
|
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));
|
|
AppendIsAddOnRecalculation(workOrder, changes);
|
|
return changes;
|
|
}
|
|
|
|
private static List<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 changes = new List<FieldChange>();
|
|
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<FieldChange> ApplyScheduleWeekOnly(WorkOrder workOrder, string? value, string auditField)
|
|
{
|
|
var change = ApplyBoolField(value, auditField, v => workOrder.ScheduleWeekOnly = v, () => workOrder.ScheduleWeekOnly);
|
|
var changes = new List<FieldChange> { change };
|
|
if (change.HasChanged)
|
|
AppendIsAddOnRecalculation(workOrder, changes);
|
|
changes.AddRange(ApplyAutoScheduleSideEffects(workOrder));
|
|
return changes;
|
|
}
|
|
|
|
private static void AppendIsAddOnRecalculation(WorkOrder workOrder, List<FieldChange> 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<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.ToScheduledInstant(apptDate, start);
|
|
workOrder.ScheduledEnd = WorkOrderBoardApptTimeParser.ToScheduledInstant(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 ApplyAvetaRequired(WorkOrder workOrder, string? value, string auditField)
|
|
{
|
|
return ApplyBoolField(
|
|
value,
|
|
auditField,
|
|
v => workOrder.AvetaRequired = v == true,
|
|
() => workOrder.AvetaRequired);
|
|
}
|
|
|
|
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)
|
|
=> WorkOrderBoardFieldMutations.ApplyAutoScheduleSideEffects(workOrder)
|
|
.Select(ToFieldChange)
|
|
.ToList();
|
|
|
|
private static FieldChange ToFieldChange(WorkOrderBoardFieldMutations.BoardFieldChange change)
|
|
=> new(change.FieldName, change.OldValue, change.NewValue, change.Action, change.DispatchId, change.HasChanged);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|