shoc-backend/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
2026-07-09 09:59:41 -03:00

529 lines
24 KiB
C#

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<WorkOrderBoardRowDto> 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<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> { ApplyVendorId(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
};
}
private async Task<Dispatch> 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<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 (!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 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 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<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 List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
{
var changes = new List<FieldChange>();
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<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 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);
}
}
}