shoc-backend/SeaHaven.Services/Implementation/VendorOperationsService.cs

448 lines
25 KiB
C#
Raw Normal View History

refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class VendorOperationsService : IVendorOperationsService
{
private static readonly string[] ValidAvailabilityStatuses = { "Available", "Unavailable", "Unknown" };
private static readonly string[] InvoiceStatuses = { "Not Submitted", "Processing", "Confirmed", "Rejected" };
private static readonly string[] PaymentStatuses = { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" };
private readonly IVendorOperationsDataService _data;
private readonly TimeProvider _timeProvider;
public VendorOperationsService(IVendorOperationsDataService data, TimeProvider timeProvider)
{
_data = data;
_timeProvider = timeProvider;
}
public DateTime UtcNow => _timeProvider.GetUtcNow().UtcDateTime;
public async Task<VendorNotificationsResult> GetNotificationsAsync(CancellationToken cancellationToken)
{
var now = UtcNow;
var cutoff = now.AddHours(48);
var assignedWorkOrderIds = await _data.GetAssignedWorkOrderIdsAsync(cancellationToken);
var candidates = await _data.GetNoVendorWorkOrdersAsync(cutoff, assignedWorkOrderIds, cancellationToken);
var noVendor = candidates.Select(wo => new NoVendorNotification
{
Id = $"no-vendor-{wo.Id}",
Type = "NoVendor",
Severity = "Medium",
Group = "No Vendor",
Title = "No vendor assigned — 48h to service",
WorkOrderId = wo.Id,
WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber,
TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48),
ScheduledAt = wo.ScheduledDate!.Value,
Action = new NotificationActionDto { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" }
}).Cast<VendorNotificationItem>().ToList();
var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken);
var conflicts = BuildConflictPairs(dispatches)
.Select(pair => (VendorNotificationItem)new VendorConflictNotification
{
Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}",
Type = "VendorConflict",
Severity = "Medium",
Group = "Vendor Conflict",
Title = "Vendor has overlapping appointments",
VendorId = pair.Left.VendorId,
VendorName = pair.Left.VendorName,
WorkOrders = new[]
{
new ConflictWorkOrderDto { WorkOrderId = pair.Left.WorkOrderId, WorkOrderNumber = pair.Left.WorkOrderNumber, Start = pair.Left.Start, End = pair.Left.End },
new ConflictWorkOrderDto { WorkOrderId = pair.Right.WorkOrderId, WorkOrderNumber = pair.Right.WorkOrderNumber, Start = pair.Right.Start, End = pair.Right.End }
},
TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start,
Actions = new[]
{
new NotificationActionDto { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" },
new NotificationActionDto { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" }
}
})
.ToList();
var items = noVendor.Concat(conflicts).OrderBy(item => item.TriggeredAt).ToList();
return new VendorNotificationsResult { GeneratedAt = now, Items = items };
}
public async Task<VendorOperationOutcome<VendorAvailabilityResult>> GetAvailabilityAsync(
DateTime? start, DateTime? end, int? locationId, string? trade, CancellationToken cancellationToken)
{
var now = UtcNow;
var requestedStart = start ?? now;
var requestedEnd = end ?? requestedStart.AddHours(2);
if (requestedEnd <= requestedStart)
return VendorOperationOutcome<VendorAvailabilityResult>.BadRequest("end must be after start");
var vendors = await _data.GetActiveVendorsAsync(cancellationToken);
var preferences = locationId.HasValue
? await _data.GetSitePreferredVendorsAsync(locationId.Value, cancellationToken)
: new List<SitePreferredVendor>();
var dispatches = await _data.GetActiveScheduledDispatchesAsync(cancellationToken);
var result = vendors.Select(vendor =>
{
var matchingDispatches = dispatches
.Where(dispatch => dispatch.VendorId == vendor.Id
&& WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End))
.ToList();
var matchingPreference = preferences
.Where(preference => preference.VendorId == vendor.Id
&& (string.IsNullOrWhiteSpace(preference.Trade)
|| string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase)))
.OrderBy(preference => preference.SortOrder)
.FirstOrDefault();
var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue
&& vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24);
var status = matchingDispatches.Count > 0
? "Unavailable"
: signalIsFresh && ValidAvailabilityStatuses.Contains(vendor.AvailabilityStatus ?? "")
? vendor.AvailabilityStatus
: "Unknown";
return new VendorAvailabilityRow
{
Id = vendor.Id,
CompanyName = vendor.CompanyName,
ContactName = vendor.ContactName,
TradeSpecialties = vendor.TradeSpecialties,
AvailabilityStatus = status,
AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt,
IsPreferred = matchingPreference != null,
PreferenceRank = matchingPreference?.SortOrder,
Conflicts = matchingDispatches.Select(dispatch => new AvailabilityConflictDto
{
WorkOrderId = dispatch.WorkOrderId,
WorkOrderNumber = dispatch.WorkOrderNumber,
Start = dispatch.Start,
End = dispatch.End
}).ToList()
};
})
.OrderBy(vendor => vendor.IsPreferred ? 0 : 1)
.ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue)
.ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0
: vendor.AvailabilityStatus == "Unknown" ? 1 : 2)
.ThenBy(vendor => vendor.CompanyName)
.ToList();
return VendorOperationOutcome<VendorAvailabilityResult>.Ok(
new VendorAvailabilityResult { GeneratedAt = now, StaleAfterHours = 24, Data = result });
}
public async Task<VendorOperationOutcome<VendorAvailabilityUpdateResult>> UpdateAvailabilityAsync(
int vendorId, string? status, string actor, CancellationToken cancellationToken)
{
var normalized = status?.Trim();
if (normalized is not ("Available" or "Unavailable" or "Unknown"))
return VendorOperationOutcome<VendorAvailabilityUpdateResult>.BadRequest("status must be Available, Unavailable, or Unknown");
var vendor = await _data.GetVendorForUpdateAsync(vendorId, cancellationToken);
if (vendor == null)
return VendorOperationOutcome<VendorAvailabilityUpdateResult>.NotFound();
var now = UtcNow;
var previous = vendor.AvailabilityStatus;
vendor.AvailabilityStatus = normalized;
vendor.AvailabilityUpdatedAt = now;
vendor.LastModificationTime = now;
await _data.StageVendorAuditAsync(new VendorAuditLog
{
VendorId = vendorId,
FieldName = nameof(Vendor.AvailabilityStatus),
OldValue = previous,
NewValue = normalized,
Actor = actor,
CreatedAt = now
}, cancellationToken);
await _data.SaveChangesAsync(cancellationToken);
return VendorOperationOutcome<VendorAvailabilityUpdateResult>.Ok(
new VendorAvailabilityUpdateResult { Id = vendor.Id, AvailabilityStatus = vendor.AvailabilityStatus, AvailabilityUpdatedAt = vendor.AvailabilityUpdatedAt });
}
public async Task<IReadOnlyList<SitePreferenceRow>> GetSitePreferencesAsync(int locationId, CancellationToken cancellationToken)
{
var preferences = await _data.GetSitePreferredVendorsAsync(locationId, cancellationToken);
return preferences.Select(p => new SitePreferenceRow
{
Id = p.Id,
LocationId = p.LocationId,
VendorId = p.VendorId,
VendorName = p.Vendor!.CompanyName,
Trade = p.Trade,
SortOrder = p.SortOrder,
IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false),
LastModificationTime = p.LastModificationTime,
UpdatedBy = p.UpdatedBy
}).ToList();
}
public async Task<VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>> ReplaceSitePreferencesAsync(
int locationId, IReadOnlyList<SitePreferenceRequest> body, string actor, CancellationToken cancellationToken)
{
if (!await _data.LocationExistsAsync(locationId, cancellationToken))
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.NotFound("Location not found");
if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1))
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.BadRequest("Duplicate vendor/trade preference");
var vendorIds = body.Select(item => item.VendorId).Distinct().ToList();
var vendors = await _data.GetVendorsByIdsAsync(vendorIds, cancellationToken);
foreach (var item in body)
{
if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false))
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.BadRequest($"Vendor {item.VendorId} is inactive or missing");
if (!string.IsNullOrWhiteSpace(item.Trade)
&& !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase))
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.BadRequest($"Vendor {item.VendorId} does not support trade '{item.Trade}'");
}
var now = UtcNow;
var existing = await _data.GetSitePreferredVendorsForReplaceAsync(locationId, cancellationToken);
await _data.RemoveSitePreferredVendorsAsync(existing, cancellationToken);
foreach (var (item, index) in body.Select((item, index) => (item, index)))
{
await _data.AddSitePreferredVendorAsync(new SitePreferredVendor
{
LocationId = locationId,
VendorId = item.VendorId,
Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(),
SortOrder = item.SortOrder ?? index,
CreatedDate = now,
LastModificationTime = now,
createdby = actor,
UpdatedBy = actor
}, cancellationToken);
}
foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct())
{
await _data.StageVendorAuditAsync(new VendorAuditLog
{
VendorId = vendorId,
FieldName = $"SitePreference:{locationId}",
OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")),
Actor = actor,
CreatedAt = now
}, cancellationToken);
}
await _data.SaveChangesAsync(cancellationToken);
return VendorOperationOutcome<IReadOnlyList<SitePreferenceRow>>.Ok(await GetSitePreferencesAsync(locationId, cancellationToken));
}
public async Task<VendorOperationOutcome<DocumentReviewResult>> ReviewDocumentAsync(
int documentId, DocumentReviewRequest body, string actor, CancellationToken cancellationToken)
{
if (body.ReviewStatus is not ("Approved" or "Rejected"))
return VendorOperationOutcome<DocumentReviewResult>.BadRequest("reviewStatus must be Approved or Rejected");
if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason))
return VendorOperationOutcome<DocumentReviewResult>.BadRequest("A rejection reason is required");
var document = await _data.GetCompletionDocumentForUpdateAsync(documentId, cancellationToken);
if (document == null)
return VendorOperationOutcome<DocumentReviewResult>.NotFound();
if (document.ScanStatus != "Passed")
return VendorOperationOutcome<DocumentReviewResult>.Conflict("A document cannot be approved or reviewed until malware scanning passes");
var now = UtcNow;
var previous = document.ReviewStatus;
document.ReviewStatus = body.ReviewStatus;
document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null;
document.ReviewedAt = now;
document.LastModificationTime = now;
await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog
{
WorkOrderId = document.WorkOrderId,
UserId = actor,
FieldName = $"Completion Document {document.OriginalFileName}",
OldValue = previous,
NewValue = document.ReviewStatus,
Action = "completion_document_reviewed",
CreatedAt = now
}, cancellationToken);
await _data.SaveChangesAsync(cancellationToken);
return VendorOperationOutcome<DocumentReviewResult>.Ok(
new DocumentReviewResult { Id = document.Id, ReviewStatus = document.ReviewStatus, RejectionReason = document.RejectionReason, ReviewedAt = document.ReviewedAt });
}
public async Task<VendorOperationOutcome<CommercialStatusResult>> UpdateCommercialStatusAsync(
int dispatchId, CommercialStatusRequest body, string actor, CancellationToken cancellationToken)
{
if (!InvoiceStatuses.Contains(body.InvoiceStatus))
return VendorOperationOutcome<CommercialStatusResult>.BadRequest("Invalid invoice status");
if (!PaymentStatuses.Contains(body.PaymentStatus))
return VendorOperationOutcome<CommercialStatusResult>.BadRequest("Invalid payment status");
var dispatch = await _data.GetDispatchForUpdateAsync(dispatchId, cancellationToken);
if (dispatch == null)
return VendorOperationOutcome<CommercialStatusResult>.NotFound();
var now = UtcNow;
var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}";
dispatch.InvoiceStatus = body.InvoiceStatus;
dispatch.PaymentStatus = body.PaymentStatus;
dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim();
dispatch.CommercialStatusUpdatedAt = now;
dispatch.LastModificationTime = now;
await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = actor,
FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status",
OldValue = oldValue,
NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}",
Action = "commercial_status_updated",
CreatedAt = now
}, cancellationToken);
await _data.SaveChangesAsync(cancellationToken);
return VendorOperationOutcome<CommercialStatusResult>.Ok(
new CommercialStatusResult
{
Id = dispatch.Id,
PONumber = dispatch.PONumber,
InvoiceNumber = dispatch.InvoiceNumber,
InvoiceStatus = dispatch.InvoiceStatus,
PaymentStatus = dispatch.PaymentStatus,
CommercialStatusUpdatedAt = dispatch.CommercialStatusUpdatedAt
});
}
public async Task<VendorOperationOutcome<VendorAssignmentResult>> AssignVendorAsync(
int workOrderId, VendorAssignmentRequest body, string actor, CancellationToken cancellationToken)
{
var workOrder = await _data.GetWorkOrderForUpdateAsync(workOrderId, cancellationToken);
if (workOrder == null)
return VendorOperationOutcome<VendorAssignmentResult>.NotFound("Work order not found");
var vendor = await _data.GetVendorForUpdateAsync(body.VendorId, cancellationToken);
if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false))
return VendorOperationOutcome<VendorAssignmentResult>.BadRequest("Only an active vendor can be assigned");
var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate;
var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2);
var activeDispatches = start.HasValue && end.HasValue
? (await _data.GetActiveScheduledDispatchesAsync(cancellationToken))
.Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId).ToList()
: new List<ScheduledDispatchReadModel>();
var conflicts = start.HasValue && end.HasValue
? activeDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList()
: new List<ScheduledDispatchReadModel>();
var dispatch = await _data.GetExistingActiveDispatchForWorkOrderAsync(workOrderId, cancellationToken);
var now = UtcNow;
var previousVendorId = dispatch?.VendorId;
if (dispatch == null)
{
dispatch = new Dispatch
{
WorkOrderId = workOrderId,
VendorId = vendor.Id,
DispatchNumber = await _data.GenerateNextDispatchNumberAsync(cancellationToken),
PONumber = await _data.GenerateNextPONumberAsync(cancellationToken),
Status = "Sent",
ScheduledDate = start,
EstimatedArrivalAt = body.EstimatedArrivalAt,
EtaManualOverride = body.EtaManualOverride,
DispatchedAt = now,
CreatedDate = now,
createdby = actor
};
await _data.AddDispatchAsync(dispatch, cancellationToken);
}
else
{
dispatch.VendorId = vendor.Id;
dispatch.ScheduledDate = start;
dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt;
dispatch.EtaManualOverride = body.EtaManualOverride;
dispatch.LastModificationTime = now;
}
workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart;
workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd;
workOrder.ScheduledDate = start ?? workOrder.ScheduledDate;
workOrder.LastModificationTime = now;
await _data.StageWorkOrderAuditAsync(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = actor,
FieldName = "Vendor Assignment",
OldValue = previousVendorId?.ToString(),
NewValue = vendor.Id.ToString(),
Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned",
CreatedAt = now
}, cancellationToken);
await _data.SaveChangesAsync(cancellationToken);
return VendorOperationOutcome<VendorAssignmentResult>.Ok(new VendorAssignmentResult
{
Id = dispatch.Id,
VendorId = dispatch.VendorId,
VendorName = vendor.CompanyName,
EstimatedArrivalAt = dispatch.EstimatedArrivalAt,
EtaManualOverride = dispatch.EtaManualOverride,
AvailabilityStatus = conflicts.Count > 0 ? "Unavailable"
: vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown",
Conflicts = conflicts.Select(conflict => new AvailabilityConflictDto
{
WorkOrderId = conflict.WorkOrderId,
WorkOrderNumber = conflict.WorkOrderNumber,
Start = conflict.Start,
End = conflict.End
}).ToList()
});
}
public async Task<IReadOnlyList<VendorInsightRow>> GetInsightRowsAsync(
DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId, CancellationToken cancellationToken)
{
var dispatches = await _data.GetInsightDispatchDataAsync(from, to, locationId, trade, vendorId, cancellationToken);
return dispatches.GroupBy(dispatch => dispatch.VendorId)
.Select(group =>
{
var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList();
var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused");
var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList();
var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.DueDate != null).ToList();
var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList();
return new VendorInsightRow
{
VendorId = group.Key,
Vendor = group.First().VendorName ?? $"Vendor {group.Key}",
CompletedJobs = completed.Count,
CancellationRefusalRate = Rate(canceled, group.Count()),
OnTimeArrivalRate = Rate(
acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate),
acknowledgedWithSchedule.Count),
OnTimeCompletionRate = Rate(
completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.DueDate),
completedWithDue.Count),
AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch =>
(dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours)
};
})
.OrderBy(row => row.Vendor)
.ToList();
}
private static IEnumerable<(ScheduledDispatchReadModel Left, ScheduledDispatchReadModel Right)> BuildConflictPairs(
IReadOnlyList<ScheduledDispatchReadModel> dispatches)
=> VendorConflictPairs.Build(dispatches);
refactor: enforce backend boundaries and optimize dispatch (#30) * refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd)
=> leftStart < rightEnd && rightStart < leftEnd;
private static decimal Rate(int numerator, int denominator)
=> denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4);
}
}