From af1c0d19b4c6dbaa4551d5494d1e4fec955b4adf Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:35:55 -0300 Subject: [PATCH 1/3] fix(migrations): make Phase6 completion slide-over migration idempotent (#19) * 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. * fix(work-orders): make stacked tip build for CI quality gates --------- Co-authored-by: Arthur Bassi --- .../WorkOrderControllerSearchTests.cs | 6 +- ...260625120000_Phase6_CompletionSlideOver.cs | 87 +++++++++++-------- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs index 61d37c9..17826af 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -59,7 +59,11 @@ public class WorkOrderControllerSearchTests Mock.Of(), Mock.Of(), Mock.Of(), - advancedSearch); + advancedSearch, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); controller.ControllerContext = new ControllerContext { diff --git a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs index 08ff98d..f52940d 100644 --- a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs +++ b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs @@ -1,4 +1,5 @@ -using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,45 +7,52 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.SeaHavenIndustries.Migrations { /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260625120000_Phase6_CompletionSlideOver")] public partial class Phase6_CompletionSlideOver : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.AddColumn( - name: "Category", - table: "workOrderAttachments", - type: "int", - nullable: true); + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL + BEGIN + ALTER TABLE workOrderAttachments ADD Category int NULL; + END + "); - migrationBuilder.CreateTable( - name: "CompletionDocTemplates", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: false), - ServiceKey = table.Column(type: "nvarchar(450)", nullable: false), - WorkOrderType = table.Column(type: "int", nullable: true), - TemplateUrl = table.Column(type: "nvarchar(max)", nullable: false), - IsActive = table.Column(type: "bit", nullable: false), - IsDeleted = table.Column(type: "bit", nullable: true), - createdby = table.Column(type: "nvarchar(max)", nullable: true), - DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), - DeletionTime = table.Column(type: "datetime2", nullable: true), - CreatedDate = table.Column(type: "datetime2", nullable: true), - LastModificationTime = table.Column(type: "datetime2", nullable: true), - LastModifierUserId = table.Column(type: "int", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_CompletionDocTemplates", x => x.Id); - }); + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NULL + BEGIN + CREATE TABLE CompletionDocTemplates ( + Id int NOT NULL IDENTITY(1,1), + Name nvarchar(max) NOT NULL, + ServiceKey nvarchar(450) NOT NULL, + WorkOrderType int NULL, + TemplateUrl nvarchar(max) NOT NULL, + IsActive bit NOT NULL, + IsDeleted bit NULL, + createdby nvarchar(max) NULL, + DeleterUserId nvarchar(max) NULL, + DeletionTime datetime2 NULL, + CreatedDate datetime2 NULL, + LastModificationTime datetime2 NULL, + LastModifierUserId int NULL, + CONSTRAINT PK_CompletionDocTemplates PRIMARY KEY (Id) + ); + END + "); - migrationBuilder.CreateIndex( - name: "IX_CompletionDocTemplates_ServiceKey_IsActive", - table: "CompletionDocTemplates", - columns: new[] { "ServiceKey", "IsActive" }); + migrationBuilder.Sql(@" + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'IX_CompletionDocTemplates_ServiceKey_IsActive' + AND object_id = OBJECT_ID(N'dbo.CompletionDocTemplates')) + BEGIN + CREATE INDEX IX_CompletionDocTemplates_ServiceKey_IsActive + ON CompletionDocTemplates (ServiceKey, IsActive); + END + "); migrationBuilder.Sql(@" UPDATE workOrders @@ -66,12 +74,15 @@ namespace Data.SeaHavenIndustries.Migrations AND LTRIM(RTRIM(SignOffAttachment)) <> ''; "); - migrationBuilder.DropTable( - name: "CompletionDocTemplates"); + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL + DROP TABLE CompletionDocTemplates; + "); - migrationBuilder.DropColumn( - name: "Category", - table: "workOrderAttachments"); + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL + ALTER TABLE workOrderAttachments DROP COLUMN Category; + "); } } } From 8c4102b1cf4cd93b137d0e38bd633147979e39fc Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:36:11 -0300 Subject: [PATCH 2/3] feat(locations): add sites options endpoint and vendor contactName (#20) * 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. * fix(work-orders): make stacked tip build for CI quality gates --------- Co-authored-by: Arthur Bassi --- .../LocationControllerSitesTests.cs | 109 ++++++++++++++++++ .../Controllers/LocationController.cs | 7 ++ .../Controllers/VendorController.cs | 2 + .../Implementation/LocationDataService.cs | 18 +++ .../Interfaces/ILocationDataService.cs | 4 + SeaHaven.Services/DTOs/LocationDTOs.cs | 8 ++ .../Implementation/LocationService.cs | 14 +++ .../Interfaces/ILocationService.cs | 1 + 8 files changed, 163 insertions(+) create mode 100644 Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs new file mode 100644 index 0000000..5074c22 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -0,0 +1,109 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationControllerSitesTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationController NewController(ApplicationDbContext ctx) + { + var dataService = new LocationDataService(ctx); + var service = new LocationService( + dataService, + Mock.Of(), + Mock.Of()); + + var controller = new LocationController(service, dataService) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + return controller; + } + + private static async Task SeedSitesAsync(ApplicationDbContext ctx) + { + ctx.Locations.AddRange( + new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" }, + new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" }, + new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" }, + new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites(); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(4); + data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8"); + data[0].Should().BeEquivalentTo(new SiteOptionDTO + { + Id = 2, + Code = "BK5", + City = "Dallas", + State = "TX" + }); + } + + [Fact] + public async Task GetSites_FiltersByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("bk5"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().ContainSingle(); + data[0].Code.Should().Be("BK5"); + } + + [Fact] + public async Task GetSites_FiltersByCity() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("Dallas"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(2); + data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8"); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 4bfba79..3d671b2 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -26,6 +26,13 @@ namespace Api.SeaHavenIndustries.Controllers _locationDataService = locationDataService; } + [HttpGet("sites")] + public async Task GetSites(string? search = null) + { + var data = await _locationService.GetSiteOptionsAsync(search); + return Ok(data); + } + [HttpGet("GetLocationList")] public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10) { diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..9c25ceb 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -174,6 +174,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, v.Address, v.City, @@ -191,6 +192,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 65c46df..b48cd5b 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var query = _context.Locations.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.ToLower(); + query = query.Where(l => + (l.Name ?? "").ToLower().Contains(s) || + (l.City ?? "").ToLower().Contains(s)); + } + + return await query + .OrderBy(l => l.Name) + .Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State)) + .ToListAsync(); + } + public async Task AddAsync(Locations location) { location.CreatedDate = DateTime.Now; diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 2865e42..679528e 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -2,6 +2,8 @@ using Data.SeaHavenIndustries; namespace SeaHaven.DataServices.Interfaces { + public record SiteOptionRow(int Id, string? Code, string? City, string? State); + public interface ILocationDataService { Task GetByIdAsync(int id); @@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces // For addressbook endpoint - returns simplified DTO Task<(IEnumerable Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); + Task AddAsync(Locations location); Task UpdateAsync(Locations location); Task DeleteAsync(int id); diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 09e41b6..9b518d0 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -36,4 +36,12 @@ namespace SeaHaven.Services.DTOs public string? Zipcode { get; set; } public int? AccountId { get; set; } } + + public class SiteOptionDTO + { + public int Id { get; set; } + public string? Code { get; set; } + public string? City { get; set; } + public string? State { get; set; } + } } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index c625192..accf6ba 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -64,6 +64,20 @@ namespace SeaHaven.Services.Implementation }; } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var rows = await _locationDataService.GetSiteOptionsAsync(search); + return rows + .Select(r => new SiteOptionDTO + { + Id = r.Id, + Code = r.Code, + City = r.City, + State = r.State + }) + .ToList(); + } + public async Task CreateLocationAsync(CreateLocationDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 7bf9d4e..dd70421 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -9,6 +9,7 @@ namespace SeaHaven.Services.Interfaces Task> GetAllLocationsAsync(); Task> GetLocationsByAccountIdAsync(int accountId); Task> GetLocationsPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); Task CreateLocationAsync(CreateLocationDTO dto, string userId); Task UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId); Task DeleteLocationAsync(int id, string userId); From f4985378fc8d09fcfd140417ec0610aa7459a513 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:36:46 -0300 Subject: [PATCH 3/3] 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 --- ...15160000_WoServiceNotesStructuredFields.cs | 121 +++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 36 ++++ Data.SeaHavenIndustries/Models/Dispatch.cs | 7 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 19 ++ .../Helpers/WorkOrderBoardProjection.cs | 95 ++++++---- .../Implementation/UserDataService.cs | 22 +++ .../WorkOrderDetailDataService.cs | 7 +- .../Interfaces/IUserDataService.cs | 1 + .../Interfaces/WorkOrderBoardModels.cs | 2 + .../Interfaces/WorkOrderDetailModels.cs | 5 +- SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 21 ++- SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs | 2 + .../Helpers/WorkOrderAuditProjection.cs | 56 +++++- .../Helpers/WorkOrderBoardFieldNames.cs | 11 +- .../Helpers/WorkOrderCommentProjection.cs | 35 +++- .../WorkOrderBoardCreateService.cs | 104 ++++++++++- .../Implementation/WorkOrderBoardService.cs | 18 ++ .../WorkOrderBoardUpdateService.cs | 92 ++++++++++ .../Implementation/WorkOrderDetailService.cs | 42 ++++- .../WorkOrderBoardCreateValidation.cs | 48 +++++ .../WorkOrderBoardCreateServiceTests.cs | 169 ++++++++++++++++++ .../WorkOrderBoardUpdateServiceTests.cs | 126 +++++++++++++ .../WorkOrderPhase6Tests.cs | 3 +- 24 files changed, 982 insertions(+), 62 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs new file mode 100644 index 0000000..e49532f --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs @@ -0,0 +1,121 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260715160000_WoServiceNotesStructuredFields")] + public partial class WoServiceNotesStructuredFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PocName", + table: "workOrders", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "PocPhone", + table: "workOrders", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "PocNotes", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExtraServices", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "ServiceNotes", + table: "workOrders", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true); + + migrationBuilder.AddColumn( + name: "TechPhone", + table: "workOrders", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "VendorNotes", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "TechPhone", + table: "Dispatches", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "VendorNotes", + table: "Dispatches", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + } + + /// + 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"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index d97ab9b..3da8116 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -739,9 +739,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Status") .HasColumnType("nvarchar(max)"); + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("VendorId") .HasColumnType("int"); + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + b.Property("VerifiedAt") .HasColumnType("datetime2"); @@ -1963,6 +1971,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(450) .HasColumnType("nvarchar(450)"); + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + b.Property("InternalWONumber") .HasColumnType("nvarchar(max)"); @@ -1996,6 +2008,18 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("PO") .HasColumnType("nvarchar(max)"); + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("PrimaryDispatchId") .HasColumnType("int"); @@ -2029,6 +2053,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + b.Property("Severity") .HasColumnType("nvarchar(max)"); @@ -2062,12 +2090,20 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("TargetWeek") .HasColumnType("date"); + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("Trade") .HasColumnType("nvarchar(max)"); b.Property("VendorNTE") .HasColumnType("decimal(18,2)"); + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + b.Property("WorkOrderType") .HasColumnType("int"); diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 831cc17..7fe14f3 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -28,6 +28,13 @@ namespace Data.SeaHavenIndustries public string? VerifiedBy { get; set; } public DateTime? VerifiedAt { get; set; } + /// Tech/crew phone for this dispatch (overrides Vendor.Phone on board). + [MaxLength(50)] + public string? TechPhone { get; set; } + /// Instructions for the vendor for this appointment/dispatch. + [MaxLength(2000)] + public string? VendorNotes { get; set; } + [Timestamp] public byte[]? RowVersion { get; set; } diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 18e51bf..cb30f29 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -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; } + /// JSON array of extra service labels beyond primary (Trade). + [MaxLength(2000)] + public string? ExtraServices { get; set; } + [MaxLength(4000)] + public string? ServiceNotes { get; set; } + /// Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone. + [MaxLength(50)] + public string? TechPhone { get; set; } + /// Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes. + [MaxLength(2000)] + public string? VendorNotes { get; set; } + // --- Scheduling slice --- public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledStart { get; set; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index e2ad8d7..71c417a 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -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(); } } } diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 365ceca..436a806 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -33,5 +33,27 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Users.AnyAsync(u => u.Id == id); } + + public async Task> GetDisplayNamesByIdsAsync(IEnumerable ids) + { + var idList = ids + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (idList.Count == 0) + return new Dictionary(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); + } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs index 8ea9358..e2526ab 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -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(); } diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 6524cc4..6cfed2d 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task GetByEmailAsync(string email); Task ExistsAsync(string id); + Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } } diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 0173d07..ce96697 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -42,6 +42,8 @@ namespace SeaHaven.DataServices.Interfaces DateTime? DispatchApptDate, string? Trade, string? Problem, + string? ServiceNotes, + string? ExtraServices, DocStatus? DocStatus, int? PrimaryDispatchId, byte[]? RowVersion, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs index cab956b..433b080 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -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, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index b35c303..1f1b549 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -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? ExtraServices { get; set; } public DocStatus? DocStatus { get; set; } public int? PrimaryDispatchId { get; set; } public byte[]? RowVersion { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 31fae59..b78be76 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers; namespace SeaHaven.Services.DTOs { - /// Payload for creating a board work order (SHOC create wizard). + /// + /// 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. + /// public class WorkOrderBoardCreateRequestDto { public string? WoNumber { get; set; } public WorkOrderType WorkOrderType { get; set; } public string? SiteCode { get; set; } public string? Description { get; set; } + /// Legacy alias for primary service; prefer . public string? Trade { get; set; } + /// Primary service label; persisted as Trade / board pm. + public string? PrimaryService { get; set; } + public List? 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; } } /// Advanced search / filter query for the board. 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; } diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs index 7ac1437..105e112 100644 --- a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -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; } diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs index 8b56b47..6a7e295 100644 --- a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -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? 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 ToDtos(IEnumerable logs) - => logs.Select(ToDto).ToList(); + public static List ToDtos( + IEnumerable logs, + IReadOnlyDictionary? assignToDisplayNames = null) + => logs.Select(log => ToDto(log, assignToDisplayNames)).ToList(); + + public static IEnumerable CollectAssignToUserIds(IEnumerable 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? 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) { diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index cf6e4e8..233090e 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -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 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 }; } diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs index 096edd5..cb4a2d5 100644 --- a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -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? 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 ToDtos(IEnumerable comments) - => comments.Select(ToDto).ToList(); + public static List ToDtos( + IEnumerable comments, + IReadOnlyDictionary? displayNames = null) + => comments.Select(c => ToDto(c, displayNames)).ToList(); + + public static IEnumerable CollectAuthorUserIds(IEnumerable comments) + => comments + .Where(c => !string.IsNullOrWhiteSpace(c.UserId)) + .Select(c => c.UserId!) + .Distinct(StringComparer.Ordinal); + + private static string? ResolveAuthorName( + Comments comment, + IReadOnlyDictionary? 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; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 77bdb27..7f75baa 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -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 @@ -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? extras) + { + if (extras == null || extras.Count == 0) + return null; + + var normalized = new List(); + var seen = new HashSet(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 ResolveWoNumberAsync(string? requested) { if (!string.IsNullOrWhiteSpace(requested)) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 0a82f76..076f090 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -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? ParseExtraServices(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(json); + return list is { Count: > 0 } ? list : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 98239bb..90b57fb 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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 { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => new List { ApplyApptTime(workOrder, dispatch!, value, auditField) }, WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.Pm => new List { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, + WorkOrderBoardFieldNames.ServiceNotes => new List { ApplyServiceNotes(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.ExtraServices => new List { 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? parsed; + try + { + parsed = JsonSerializer.Deserialize>(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 NormalizeExtraServiceList(List? extras) + { + var normalized = new List(); + if (extras == null || extras.Count == 0) + return normalized; + + var seen = new HashSet(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); diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 34aa8ac..0f7b5d0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -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 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() : 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> MapAuditAsync(IReadOnlyList 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? ParseExtraServices(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(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, diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 3b00bed..8d0564d 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -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); } } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 6910f7a..b3171f2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -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 { "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>(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(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ExtraServices = new List { "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(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + VendorId = 999 + }, "actor-1")); + + Assert.Equal("VendorNotFound", ex.Code); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index 59b180c..94ba33d 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -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(() => + 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(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ExtraServices, + Value = "[\"Filter change\"]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 343337e..85d0a98 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -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); }