mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 16:32:11 +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")
|
b.Property<string>("Status")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("TechPhone")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
b.Property<int>("VendorId")
|
b.Property<int>("VendorId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("VendorNotes")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
b.Property<DateTime?>("VerifiedAt")
|
b.Property<DateTime?>("VerifiedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
|
@ -1963,6 +1971,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ExtraServices")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
b.Property<string>("InternalWONumber")
|
b.Property<string>("InternalWONumber")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -1996,6 +2008,18 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<string>("PO")
|
b.Property<string>("PO")
|
||||||
.HasColumnType("nvarchar(max)");
|
.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")
|
b.Property<int?>("PrimaryDispatchId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
|
@ -2029,6 +2053,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("nvarchar(128)");
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceNotes")
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("nvarchar(4000)");
|
||||||
|
|
||||||
b.Property<string>("Severity")
|
b.Property<string>("Severity")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|
@ -2062,12 +2090,20 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Property<DateOnly?>("TargetWeek")
|
b.Property<DateOnly?>("TargetWeek")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("TechPhone")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
b.Property<string>("Trade")
|
b.Property<string>("Trade")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<decimal?>("VendorNTE")
|
b.Property<decimal?>("VendorNTE")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("VendorNotes")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
b.Property<int?>("WorkOrderType")
|
b.Property<int?>("WorkOrderType")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ namespace Data.SeaHavenIndustries
|
||||||
public string? VerifiedBy { get; set; }
|
public string? VerifiedBy { get; set; }
|
||||||
public DateTime? VerifiedAt { 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]
|
[Timestamp]
|
||||||
public byte[]? RowVersion { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,25 @@ namespace Data.SeaHavenIndustries
|
||||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
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 ---
|
// --- Scheduling slice ---
|
||||||
public DateTime? ScheduledDate { get; set; }
|
public DateTime? ScheduledDate { get; set; }
|
||||||
public DateTime? ScheduledStart { get; set; }
|
public DateTime? ScheduledStart { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
w.SiteCode,
|
w.SiteCode,
|
||||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
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)
|
.OrderBy(c => c.Id)
|
||||||
.Select(c => new
|
.Select(c => new
|
||||||
{
|
{
|
||||||
|
|
@ -52,12 +55,16 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null
|
||||||
? w.PrimaryDispatch.Vendor.ContactName
|
? w.PrimaryDispatch.Vendor.ContactName
|
||||||
: null,
|
: 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
|
? w.PrimaryDispatch.Vendor.Phone
|
||||||
: null,
|
: null,
|
||||||
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
|
DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null,
|
||||||
w.Trade,
|
w.Trade,
|
||||||
w.Problem,
|
w.Problem,
|
||||||
|
w.ServiceNotes,
|
||||||
|
w.ExtraServices,
|
||||||
w.DocStatus,
|
w.DocStatus,
|
||||||
w.PrimaryDispatchId,
|
w.PrimaryDispatchId,
|
||||||
w.RowVersion,
|
w.RowVersion,
|
||||||
|
|
@ -65,42 +72,54 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return rows.Select(w => new WorkOrderBoardRawRow(
|
return rows.Select(w =>
|
||||||
w.Id,
|
{
|
||||||
w.InternalWONumber,
|
var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name;
|
||||||
w.RescheduleCount,
|
var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber;
|
||||||
w.CarriedOver,
|
var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes;
|
||||||
w.WorkOrderType,
|
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
|
||||||
w.SiteCode,
|
? w.DispatchTechPhone
|
||||||
w.LocationName,
|
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
|
||||||
w.Poc?.Name,
|
|
||||||
w.Poc?.PhoneNumber,
|
return new WorkOrderBoardRawRow(
|
||||||
w.Poc?.Notes,
|
w.Id,
|
||||||
w.LifecycleStatus,
|
w.InternalWONumber,
|
||||||
w.LegacyStatus ?? w.Status,
|
w.RescheduleCount,
|
||||||
w.AssignTo,
|
w.CarriedOver,
|
||||||
w.FirstName,
|
w.WorkOrderType,
|
||||||
w.LastName,
|
w.SiteCode,
|
||||||
w.Initials,
|
w.LocationName,
|
||||||
w.Color,
|
pocName,
|
||||||
w.DueDate,
|
pocPhone,
|
||||||
w.ScheduledDate,
|
pocNotes,
|
||||||
w.ScheduledStart,
|
w.LifecycleStatus,
|
||||||
w.ScheduledEnd,
|
w.LegacyStatus ?? w.Status,
|
||||||
w.TargetWeek,
|
w.AssignTo,
|
||||||
w.ScheduleWeekOnly,
|
w.FirstName,
|
||||||
w.VendorId,
|
w.LastName,
|
||||||
w.VendorName,
|
w.Initials,
|
||||||
w.TechName,
|
w.Color,
|
||||||
w.TechPhone,
|
w.DueDate,
|
||||||
w.DispatchApptDate,
|
w.ScheduledDate,
|
||||||
w.Trade ?? w.Problem,
|
w.ScheduledStart,
|
||||||
w.Problem,
|
w.ScheduledEnd,
|
||||||
w.DocStatus,
|
w.TargetWeek,
|
||||||
w.PrimaryDispatchId,
|
w.ScheduleWeekOnly,
|
||||||
w.RowVersion,
|
w.VendorId,
|
||||||
w.DispatchRowVersion,
|
w.VendorName,
|
||||||
isUnscheduled)).ToList();
|
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);
|
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.CompletedDate,
|
||||||
w.SignOffName,
|
w.SignOffName,
|
||||||
w.SignOffAttachment,
|
w.SignOffAttachment,
|
||||||
w.SignOffSignature))
|
w.SignOffSignature,
|
||||||
|
w.ExtraServices,
|
||||||
|
w.ServiceNotes,
|
||||||
|
w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null
|
||||||
|
? w.PrimaryDispatch.VendorNotes
|
||||||
|
: w.VendorNotes))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<IEnumerable<ApplicationUser>> GetAllAsync();
|
Task<IEnumerable<ApplicationUser>> GetAllAsync();
|
||||||
Task<ApplicationUser?> GetByEmailAsync(string email);
|
Task<ApplicationUser?> GetByEmailAsync(string email);
|
||||||
Task<bool> ExistsAsync(string id);
|
Task<bool> ExistsAsync(string id);
|
||||||
|
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
DateTime? DispatchApptDate,
|
DateTime? DispatchApptDate,
|
||||||
string? Trade,
|
string? Trade,
|
||||||
string? Problem,
|
string? Problem,
|
||||||
|
string? ServiceNotes,
|
||||||
|
string? ExtraServices,
|
||||||
DocStatus? DocStatus,
|
DocStatus? DocStatus,
|
||||||
int? PrimaryDispatchId,
|
int? PrimaryDispatchId,
|
||||||
byte[]? RowVersion,
|
byte[]? RowVersion,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
DateTime? CompletedDate,
|
DateTime? CompletedDate,
|
||||||
string? SignOffName,
|
string? SignOffName,
|
||||||
string? SignOffAttachment,
|
string? SignOffAttachment,
|
||||||
string? SignOffSignature);
|
string? SignOffSignature,
|
||||||
|
string? ExtraServices,
|
||||||
|
string? ServiceNotes,
|
||||||
|
string? VendorNotes);
|
||||||
|
|
||||||
public record DispatchSignoffRow(
|
public record DispatchSignoffRow(
|
||||||
int DispatchId,
|
int DispatchId,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public DateTime? ApptDate { get; set; }
|
public DateTime? ApptDate { get; set; }
|
||||||
public string? ApptTime { get; set; }
|
public string? ApptTime { get; set; }
|
||||||
public string? Pm { get; set; }
|
public string? Pm { get; set; }
|
||||||
|
public string? ServiceNotes { get; set; }
|
||||||
|
public List<string>? ExtraServices { get; set; }
|
||||||
public DocStatus? DocStatus { get; set; }
|
public DocStatus? DocStatus { get; set; }
|
||||||
public int? PrimaryDispatchId { get; set; }
|
public int? PrimaryDispatchId { get; set; }
|
||||||
public byte[]? RowVersion { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers;
|
||||||
|
|
||||||
namespace SeaHaven.Services.DTOs
|
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 class WorkOrderBoardCreateRequestDto
|
||||||
{
|
{
|
||||||
public string? WoNumber { get; set; }
|
public string? WoNumber { get; set; }
|
||||||
public WorkOrderType WorkOrderType { get; set; }
|
public WorkOrderType WorkOrderType { get; set; }
|
||||||
public string? SiteCode { get; set; }
|
public string? SiteCode { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
|
||||||
public string? Trade { get; set; }
|
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 int? LocationId { get; set; }
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
public string? AssignTo { get; set; }
|
public string? AssignTo { get; set; }
|
||||||
|
|
@ -22,14 +33,18 @@ namespace SeaHaven.Services.DTOs
|
||||||
public DateTime? ApptDate { get; set; }
|
public DateTime? ApptDate { get; set; }
|
||||||
public string? ApptTime { get; set; }
|
public string? ApptTime { get; set; }
|
||||||
public int? PocContactId { get; set; }
|
public int? PocContactId { get; set; }
|
||||||
|
public string? PocName { get; set; }
|
||||||
|
public string? PocPhone { get; set; }
|
||||||
public string? PocNotes { 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>
|
/// <summary>Advanced search / filter query for the board.</summary>
|
||||||
public class WorkOrderAdvancedSearchQueryDto
|
public class WorkOrderAdvancedSearchQueryDto
|
||||||
{
|
{
|
||||||
public int Page { get; set; } = 1;
|
public int Page { get; set; } = 0;
|
||||||
public int PageSize { get; set; } = 50;
|
public int PageSize { get; set; } = 100;
|
||||||
public string? Search { get; set; }
|
public string? Search { get; set; }
|
||||||
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
||||||
public DateOnly? DateFrom { get; set; }
|
public DateOnly? DateFrom { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
public DateOnly? OriginalWeek { get; set; }
|
public DateOnly? OriginalWeek { get; set; }
|
||||||
public DateOnly? OriginalDate { get; set; }
|
public DateOnly? OriginalDate { get; set; }
|
||||||
public DateTime? CompletedDate { get; set; }
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
public string? VendorNotes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CompletionDocTemplateDto
|
public class CompletionDocTemplateDto
|
||||||
|
|
@ -63,6 +64,7 @@ namespace SeaHaven.Services.DTOs
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string? AuthorId { get; set; }
|
public string? AuthorId { get; set; }
|
||||||
|
public string? AuthorName { get; set; }
|
||||||
public string Text { get; set; } = "";
|
public string Text { get; set; } = "";
|
||||||
public string Time { get; set; } = "";
|
public string Time { get; set; } = "";
|
||||||
public string? Documents { get; set; }
|
public string? Documents { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
public static class WorkOrderAuditProjection
|
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
|
return new WorkOrderAuditEntryDto
|
||||||
{
|
{
|
||||||
Type = MapEventType(log.EventType),
|
Type = MapEventType(log.EventType),
|
||||||
DispatcherId = log.UserId,
|
DispatcherId = log.UserId,
|
||||||
Action = log.Action ?? "",
|
Action = log.Action ?? "",
|
||||||
FieldName = log.FieldName,
|
FieldName = log.FieldName,
|
||||||
OldValue = log.OldValue ?? "",
|
OldValue = oldValue,
|
||||||
NewValue = log.NewValue ?? "",
|
NewValue = newValue,
|
||||||
Time = log.CreatedAt.ToString("o"),
|
Time = log.CreatedAt.ToString("o"),
|
||||||
DispatchId = log.DispatchId
|
DispatchId = log.DispatchId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<WorkOrderAuditEntryDto> ToDtos(IEnumerable<WorkOrderAuditLog> logs)
|
public static List<WorkOrderAuditEntryDto> ToDtos(
|
||||||
=> logs.Select(ToDto).ToList();
|
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)
|
private static string MapEventType(string? eventType)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ namespace SeaHaven.Services.Helpers
|
||||||
public const string ApptDate = "apptDate";
|
public const string ApptDate = "apptDate";
|
||||||
public const string ApptTime = "apptTime";
|
public const string ApptTime = "apptTime";
|
||||||
public const string DocStatus = "docStatus";
|
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";
|
public const string RescheduleCount = "RescheduleCount";
|
||||||
|
|
||||||
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
|
@ -38,7 +41,10 @@ namespace SeaHaven.Services.Helpers
|
||||||
VendorId,
|
VendorId,
|
||||||
ApptDate,
|
ApptDate,
|
||||||
ApptTime,
|
ApptTime,
|
||||||
DocStatus
|
DocStatus,
|
||||||
|
Pm,
|
||||||
|
ServiceNotes,
|
||||||
|
ExtraServices
|
||||||
};
|
};
|
||||||
|
|
||||||
public static bool IsEditable(string field) => Canonicalize(field) != null;
|
public static bool IsEditable(string field) => Canonicalize(field) != null;
|
||||||
|
|
@ -75,6 +81,9 @@ namespace SeaHaven.Services.Helpers
|
||||||
ApptDate => "ApptDate",
|
ApptDate => "ApptDate",
|
||||||
ApptTime => "ApptTime",
|
ApptTime => "ApptTime",
|
||||||
DocStatus => "DocStatus",
|
DocStatus => "DocStatus",
|
||||||
|
Pm => "Trade",
|
||||||
|
ServiceNotes => "ServiceNotes",
|
||||||
|
ExtraServices => "ExtraServices",
|
||||||
_ => canonical
|
_ => canonical
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers
|
||||||
{
|
{
|
||||||
public static class WorkOrderCommentProjection
|
public static class WorkOrderCommentProjection
|
||||||
{
|
{
|
||||||
public static WorkOrderCommentDto ToDto(Comments comment)
|
public static WorkOrderCommentDto ToDto(
|
||||||
|
Comments comment,
|
||||||
|
IReadOnlyDictionary<string, string>? displayNames = null)
|
||||||
{
|
{
|
||||||
return new WorkOrderCommentDto
|
return new WorkOrderCommentDto
|
||||||
{
|
{
|
||||||
Id = comment.Id,
|
Id = comment.Id,
|
||||||
AuthorId = comment.UserId,
|
AuthorId = comment.UserId,
|
||||||
|
AuthorName = ResolveAuthorName(comment, displayNames),
|
||||||
Text = comment.Commenttext ?? "",
|
Text = comment.Commenttext ?? "",
|
||||||
Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"),
|
Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"),
|
||||||
Documents = comment.Documents
|
Documents = comment.Documents
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<WorkOrderCommentDto> ToDtos(IEnumerable<Comments> comments)
|
public static List<WorkOrderCommentDto> ToDtos(
|
||||||
=> comments.Select(ToDto).ToList();
|
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;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
@ -41,6 +42,24 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
||||||
var siteCode = request.SiteCode!.Trim();
|
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
|
var workOrder = new WorkOrder
|
||||||
{
|
{
|
||||||
|
|
@ -49,7 +68,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrderType = request.WorkOrderType,
|
WorkOrderType = request.WorkOrderType,
|
||||||
SiteCode = siteCode,
|
SiteCode = siteCode,
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
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,
|
LocationId = request.LocationId,
|
||||||
DueDate = request.DueDate?.Date,
|
DueDate = request.DueDate?.Date,
|
||||||
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
|
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
|
||||||
|
|
@ -61,6 +85,12 @@ namespace SeaHaven.Services.Implementation
|
||||||
istemplate = false
|
istemplate = false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!hasVendorDispatch)
|
||||||
|
{
|
||||||
|
workOrder.TechPhone = techPhone;
|
||||||
|
workOrder.VendorNotes = vendorNotes;
|
||||||
|
}
|
||||||
|
|
||||||
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
||||||
|
|
||||||
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
||||||
|
|
@ -75,6 +105,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||||
if (workOrder.Trade != null)
|
if (workOrder.Trade != null)
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
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)
|
if (workOrder.LocationId.HasValue)
|
||||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
|
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
|
||||||
if (workOrder.DueDate.HasValue)
|
if (workOrder.DueDate.HasValue)
|
||||||
|
|
@ -95,7 +139,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
|
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
|
||||||
|
|
||||||
Dispatch? dispatch = null;
|
Dispatch? dispatch = null;
|
||||||
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
|
if (hasVendorDispatch)
|
||||||
{
|
{
|
||||||
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
|
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
|
||||||
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
|
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
|
||||||
|
|
@ -105,7 +149,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
VendorId = request.VendorId.Value,
|
VendorId = request.VendorId.Value,
|
||||||
Status = "Pending",
|
Status = "Pending",
|
||||||
CreatedDate = DateTime.UtcNow,
|
CreatedDate = DateTime.UtcNow,
|
||||||
ScheduledDate = request.ApptDate?.Date
|
ScheduledDate = request.ApptDate?.Date,
|
||||||
|
TechPhone = techPhone,
|
||||||
|
VendorNotes = vendorNotes
|
||||||
};
|
};
|
||||||
_context.Dispatches.Add(dispatch);
|
_context.Dispatches.Add(dispatch);
|
||||||
workOrder.PrimaryDispatch = dispatch;
|
workOrder.PrimaryDispatch = dispatch;
|
||||||
|
|
@ -120,6 +166,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (apptChange.HasChanged)
|
if (apptChange.HasChanged)
|
||||||
changes.Add(apptChange);
|
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);
|
_context.workOrders.Add(workOrder);
|
||||||
|
|
@ -140,7 +191,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
WorkorderId = workOrder.Id,
|
WorkorderId = workOrder.Id,
|
||||||
ContactId = request.PocContactId,
|
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.");
|
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)
|
private async Task<string> ResolveWoNumberAsync(string? requested)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(requested))
|
if (!string.IsNullOrWhiteSpace(requested))
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
ApptDate = row.DispatchApptDate ?? row.ScheduledDate,
|
||||||
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd),
|
||||||
Pm = row.Trade ?? row.Problem,
|
Pm = row.Trade ?? row.Problem,
|
||||||
|
ServiceNotes = row.ServiceNotes,
|
||||||
|
ExtraServices = ParseExtraServices(row.ExtraServices),
|
||||||
DocStatus = row.DocStatus,
|
DocStatus = row.DocStatus,
|
||||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||||
RowVersion = row.RowVersion,
|
RowVersion = row.RowVersion,
|
||||||
|
|
@ -124,5 +126,21 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
private static string FormatName(string? firstName, string? lastName)
|
private static string FormatName(string? firstName, string? lastName)
|
||||||
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
=> $"{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;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -157,6 +158,9 @@ namespace SeaHaven.Services.Implementation
|
||||||
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
|
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
|
||||||
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, 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.")
|
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +226,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required.");
|
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))
|
if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed))
|
||||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}");
|
||||||
|
|
||||||
|
|
@ -248,6 +257,89 @@ namespace SeaHaven.Services.Implementation
|
||||||
return FieldChange.ForField(auditField, old, normalized);
|
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)
|
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
|
||||||
{
|
{
|
||||||
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
|
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,18 @@ namespace SeaHaven.Services.Implementation
|
||||||
private readonly IWorkOrderBoardService _boardService;
|
private readonly IWorkOrderBoardService _boardService;
|
||||||
private readonly IWorkOrderDetailDataService _detailData;
|
private readonly IWorkOrderDetailDataService _detailData;
|
||||||
private readonly ICompletionDocTemplateDataService _templateData;
|
private readonly ICompletionDocTemplateDataService _templateData;
|
||||||
|
private readonly IUserDataService _userDataService;
|
||||||
|
|
||||||
public WorkOrderDetailService(
|
public WorkOrderDetailService(
|
||||||
IWorkOrderBoardService boardService,
|
IWorkOrderBoardService boardService,
|
||||||
IWorkOrderDetailDataService detailData,
|
IWorkOrderDetailDataService detailData,
|
||||||
ICompletionDocTemplateDataService templateData)
|
ICompletionDocTemplateDataService templateData,
|
||||||
|
IUserDataService userDataService)
|
||||||
{
|
{
|
||||||
_boardService = boardService;
|
_boardService = boardService;
|
||||||
_detailData = detailData;
|
_detailData = detailData;
|
||||||
_templateData = templateData;
|
_templateData = templateData;
|
||||||
|
_userDataService = userDataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
|
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
|
||||||
|
|
@ -41,12 +44,15 @@ namespace SeaHaven.Services.Implementation
|
||||||
extended?.Trade ?? boardRow.Pm,
|
extended?.Trade ?? boardRow.Pm,
|
||||||
boardRow.WorkOrderType);
|
boardRow.WorkOrderType);
|
||||||
|
|
||||||
|
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
|
||||||
|
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
|
||||||
|
|
||||||
return new WorkOrderDetailDto
|
return new WorkOrderDetailDto
|
||||||
{
|
{
|
||||||
Info = MapInfo(boardRow, extended),
|
Info = MapInfo(boardRow, extended),
|
||||||
Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs),
|
Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs),
|
||||||
Comments = WorkOrderCommentProjection.ToDtos(comments),
|
Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames),
|
||||||
Audit = WorkOrderAuditProjection.ToDtos(audit),
|
Audit = await MapAuditAsync(audit),
|
||||||
Media = workOrder == null
|
Media = workOrder == null
|
||||||
? new List<WorkOrderMediaFileDto>()
|
? new List<WorkOrderMediaFileDto>()
|
||||||
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
|
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
|
||||||
|
|
@ -59,7 +65,14 @@ namespace SeaHaven.Services.Implementation
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
|
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(
|
private static WorkOrderDetailInfoDto MapInfo(
|
||||||
|
|
@ -98,6 +111,8 @@ namespace SeaHaven.Services.Implementation
|
||||||
ApptDate = row.ApptDate,
|
ApptDate = row.ApptDate,
|
||||||
ApptTime = row.ApptTime,
|
ApptTime = row.ApptTime,
|
||||||
Pm = row.Pm,
|
Pm = row.Pm,
|
||||||
|
ServiceNotes = extended?.ServiceNotes ?? row.ServiceNotes,
|
||||||
|
ExtraServices = ParseExtraServices(extended?.ExtraServices) ?? row.ExtraServices,
|
||||||
DocStatus = row.DocStatus,
|
DocStatus = row.DocStatus,
|
||||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||||
RowVersion = row.RowVersion,
|
RowVersion = row.RowVersion,
|
||||||
|
|
@ -108,10 +123,27 @@ namespace SeaHaven.Services.Implementation
|
||||||
SubTrade = extended?.SubTrade,
|
SubTrade = extended?.SubTrade,
|
||||||
OriginalWeek = extended?.OriginalWeek,
|
OriginalWeek = extended?.OriginalWeek,
|
||||||
OriginalDate = extended?.OriginalDate,
|
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(
|
private static WorkOrderCompletionDto MapCompletion(
|
||||||
WorkOrderDetailExtendedFields? extended,
|
WorkOrderDetailExtendedFields? extended,
|
||||||
Data.SeaHavenIndustries.Enums.DocStatus? docStatus,
|
Data.SeaHavenIndustries.Enums.DocStatus? docStatus,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,34 @@ namespace SeaHaven.Services.Validation
|
||||||
.MaximumLength(128)
|
.MaximumLength(128)
|
||||||
.When(x => !string.IsNullOrEmpty(x.Trade));
|
.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)
|
RuleFor(x => x.LocationId)
|
||||||
.GreaterThan(0)
|
.GreaterThan(0)
|
||||||
.When(x => x.LocationId.HasValue);
|
.When(x => x.LocationId.HasValue);
|
||||||
|
|
@ -41,6 +69,26 @@ namespace SeaHaven.Services.Validation
|
||||||
RuleFor(x => x.PocContactId)
|
RuleFor(x => x.PocContactId)
|
||||||
.GreaterThan(0)
|
.GreaterThan(0)
|
||||||
.When(x => x.PocContactId.HasValue);
|
.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.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
|
||||||
Assert.True(result.Id > 0);
|
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);
|
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]
|
[Fact]
|
||||||
public async Task PatchField_BlocksEditWhenCanceled()
|
public async Task PatchField_BlocksEditWhenCanceled()
|
||||||
{
|
{
|
||||||
|
|
@ -224,4 +251,103 @@ public class WorkOrderBoardUpdateServiceTests
|
||||||
|
|
||||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
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 boardService = new WorkOrderBoardService(boardData);
|
||||||
var detailData = new WorkOrderDetailDataService(context);
|
var detailData = new WorkOrderDetailDataService(context);
|
||||||
var templateData = new CompletionDocTemplateDataService(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);
|
return (context, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue