mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 20:03:11 +00:00
- Reject duplicate site codes per client (case-insensitive); site code is immutable once set
- Delete tombstones the site and requires the DeleteSites permission (Admin, Scheduler)
- GET /api/locations/{id}/open-work-orders returns the open count and ids
- PATCH /api/locations/{id}/contact-info saves contacts and notes from the work-order Site dialog
- Add nullable Locations.Notes, used as the site-level POC notes fallback
354 lines
14 KiB
C#
354 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,
|
|
SiteNotes = w.Locations != null ? w.Locations.Notes : 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, w.SiteNotes);
|
|
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.CurrentNTE,
|
|
u.createdby,
|
|
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.CurrentNTE,
|
|
u.createdby,
|
|
u.CreatedDate
|
|
});
|
|
|
|
var links = await fromPrimary.Concat(fromLinks).ToListAsync(cancellationToken);
|
|
return AggregateUpliftLinks(links.Select(l => new UpliftLinkRow(
|
|
l.UpliftId,
|
|
l.WorkOrderId,
|
|
l.Status,
|
|
UpliftAmount.Of(l.createdby, l.RequestedNTE, l.CurrentNTE),
|
|
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?.Amount);
|
|
}
|
|
|
|
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 Amount,
|
|
DateTime? CreatedDate);
|
|
|
|
private sealed record BoardUpliftAgg(
|
|
int PendingCount,
|
|
bool HasUplift,
|
|
string? PrimaryStatus,
|
|
decimal? PrimaryAmount);
|
|
}
|
|
}
|