shoc-backend/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs
Alexandre Brandizzi cbecc7b4ea fix(workorders): persist manual POC override with audit and site-follow (SH-379)
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.

- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
  + WorkOrderPocDataService: persists the override, stages FieldChanged audit
  entries (which also write field locks so sync never overwrites a manual POC),
  and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
  stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
  stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
  fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
  WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
2026-09-18 14:30:54 -03:00

349 lines
14 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using System.Text.Json;
namespace SeaHaven.DataServices.Helpers
{
public static class WorkOrderBoardProjection
{
private static readonly JsonSerializerOptions SnapshotJsonOptions =
new(JsonSerializerDefaults.Web);
public static async Task<IReadOnlyList<WorkOrderBoardRawRow>> ProjectRowsAsync(
ApplicationDbContext context,
IQueryable<WorkOrder> query,
bool isUnscheduled,
CancellationToken cancellationToken = default)
{
var rows = await query
.Select(w => new
{
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.IsAddOn,
w.AvetaRequired,
HasAvetaDocument = w.workOrderAttachments!.Any(a =>
(a.IsDeleted == null || a.IsDeleted == false)
&& a.Category == WorkOrderMediaCategory.Aveta),
MediaCount = w.workOrderAttachments!.Count(a =>
a.IsDeleted == null || a.IsDeleted == false),
w.OriginalDate,
w.OriginalWeek,
w.WorkOrderType,
w.ServiceId,
w.ServiceNameSnapshot,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
ContactPoc = w.WorkOrderContacts!
.OrderBy(c => c.Id)
.Select(c => new
{
Name = c.POC != null
? ((c.POC.FirstName ?? "") + " " + (c.POC.LastName ?? "")).Trim()
: null,
PhoneNumber = c.POC != null ? c.POC.PhoneNumber : null,
c.Notes
})
.FirstOrDefault(),
// SH-379/SH-190: the Site's live primary contact backs the
// follow-the-site POC display when no override or link exists.
SitePoc = w.Locations != null
? w.Locations.Contacts!
.Where(c => c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => new
{
Name = ((c.FirstName ?? "") + " " + (c.LastName ?? "")).Trim(),
c.PhoneNumber
})
.FirstOrDefault()
: null,
w.LifecycleStatus,
w.LegacyStatus,
w.Status,
w.AssignTo,
w.AssignToUser!.FirstName,
w.AssignToUser!.LastName,
w.AssignToUser!.Initials,
w.AssignToUser!.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
PrimaryDispatchStatus = w.PrimaryDispatch != null ? w.PrimaryDispatch.Status : null,
VendorId = w.PrimaryDispatch != null ? (int?)w.PrimaryDispatch.VendorId : null,
VendorName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.CompanyName
: null,
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.ContactName
: null,
DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null,
WoTechPhone = w.TechPhone,
VendorPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
? w.PrimaryDispatch.Vendor.Phone
: null,
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
w.Trade,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.AdditionalContacts,
w.DocStatus,
w.CompletedDate,
w.FrozenSite,
w.FrozenCompany,
w.FrozenPoc,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null,
w.Severity
})
.ToListAsync(cancellationToken);
if (rows.Count == 0)
return Array.Empty<WorkOrderBoardRawRow>();
var upliftByWorkOrder = await LoadUpliftAggregatesAsync(context, query, cancellationToken);
return rows.Select(w =>
{
var frozenSite = w.LifecycleStatus == LifecycleStatus.Completed
? Deserialize<WorkOrderFrozenSite>(w.FrozenSite)
: null;
var frozenCompany = w.LifecycleStatus == LifecycleStatus.Completed
? Deserialize<WorkOrderFrozenCompany>(w.FrozenCompany)
: null;
var frozenPoc = w.LifecycleStatus == LifecycleStatus.Completed
? Deserialize<WorkOrderFrozenPoc>(w.FrozenPoc)
: null;
var primaryFrozenPoc = frozenPoc?.Contacts.FirstOrDefault();
var pocName = primaryFrozenPoc?.Name
?? FirstNotBlank(w.WoPocName, w.ContactPoc?.Name, w.SitePoc?.Name);
var pocPhone = primaryFrozenPoc?.Phone
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
var pocNotes = frozenPoc?.Notes
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
var (vendorId, vendorName) = WorkOrderBoardDispatchAssignment.LiveVendor(
w.VendorId,
w.VendorName,
w.PrimaryDispatchStatus);
vendorName = frozenCompany?.Name ?? vendorName;
upliftByWorkOrder.TryGetValue(w.Id, out var uplift);
return new WorkOrderBoardRawRow(
w.Id,
w.InternalWONumber,
w.RescheduleCount,
w.CarriedOver,
w.IsAddOn,
w.WorkOrderType,
w.ServiceId,
w.ServiceNameSnapshot,
frozenSite?.Code ?? w.SiteCode,
frozenSite?.Label ?? w.LocationName,
pocName,
pocPhone,
pocNotes,
w.LifecycleStatus,
w.LegacyStatus ?? w.Status,
w.AssignTo,
w.FirstName,
w.LastName,
w.Initials,
w.Color,
w.DueDate,
w.ScheduledDate,
w.ScheduledStart,
w.ScheduledEnd,
w.TargetWeek,
w.ScheduleWeekOnly,
vendorId,
vendorName,
frozenCompany?.ContactName ?? w.TechName,
frozenCompany?.Phone ?? techPhone,
w.DispatchApptDate,
w.Trade ?? w.Problem,
w.Problem,
w.ServiceNotes,
w.ExtraServices,
w.AdditionalContacts,
w.DocStatus,
w.CompletedDate,
w.FlagColor,
w.PrimaryDispatchId,
w.RowVersion,
w.DispatchRowVersion,
uplift?.PendingCount ?? 0,
uplift?.HasUplift ?? false,
uplift?.PrimaryStatus,
uplift?.PrimaryAmount,
w.PrimaryDispatchStatus,
w.AvetaRequired,
w.HasAvetaDocument,
w.OriginalDate,
w.OriginalWeek,
isUnscheduled,
w.Severity,
w.MediaCount,
frozenSite,
frozenCompany,
frozenPoc,
w.WoPocName,
w.WoPocPhone);
}).ToList();
}
private static string? FirstNotBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static T? Deserialize<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return default;
try
{
return JsonSerializer.Deserialize<T>(json, SnapshotJsonOptions);
}
catch (JsonException)
{
return default;
}
}
/// <summary>
/// SH-188: one round-trip for live uplifts on WOs in <paramref name="workOrders"/>
/// (primary WorkOrderId and multi-WO DispatchWorkOrders links). Uses the same
/// IQueryable as a SQL subquery so large boards do not hit the 2100-parameter cap.
/// </summary>
private static async Task<Dictionary<int, BoardUpliftAgg>> LoadUpliftAggregatesAsync(
ApplicationDbContext context,
IQueryable<WorkOrder> workOrders,
CancellationToken cancellationToken)
{
var workOrderIds = workOrders.Select(w => w.Id);
var live = context.DispatchUpliftRequests
.AsNoTracking()
.Where(u =>
(u.IsDeleted == null || u.IsDeleted == false)
&& u.Dispatch != null
&& (u.Dispatch.IsDeleted == null || u.Dispatch.IsDeleted == false));
var fromPrimary = live
.Where(u =>
u.Dispatch!.WorkOrderId != null
&& workOrderIds.Contains(u.Dispatch.WorkOrderId.Value))
.Select(u => new
{
UpliftId = u.Id,
WorkOrderId = u.Dispatch!.WorkOrderId!.Value,
u.Status,
u.RequestedNTE,
u.CreatedDate
});
var fromLinks = live
.SelectMany(
u => u.Dispatch!.DispatchWorkOrders!
.Where(link => workOrderIds.Contains(link.WorkOrderId)),
(u, link) => new
{
UpliftId = u.Id,
WorkOrderId = link.WorkOrderId,
u.Status,
u.RequestedNTE,
u.CreatedDate
});
var links = await fromPrimary.Concat(fromLinks).ToListAsync(cancellationToken);
return AggregateUpliftLinks(links.Select(l => new UpliftLinkRow(
l.UpliftId,
l.WorkOrderId,
l.Status,
l.RequestedNTE,
l.CreatedDate)));
}
private static Dictionary<int, BoardUpliftAgg> AggregateUpliftLinks(IEnumerable<UpliftLinkRow> links)
{
var seen = new HashSet<(int UpliftId, int WorkOrderId)>();
var byWorkOrder = new Dictionary<int, List<UpliftLinkRow>>();
foreach (var link in links)
{
if (!seen.Add((link.UpliftId, link.WorkOrderId)))
continue;
if (!byWorkOrder.TryGetValue(link.WorkOrderId, out var group))
{
group = new List<UpliftLinkRow>();
byWorkOrder[link.WorkOrderId] = group;
}
group.Add(link);
}
var result = new Dictionary<int, BoardUpliftAgg>(byWorkOrder.Count);
foreach (var (workOrderId, group) in byWorkOrder)
{
var pendingCount = 0;
UpliftLinkRow? primary = null;
foreach (var item in group)
{
if (item.Status == "Pending")
pendingCount++;
if (primary == null || IsNewer(item.CreatedDate, primary.CreatedDate))
primary = item;
}
result[workOrderId] = new BoardUpliftAgg(
pendingCount,
HasUplift: true,
primary?.Status,
primary?.RequestedNTE);
}
return result;
}
private static bool IsNewer(DateTime? candidate, DateTime? current)
{
if (candidate == current)
return false;
if (candidate is null)
return false;
if (current is null)
return true;
return candidate.Value > current.Value;
}
private sealed record UpliftLinkRow(
int UpliftId,
int WorkOrderId,
string Status,
decimal RequestedNTE,
DateTime? CreatedDate);
private sealed record BoardUpliftAgg(
int PendingCount,
bool HasUplift,
string? PrimaryStatus,
decimal? PrimaryAmount);
}
}