mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 16:33:12 +00:00
feat(work-orders): add structured service notes POC and vendor fields (#21)
* fix(migrations): make Phase6 completion slide-over migration idempotent Use IF EXISTS / IF COL_LENGTH guards and explicit EF migration metadata so partial environments can apply Phase 6 safely. * feat(locations): add sites options endpoint and vendor contactName Expose GET /api/locations/sites for the WO create wizard and include vendor contactName on dropdown responses. * feat(work-orders): add structured service notes POC and vendor fields Persist primary/extra services, service notes, free-text POC, and tech/vendor notes on create/patch/board/detail instead of embedding them in description. * fix(work-orders): make stacked tip build for CI quality gates * fix(work-orders): align stacked services with CI build * fix(work-orders): keep FlagColor out of structured-notes slice Remove FlagColor model/API/tests from this PR so schema and EF model stay aligned with the structured-fields migration. FlagColor remains in the follow-on PR that adds its migration. --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
This commit is contained in:
parent
8c4102b1cf
commit
f4985378fc
24 changed files with 982 additions and 62 deletions
|
|
@ -0,0 +1,121 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260715160000_WoServiceNotesStructuredFields")]
|
||||
public partial class WoServiceNotesStructuredFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PocName",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PocPhone",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PocNotes",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExtraServices",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ServiceNotes",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TechPhone",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VendorNotes",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TechPhone",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(50)",
|
||||
maxLength: 50,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VendorNotes",
|
||||
table: "Dispatches",
|
||||
type: "nvarchar(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PocName",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PocPhone",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PocNotes",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExtraServices",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ServiceNotes",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TechPhone",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorNotes",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TechPhone",
|
||||
table: "Dispatches");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorNotes",
|
||||
table: "Dispatches");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -739,9 +739,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Status")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TechPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("VendorNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -1963,6 +1971,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ExtraServices")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("InternalWONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -1996,6 +2008,18 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PO")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PocName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("PocNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("PocPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("PrimaryDispatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
|
@ -2029,6 +2053,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ServiceNotes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -2062,12 +2090,20 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateOnly?>("TargetWeek")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("TechPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("VendorNTE")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("VendorNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ namespace Data.SeaHavenIndustries
|
|||
public string? VerifiedBy { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>Tech/crew phone for this dispatch (overrides Vendor.Phone on board).</summary>
|
||||
[MaxLength(50)]
|
||||
public string? TechPhone { get; set; }
|
||||
/// <summary>Instructions for the vendor for this appointment/dispatch.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? VendorNotes { get; set; }
|
||||
|
||||
[Timestamp]
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,25 @@ namespace Data.SeaHavenIndustries
|
|||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||
|
||||
// --- Service & Notes / POC (structured create) ---
|
||||
[MaxLength(200)]
|
||||
public string? PocName { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string? PocPhone { get; set; }
|
||||
[MaxLength(2000)]
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? ExtraServices { get; set; }
|
||||
[MaxLength(4000)]
|
||||
public string? ServiceNotes { get; set; }
|
||||
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
|
||||
[MaxLength(50)]
|
||||
public string? TechPhone { get; set; }
|
||||
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? VendorNotes { get; set; }
|
||||
|
||||
// --- Scheduling slice ---
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.WorkOrderType,
|
||||
w.SiteCode,
|
||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||
Poc = w.WorkOrderContacts!
|
||||
WoPocName = w.PocName,
|
||||
WoPocPhone = w.PocPhone,
|
||||
WoPocNotes = w.PocNotes,
|
||||
ContactPoc = w.WorkOrderContacts!
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new
|
||||
{
|
||||
|
|
@ -52,12 +55,16 @@ namespace SeaHaven.DataServices.Helpers
|
|||
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||
? w.PrimaryDispatch.Vendor.ContactName
|
||||
: null,
|
||||
TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != 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.DocStatus,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
|
|
@ -65,42 +72,54 @@ namespace SeaHaven.DataServices.Helpers
|
|||
})
|
||||
.ToListAsync();
|
||||
|
||||
return rows.Select(w => new WorkOrderBoardRawRow(
|
||||
w.Id,
|
||||
w.InternalWONumber,
|
||||
w.RescheduleCount,
|
||||
w.CarriedOver,
|
||||
w.WorkOrderType,
|
||||
w.SiteCode,
|
||||
w.LocationName,
|
||||
w.Poc?.Name,
|
||||
w.Poc?.PhoneNumber,
|
||||
w.Poc?.Notes,
|
||||
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,
|
||||
w.VendorId,
|
||||
w.VendorName,
|
||||
w.TechName,
|
||||
w.TechPhone,
|
||||
w.DispatchApptDate,
|
||||
w.Trade ?? w.Problem,
|
||||
w.Problem,
|
||||
w.DocStatus,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
w.DispatchRowVersion,
|
||||
isUnscheduled)).ToList();
|
||||
return rows.Select(w =>
|
||||
{
|
||||
var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name;
|
||||
var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber;
|
||||
var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes;
|
||||
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
|
||||
? w.DispatchTechPhone
|
||||
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
|
||||
|
||||
return new WorkOrderBoardRawRow(
|
||||
w.Id,
|
||||
w.InternalWONumber,
|
||||
w.RescheduleCount,
|
||||
w.CarriedOver,
|
||||
w.WorkOrderType,
|
||||
w.SiteCode,
|
||||
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,
|
||||
w.VendorId,
|
||||
w.VendorName,
|
||||
w.TechName,
|
||||
techPhone,
|
||||
w.DispatchApptDate,
|
||||
w.Trade ?? w.Problem,
|
||||
w.Problem,
|
||||
w.ServiceNotes,
|
||||
w.ExtraServices,
|
||||
w.DocStatus,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
w.DispatchRowVersion,
|
||||
isUnscheduled);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,5 +33,27 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
return await _context.Users.AnyAsync(u => u.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids)
|
||||
{
|
||||
var idList = ids
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (idList.Count == 0)
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
var users = await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => idList.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FirstName, u.LastName })
|
||||
.ToListAsync();
|
||||
|
||||
return users.ToDictionary(
|
||||
u => u.Id,
|
||||
u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,12 @@ namespace SeaHaven.DataServices.Implementation
|
|||
w.CompletedDate,
|
||||
w.SignOffName,
|
||||
w.SignOffAttachment,
|
||||
w.SignOffSignature))
|
||||
w.SignOffSignature,
|
||||
w.ExtraServices,
|
||||
w.ServiceNotes,
|
||||
w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null
|
||||
? w.PrimaryDispatch.VendorNotes
|
||||
: w.VendorNotes))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<IEnumerable<ApplicationUser>> GetAllAsync();
|
||||
Task<ApplicationUser?> GetByEmailAsync(string email);
|
||||
Task<bool> ExistsAsync(string id);
|
||||
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
DateTime? DispatchApptDate,
|
||||
string? Trade,
|
||||
string? Problem,
|
||||
string? ServiceNotes,
|
||||
string? ExtraServices,
|
||||
DocStatus? DocStatus,
|
||||
int? PrimaryDispatchId,
|
||||
byte[]? RowVersion,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
DateTime? CompletedDate,
|
||||
string? SignOffName,
|
||||
string? SignOffAttachment,
|
||||
string? SignOffSignature);
|
||||
string? SignOffSignature,
|
||||
string? ExtraServices,
|
||||
string? ServiceNotes,
|
||||
string? VendorNotes);
|
||||
|
||||
public record DispatchSignoffRow(
|
||||
int DispatchId,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? ApptDate { get; set; }
|
||||
public string? ApptTime { get; set; }
|
||||
public string? Pm { get; set; }
|
||||
public string? ServiceNotes { get; set; }
|
||||
public List<string>? ExtraServices { get; set; }
|
||||
public DocStatus? DocStatus { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
|
|
|||
|
|
@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers;
|
|||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
/// <summary>Payload for creating a board work order (SHOC create wizard).</summary>
|
||||
/// <summary>
|
||||
/// Payload for creating a board work order (SHOC create wizard).
|
||||
/// FE follow-up: extend WorkOrderBoardCreatePayload + mapper with PrimaryService,
|
||||
/// ExtraServices, ServiceNotes, PocName, PocPhone, VendorNotes, TechPhone; stop embedding
|
||||
/// POC/vendor/service notes in description (pocNotes already exists). Prefer structured
|
||||
/// fields; keep Trade+Description temporarily for legacy clients.
|
||||
/// </summary>
|
||||
public class WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
public string? WoNumber { get; set; }
|
||||
public WorkOrderType WorkOrderType { get; set; }
|
||||
public string? SiteCode { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
|
||||
public string? Trade { get; set; }
|
||||
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
|
||||
public string? PrimaryService { get; set; }
|
||||
public List<string>? ExtraServices { get; set; }
|
||||
public string? ServiceNotes { get; set; }
|
||||
public int? LocationId { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string? AssignTo { get; set; }
|
||||
|
|
@ -22,14 +33,18 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? ApptDate { get; set; }
|
||||
public string? ApptTime { get; set; }
|
||||
public int? PocContactId { get; set; }
|
||||
public string? PocName { get; set; }
|
||||
public string? PocPhone { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
public string? TechPhone { get; set; }
|
||||
public string? VendorNotes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Advanced search / filter query for the board.</summary>
|
||||
public class WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 50;
|
||||
public int Page { get; set; } = 0;
|
||||
public int PageSize { get; set; } = 100;
|
||||
public string? Search { get; set; }
|
||||
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
||||
public DateOnly? DateFrom { get; set; }
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateOnly? OriginalWeek { get; set; }
|
||||
public DateOnly? OriginalDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? VendorNotes { get; set; }
|
||||
}
|
||||
|
||||
public class CompletionDocTemplateDto
|
||||
|
|
@ -63,6 +64,7 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public int Id { get; set; }
|
||||
public string? AuthorId { get; set; }
|
||||
public string? AuthorName { get; set; }
|
||||
public string Text { get; set; } = "";
|
||||
public string Time { get; set; } = "";
|
||||
public string? Documents { get; set; }
|
||||
|
|
|
|||
|
|
@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers
|
|||
{
|
||||
public static class WorkOrderAuditProjection
|
||||
{
|
||||
public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log)
|
||||
private const string UnassignedLabel = "Unassigned";
|
||||
|
||||
public static WorkOrderAuditEntryDto ToDto(
|
||||
WorkOrderAuditLog log,
|
||||
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
|
||||
{
|
||||
var oldValue = log.OldValue ?? "";
|
||||
var newValue = log.NewValue ?? "";
|
||||
|
||||
if (IsAssignToField(log.FieldName))
|
||||
{
|
||||
oldValue = ResolveAssignToDisplay(oldValue, assignToDisplayNames);
|
||||
newValue = ResolveAssignToDisplay(newValue, assignToDisplayNames);
|
||||
}
|
||||
|
||||
return new WorkOrderAuditEntryDto
|
||||
{
|
||||
Type = MapEventType(log.EventType),
|
||||
DispatcherId = log.UserId,
|
||||
Action = log.Action ?? "",
|
||||
FieldName = log.FieldName,
|
||||
OldValue = log.OldValue ?? "",
|
||||
NewValue = log.NewValue ?? "",
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Time = log.CreatedAt.ToString("o"),
|
||||
DispatchId = log.DispatchId
|
||||
};
|
||||
}
|
||||
|
||||
public static List<WorkOrderAuditEntryDto> ToDtos(IEnumerable<WorkOrderAuditLog> logs)
|
||||
=> logs.Select(ToDto).ToList();
|
||||
public static List<WorkOrderAuditEntryDto> ToDtos(
|
||||
IEnumerable<WorkOrderAuditLog> logs,
|
||||
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
|
||||
=> logs.Select(log => ToDto(log, assignToDisplayNames)).ToList();
|
||||
|
||||
public static IEnumerable<string> CollectAssignToUserIds(IEnumerable<WorkOrderAuditLog> logs)
|
||||
{
|
||||
return logs
|
||||
.Where(l => IsAssignToField(l.FieldName))
|
||||
.SelectMany(l => new[] { l.OldValue, l.NewValue })
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v!)
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal static bool IsAssignToField(string? fieldName)
|
||||
=> string.Equals(fieldName, "AssignTo", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(fieldName, "AssignedTo", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string ResolveAssignToDisplay(
|
||||
string value,
|
||||
IReadOnlyDictionary<string, string>? assignToDisplayNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return UnassignedLabel;
|
||||
|
||||
if (assignToDisplayNames != null
|
||||
&& assignToDisplayNames.TryGetValue(value, out var name)
|
||||
&& !string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string MapEventType(string? eventType)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ namespace SeaHaven.Services.Helpers
|
|||
public const string ApptDate = "apptDate";
|
||||
public const string ApptTime = "apptTime";
|
||||
public const string DocStatus = "docStatus";
|
||||
public const string Pm = "pm";
|
||||
public const string ServiceNotes = "serviceNotes";
|
||||
public const string ExtraServices = "extraServices";
|
||||
public const string RescheduleCount = "RescheduleCount";
|
||||
|
||||
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
|
|
@ -38,7 +41,10 @@ namespace SeaHaven.Services.Helpers
|
|||
VendorId,
|
||||
ApptDate,
|
||||
ApptTime,
|
||||
DocStatus
|
||||
DocStatus,
|
||||
Pm,
|
||||
ServiceNotes,
|
||||
ExtraServices
|
||||
};
|
||||
|
||||
public static bool IsEditable(string field) => Canonicalize(field) != null;
|
||||
|
|
@ -75,6 +81,9 @@ namespace SeaHaven.Services.Helpers
|
|||
ApptDate => "ApptDate",
|
||||
ApptTime => "ApptTime",
|
||||
DocStatus => "DocStatus",
|
||||
Pm => "Trade",
|
||||
ServiceNotes => "ServiceNotes",
|
||||
ExtraServices => "ExtraServices",
|
||||
_ => canonical
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers
|
|||
{
|
||||
public static class WorkOrderCommentProjection
|
||||
{
|
||||
public static WorkOrderCommentDto ToDto(Comments comment)
|
||||
public static WorkOrderCommentDto ToDto(
|
||||
Comments comment,
|
||||
IReadOnlyDictionary<string, string>? displayNames = null)
|
||||
{
|
||||
return new WorkOrderCommentDto
|
||||
{
|
||||
Id = comment.Id,
|
||||
AuthorId = comment.UserId,
|
||||
AuthorName = ResolveAuthorName(comment, displayNames),
|
||||
Text = comment.Commenttext ?? "",
|
||||
Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"),
|
||||
Documents = comment.Documents
|
||||
};
|
||||
}
|
||||
|
||||
public static List<WorkOrderCommentDto> ToDtos(IEnumerable<Comments> comments)
|
||||
=> comments.Select(ToDto).ToList();
|
||||
public static List<WorkOrderCommentDto> ToDtos(
|
||||
IEnumerable<Comments> comments,
|
||||
IReadOnlyDictionary<string, string>? displayNames = null)
|
||||
=> comments.Select(c => ToDto(c, displayNames)).ToList();
|
||||
|
||||
public static IEnumerable<string> CollectAuthorUserIds(IEnumerable<Comments> comments)
|
||||
=> comments
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.UserId))
|
||||
.Select(c => c.UserId!)
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
|
||||
private static string? ResolveAuthorName(
|
||||
Comments comment,
|
||||
IReadOnlyDictionary<string, string>? displayNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(comment.UserId)
|
||||
&& displayNames != null
|
||||
&& displayNames.TryGetValue(comment.UserId, out var name)
|
||||
&& !string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(comment.Commenter))
|
||||
return comment.Commenter.Trim();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Text.Json;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentValidation;
|
||||
|
|
@ -41,6 +42,24 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
||||
var siteCode = request.SiteCode!.Trim();
|
||||
var primaryService = ResolvePrimaryService(request);
|
||||
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
||||
var pocName = TrimOrNull(request.PocName);
|
||||
var pocPhone = TrimOrNull(request.PocPhone);
|
||||
var pocNotes = TrimOrNull(request.PocNotes);
|
||||
var techPhone = TrimOrNull(request.TechPhone);
|
||||
var vendorNotes = TrimOrNull(request.VendorNotes);
|
||||
var hasVendorDispatch = request.VendorId.HasValue
|
||||
|| request.ApptDate.HasValue
|
||||
|| !string.IsNullOrWhiteSpace(request.ApptTime);
|
||||
|
||||
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
||||
{
|
||||
var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == request.VendorId.Value && v.IsDeleted != true);
|
||||
if (!vendorExists)
|
||||
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
||||
}
|
||||
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
|
|
@ -49,7 +68,12 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderType = request.WorkOrderType,
|
||||
SiteCode = siteCode,
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(),
|
||||
Trade = primaryService,
|
||||
ExtraServices = extraServicesJson,
|
||||
ServiceNotes = serviceNotes,
|
||||
PocName = pocName,
|
||||
PocPhone = pocPhone,
|
||||
PocNotes = pocNotes,
|
||||
LocationId = request.LocationId,
|
||||
DueDate = request.DueDate?.Date,
|
||||
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
|
||||
|
|
@ -61,6 +85,12 @@ namespace SeaHaven.Services.Implementation
|
|||
istemplate = false
|
||||
};
|
||||
|
||||
if (!hasVendorDispatch)
|
||||
{
|
||||
workOrder.TechPhone = techPhone;
|
||||
workOrder.VendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
||||
|
||||
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
||||
|
|
@ -75,6 +105,20 @@ namespace SeaHaven.Services.Implementation
|
|||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||
if (workOrder.Trade != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
||||
if (workOrder.ExtraServices != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
||||
if (workOrder.ServiceNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes));
|
||||
if (workOrder.PocName != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName));
|
||||
if (workOrder.PocPhone != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone));
|
||||
if (workOrder.PocNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes));
|
||||
if (workOrder.TechPhone != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone));
|
||||
if (workOrder.VendorNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", workOrder.VendorNotes));
|
||||
if (workOrder.LocationId.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
|
||||
if (workOrder.DueDate.HasValue)
|
||||
|
|
@ -95,7 +139,7 @@ namespace SeaHaven.Services.Implementation
|
|||
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
|
||||
|
||||
Dispatch? dispatch = null;
|
||||
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
|
||||
if (hasVendorDispatch)
|
||||
{
|
||||
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
|
||||
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
|
||||
|
|
@ -105,7 +149,9 @@ namespace SeaHaven.Services.Implementation
|
|||
VendorId = request.VendorId.Value,
|
||||
Status = "Pending",
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
ScheduledDate = request.ApptDate?.Date
|
||||
ScheduledDate = request.ApptDate?.Date,
|
||||
TechPhone = techPhone,
|
||||
VendorNotes = vendorNotes
|
||||
};
|
||||
_context.Dispatches.Add(dispatch);
|
||||
workOrder.PrimaryDispatch = dispatch;
|
||||
|
|
@ -120,6 +166,11 @@ namespace SeaHaven.Services.Implementation
|
|||
if (apptChange.HasChanged)
|
||||
changes.Add(apptChange);
|
||||
}
|
||||
|
||||
if (techPhone != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", techPhone));
|
||||
if (vendorNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes));
|
||||
}
|
||||
|
||||
_context.workOrders.Add(workOrder);
|
||||
|
|
@ -140,7 +191,7 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
WorkorderId = workOrder.Id,
|
||||
ContactId = request.PocContactId,
|
||||
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
|
||||
Notes = pocNotes
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +201,51 @@ namespace SeaHaven.Services.Implementation
|
|||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||
}
|
||||
|
||||
private static string? ResolvePrimaryService(WorkOrderBoardCreateRequestDto request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.PrimaryService))
|
||||
return request.PrimaryService.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Trade))
|
||||
return request.Trade.Trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? SerializeExtraServices(List<string>? extras)
|
||||
{
|
||||
if (extras == null || extras.Count == 0)
|
||||
return null;
|
||||
|
||||
var normalized = new List<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
if (normalized.Count == 0)
|
||||
return null;
|
||||
|
||||
var json = JsonSerializer.Serialize(normalized);
|
||||
if (json.Length > 2000)
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
"extraServices must serialize to at most 2000 characters.");
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private async Task<string> ResolveWoNumberAsync(string? requested)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requested))
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ namespace SeaHaven.Services.Implementation
|
|||
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
||||
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
||||
Pm = row.Trade ?? row.Problem,
|
||||
ServiceNotes = row.ServiceNotes,
|
||||
ExtraServices = ParseExtraServices(row.ExtraServices),
|
||||
DocStatus = row.DocStatus,
|
||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||
RowVersion = row.RowVersion,
|
||||
|
|
@ -124,5 +126,21 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
private static string FormatName(string? firstName, string? lastName)
|
||||
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
||||
|
||||
private static List<string>? ParseExtraServices(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||
return list is { Count: > 0 } ? list : null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Text.Json;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -157,6 +158,9 @@ namespace SeaHaven.Services.Implementation
|
|||
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) },
|
||||
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) },
|
||||
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
|
||||
};
|
||||
}
|
||||
|
|
@ -222,6 +226,11 @@ namespace SeaHaven.Services.Implementation
|
|||
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 scheduledDate (isPastDue).");
|
||||
|
||||
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
||||
|
||||
|
|
@ -248,6 +257,89 @@ namespace SeaHaven.Services.Implementation
|
|||
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 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 FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly ICompletionDocTemplateDataService _templateData;
|
||||
private readonly IUserDataService _userDataService;
|
||||
|
||||
public WorkOrderDetailService(
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderDetailDataService detailData,
|
||||
ICompletionDocTemplateDataService templateData)
|
||||
ICompletionDocTemplateDataService templateData,
|
||||
IUserDataService userDataService)
|
||||
{
|
||||
_boardService = boardService;
|
||||
_detailData = detailData;
|
||||
_templateData = templateData;
|
||||
_userDataService = userDataService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
|
||||
|
|
@ -41,12 +44,15 @@ namespace SeaHaven.Services.Implementation
|
|||
extended?.Trade ?? boardRow.Pm,
|
||||
boardRow.WorkOrderType);
|
||||
|
||||
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
|
||||
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
|
||||
|
||||
return new WorkOrderDetailDto
|
||||
{
|
||||
Info = MapInfo(boardRow, extended),
|
||||
Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs),
|
||||
Comments = WorkOrderCommentProjection.ToDtos(comments),
|
||||
Audit = WorkOrderAuditProjection.ToDtos(audit),
|
||||
Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames),
|
||||
Audit = await MapAuditAsync(audit),
|
||||
Media = workOrder == null
|
||||
? new List<WorkOrderMediaFileDto>()
|
||||
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
|
||||
|
|
@ -59,7 +65,14 @@ namespace SeaHaven.Services.Implementation
|
|||
return null;
|
||||
|
||||
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
|
||||
return WorkOrderAuditProjection.ToDtos(logs);
|
||||
return await MapAuditAsync(logs);
|
||||
}
|
||||
|
||||
private async Task<List<WorkOrderAuditEntryDto>> MapAuditAsync(IReadOnlyList<Data.SeaHavenIndustries.WorkOrderAuditLog> logs)
|
||||
{
|
||||
var userIds = WorkOrderAuditProjection.CollectAssignToUserIds(logs);
|
||||
var displayNames = await _userDataService.GetDisplayNamesByIdsAsync(userIds);
|
||||
return WorkOrderAuditProjection.ToDtos(logs, displayNames);
|
||||
}
|
||||
|
||||
private static WorkOrderDetailInfoDto MapInfo(
|
||||
|
|
@ -98,6 +111,8 @@ namespace SeaHaven.Services.Implementation
|
|||
ApptDate = row.ApptDate,
|
||||
ApptTime = row.ApptTime,
|
||||
Pm = row.Pm,
|
||||
ServiceNotes = extended?.ServiceNotes ?? row.ServiceNotes,
|
||||
ExtraServices = ParseExtraServices(extended?.ExtraServices) ?? row.ExtraServices,
|
||||
DocStatus = row.DocStatus,
|
||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||
RowVersion = row.RowVersion,
|
||||
|
|
@ -108,10 +123,27 @@ namespace SeaHaven.Services.Implementation
|
|||
SubTrade = extended?.SubTrade,
|
||||
OriginalWeek = extended?.OriginalWeek,
|
||||
OriginalDate = extended?.OriginalDate,
|
||||
CompletedDate = extended?.CompletedDate
|
||||
CompletedDate = extended?.CompletedDate,
|
||||
VendorNotes = extended?.VendorNotes
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string>? ParseExtraServices(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||
return list is { Count: > 0 } ? list : null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkOrderCompletionDto MapCompletion(
|
||||
WorkOrderDetailExtendedFields? extended,
|
||||
Data.SeaHavenIndustries.Enums.DocStatus? docStatus,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,34 @@ namespace SeaHaven.Services.Validation
|
|||
.MaximumLength(128)
|
||||
.When(x => !string.IsNullOrEmpty(x.Trade));
|
||||
|
||||
RuleFor(x => x.PrimaryService)
|
||||
.MaximumLength(128)
|
||||
.When(x => !string.IsNullOrEmpty(x.PrimaryService));
|
||||
|
||||
RuleFor(x => x.ServiceNotes)
|
||||
.MaximumLength(4000)
|
||||
.When(x => !string.IsNullOrEmpty(x.ServiceNotes));
|
||||
|
||||
RuleFor(x => x.PocName)
|
||||
.MaximumLength(200)
|
||||
.When(x => !string.IsNullOrEmpty(x.PocName));
|
||||
|
||||
RuleFor(x => x.PocPhone)
|
||||
.MaximumLength(50)
|
||||
.When(x => !string.IsNullOrEmpty(x.PocPhone));
|
||||
|
||||
RuleFor(x => x.PocNotes)
|
||||
.MaximumLength(2000)
|
||||
.When(x => !string.IsNullOrEmpty(x.PocNotes));
|
||||
|
||||
RuleFor(x => x.TechPhone)
|
||||
.MaximumLength(50)
|
||||
.When(x => !string.IsNullOrEmpty(x.TechPhone));
|
||||
|
||||
RuleFor(x => x.VendorNotes)
|
||||
.MaximumLength(2000)
|
||||
.When(x => !string.IsNullOrEmpty(x.VendorNotes));
|
||||
|
||||
RuleFor(x => x.LocationId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.LocationId.HasValue);
|
||||
|
|
@ -41,6 +69,26 @@ namespace SeaHaven.Services.Validation
|
|||
RuleFor(x => x.PocContactId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.PocContactId.HasValue);
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(HasPrimaryWhenExtras)
|
||||
.WithMessage("primaryService (or trade) is required when extraServices is provided.")
|
||||
.WithName("extraServices");
|
||||
|
||||
RuleForEach(x => x.ExtraServices)
|
||||
.MaximumLength(128)
|
||||
.When(x => x.ExtraServices != null);
|
||||
}
|
||||
|
||||
private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request)
|
||||
{
|
||||
var hasExtras = request.ExtraServices != null
|
||||
&& request.ExtraServices.Any(s => !string.IsNullOrWhiteSpace(s));
|
||||
if (!hasExtras)
|
||||
return true;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(request.PrimaryService)
|
||||
|| !string.IsNullOrWhiteSpace(request.Trade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,4 +180,173 @@ public class WorkOrderBoardCreateServiceTests
|
|||
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
|
||||
Assert.True(result.Id > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_StructuredServiceNotes_PersistsAndReturnsPm()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
PrimaryService = "HVAC PM",
|
||||
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
|
||||
ServiceNotes = "Unit on roof"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("HVAC PM", result.Pm);
|
||||
Assert.Equal("Unit on roof", result.ServiceNotes);
|
||||
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("HVAC PM", wo.Trade);
|
||||
Assert.Equal("Unit on roof", wo.ServiceNotes);
|
||||
Assert.Contains("Filter change", wo.ExtraServices);
|
||||
Assert.Contains("Coil clean", wo.ExtraServices);
|
||||
Assert.Equal(1, System.Text.Json.JsonSerializer.Deserialize<List<string>>(wo.ExtraServices!)!
|
||||
.Count(s => s == "Filter change"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PrimaryServiceTakesPrecedenceOverTrade()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.Reactive,
|
||||
SiteCode = "CHI",
|
||||
Trade = "Legacy Trade",
|
||||
PrimaryService = "Plumbing"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("Plumbing", result.Pm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ExtraServicesWithoutPrimary_ThrowsValidation()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
ExtraServices = new List<string> { "Filter change" }
|
||||
}, "actor-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PocFreeText_WithoutContact_AppearsOnBoardRow()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.Reactive,
|
||||
SiteCode = "BK5",
|
||||
PocName = "Jane Site Lead",
|
||||
PocPhone = "+1 555-0100",
|
||||
PocNotes = "Call 30 min before"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("Jane Site Lead", result.PocName);
|
||||
Assert.Equal("+1 555-0100", result.PocPhone);
|
||||
Assert.Equal("Call 30 min before", result.PocNotes);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("Jane Site Lead", wo.PocName);
|
||||
Assert.Empty(context.WorkOrderContacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutVendor_PersistsTechPhoneAndVendorNotesOnWorkOrder()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
TechPhone = "+1 555-0199",
|
||||
VendorNotes = "Gate code 4421"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("+1 555-0199", result.TechPhone);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("+1 555-0199", wo.TechPhone);
|
||||
Assert.Equal("Gate code 4421", wo.VendorNotes);
|
||||
Assert.Null(wo.PrimaryDispatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithVendor_PersistsTechPhoneAndVendorNotesOnDispatch()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.Add(new Vendor { Id = 55, CompanyName = "Acme", Phone = "+1 555-0000" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
VendorId = 55,
|
||||
ApptDate = new DateTime(2026, 7, 18),
|
||||
TechPhone = "+1 555-0199",
|
||||
VendorNotes = "Park behind dock"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("+1 555-0199", result.TechPhone);
|
||||
Assert.Equal(55, result.VendorId);
|
||||
|
||||
var wo = await context.workOrders.Include(w => w.PrimaryDispatch).SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Null(wo.TechPhone);
|
||||
Assert.Null(wo.VendorNotes);
|
||||
Assert.NotNull(wo.PrimaryDispatch);
|
||||
Assert.Equal("+1 555-0199", wo.PrimaryDispatch!.TechPhone);
|
||||
Assert.Equal("Park behind dock", wo.PrimaryDispatch.VendorNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_LegacyTradeAndDescription_StillWorks()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Trade = "HVAC",
|
||||
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("HVAC", result.Pm);
|
||||
Assert.Null(result.PocName);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("HVAC", wo.Trade);
|
||||
Assert.Contains("Vendor notes:", wo.Description);
|
||||
Assert.Null(wo.PocName);
|
||||
Assert.Null(wo.ExtraServices);
|
||||
Assert.Null(wo.ServiceNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_UnknownVendorId_Throws()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
VendorId = 999
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("VendorNotFound", ex.Code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,33 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_RejectsOverdueAsWorkOrderType()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
WorkOrderType = WorkOrderType.Reactive,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.WorkOrderType,
|
||||
Value = "Overdue",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("InvalidValue", ex.Code);
|
||||
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_BlocksEditWhenCanceled()
|
||||
{
|
||||
|
|
@ -224,4 +251,103 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
|
||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_UpdatesPmTrade()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Trade = "HVAC",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.Pm,
|
||||
Value = "Plumbing",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("Plumbing", result.Pm);
|
||||
Assert.Equal("Plumbing", (await context.workOrders.FindAsync(1))!.Trade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_UpdatesServiceNotesAndReturnsOnBoardRow()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Trade = "Backflow Preventers",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ServiceNotes,
|
||||
Value = "service notes",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("service notes", result.ServiceNotes);
|
||||
Assert.Equal("service notes", (await context.workOrders.FindAsync(1))!.ServiceNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_UpdatesExtraServices_WhenPrimaryExists()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Trade = "Backflow Preventers",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ExtraServices,
|
||||
Value = "[\"Filter change\",\"Coil clean\"]",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
|
||||
Assert.Contains("Filter change", (await context.workOrders.FindAsync(1))!.ExtraServices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ExtraServicesWithoutPrimary_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ExtraServices,
|
||||
Value = "[\"Filter change\"]",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("InvalidValue", ex.Code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ public class WorkOrderDetailServiceTests
|
|||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var templateData = new CompletionDocTemplateDataService(context);
|
||||
var service = new WorkOrderDetailService(boardService, detailData, templateData);
|
||||
var userData = new UserDataService(context);
|
||||
var service = new WorkOrderDetailService(boardService, detailData, templateData, userData);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue