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:
Arthur Bassi 2026-07-22 17:36:46 -03:00 • committed by GitHub
parent 8c4102b1cf
commit f4985378fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 982 additions and 62 deletions

View file

@ -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");
}
}
}

View file

@ -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");

View file

@ -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; }

View file

@ -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; }

View file

@ -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();
}
}
}

View file

@ -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);
}
}
}

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -42,6 +42,8 @@ namespace SeaHaven.DataServices.Interfaces
DateTime? DispatchApptDate,
string? Trade,
string? Problem,
string? ServiceNotes,
string? ExtraServices,
DocStatus? DocStatus,
int? PrimaryDispatchId,
byte[]? RowVersion,

View file

@ -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,

View file

@ -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; }

View file

@ -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; }

View file

@ -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; }

View file

@ -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)
{

View file

@ -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
};
}

View file

@ -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;
}
}
}

View file

@ -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))

View file

@ -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;
}
}
}
}

View file

@ -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);

View file

@ -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,

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}