mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Compare commits
9 commits
2c26ead193
...
4e32c487d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e32c487d2 | ||
|
|
a930d75ce0 | ||
|
|
f61aff06ff | ||
|
|
16b31e5f28 | ||
|
|
1eaf9b7c84 | ||
|
|
62572d8e03 | ||
|
|
ecc1db3726 | ||
|
|
3983f7667d | ||
|
|
3e2952f8a0 |
54 changed files with 2897 additions and 268 deletions
109
Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs
Normal file
109
Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs
Normal file
|
|
@ -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<ApplicationDbContext>()
|
||||
.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<ICreateLocationValidation>(),
|
||||
Mock.Of<IUpdateLocationValidation>());
|
||||
|
||||
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<OkObjectResult>().Subject;
|
||||
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().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<OkObjectResult>().Subject;
|
||||
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().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<OkObjectResult>().Subject;
|
||||
var data = ok.Value.Should().BeAssignableTo<IReadOnlyList<SiteOptionDTO>>().Subject;
|
||||
|
||||
data.Should().HaveCount(2);
|
||||
data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8");
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
|
@ -59,7 +60,11 @@ public class WorkOrderControllerSearchTests
|
|||
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||
advancedSearch);
|
||||
advancedSearch,
|
||||
Mock.Of<IWorkOrderDetailService>(),
|
||||
Mock.Of<IWorkOrderCommentService>(),
|
||||
Mock.Of<IWorkOrderCompletionService>(),
|
||||
Mock.Of<IWorkOrderMediaService>());
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
|
|
@ -98,4 +103,61 @@ public class WorkOrderControllerSearchTests
|
|||
body.Status.Should().Be("Error");
|
||||
body.Message.Should().Contain("dateFrom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(ctx));
|
||||
var controller = NewController(ctx, Mock.Of<IWorkOrderAdvancedSearchService>());
|
||||
// Replace board service - controller uses injected mock. Need different approach.
|
||||
|
||||
// Use real board service via manual controller setup
|
||||
var advancedSearch = Mock.Of<IWorkOrderAdvancedSearchService>();
|
||||
var controllerWithBoard = new WorkOrderController(
|
||||
MockUserManager(),
|
||||
Mock.Of<IWorkOrderService>(),
|
||||
Mock.Of<IWorkOrderDataService>(),
|
||||
Mock.Of<ICommentDataService>(),
|
||||
Mock.Of<IQuotesDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<IDispatchDataService>(),
|
||||
Mock.Of<IVendorDataService>(),
|
||||
Mock.Of<ITaskListTemplateDataService>(),
|
||||
ctx,
|
||||
Mock.Of<IWebHostEnvironment>(),
|
||||
Mock.Of<IHttpContextAccessor>(),
|
||||
null!,
|
||||
null!,
|
||||
Mock.Of<IConfiguration>(),
|
||||
boardService,
|
||||
Mock.Of<IWorkOrderBoardUpdateService>(),
|
||||
Mock.Of<IWorkOrderBoardCreateService>(),
|
||||
Mock.Of<IWorkOrderBoardCancelService>(),
|
||||
advancedSearch,
|
||||
Mock.Of<IWorkOrderDetailService>(),
|
||||
Mock.Of<IWorkOrderCommentService>(),
|
||||
Mock.Of<IWorkOrderCompletionService>(),
|
||||
Mock.Of<IWorkOrderMediaService>());
|
||||
|
||||
controllerWithBoard.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new System.Security.Claims.ClaimsPrincipal(
|
||||
new System.Security.Claims.ClaimsIdentity(
|
||||
new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "test-user") },
|
||||
"Test"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controllerWithBoard.GetBoard(
|
||||
new DateOnly(2026, 6, 22),
|
||||
new DateOnly(2026, 6, 15));
|
||||
|
||||
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
|
||||
body.Status.Should().Be("Error");
|
||||
body.Message.Should().Contain("weekEnd");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_locationDataService = locationDataService;
|
||||
}
|
||||
|
||||
[HttpGet("sites")]
|
||||
public async Task<IActionResult> GetSites(string? search = null)
|
||||
{
|
||||
var data = await _locationService.GetSiteOptionsAsync(search);
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("GetLocationList")]
|
||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -114,19 +114,26 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[FromQuery] List<WorkOrderType>? types = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var query = new WorkOrderBoardQueryDto
|
||||
try
|
||||
{
|
||||
WeekStart = weekStart,
|
||||
WeekEnd = weekEnd,
|
||||
Dispatchers = dispatchers,
|
||||
MyWorkOrders = myWorkOrders,
|
||||
Types = types,
|
||||
Search = search
|
||||
};
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var query = new WorkOrderBoardQueryDto
|
||||
{
|
||||
WeekStart = weekStart,
|
||||
WeekEnd = weekEnd,
|
||||
Dispatchers = dispatchers,
|
||||
MyWorkOrders = myWorkOrders,
|
||||
Types = types,
|
||||
Search = search
|
||||
};
|
||||
|
||||
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
|
||||
return Ok(result);
|
||||
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("board/search")]
|
||||
|
|
@ -278,6 +285,35 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}/comments/{commentId:int}")]
|
||||
public async Task<IActionResult> UpdateBoardComment(
|
||||
int id,
|
||||
int commentId,
|
||||
[FromBody] WorkOrderCommentCreateDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var isAdmin = User.IsInRole("Admin");
|
||||
var comment = await _workOrderCommentService.UpdateCommentAsync(
|
||||
id, commentId, request, actorId, isAdmin);
|
||||
return Ok(comment);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("completion-templates")]
|
||||
public async Task<IActionResult> GetCompletionTemplates(
|
||||
[FromQuery] string? serviceKey = null,
|
||||
|
|
|
|||
|
|
@ -49,10 +49,18 @@ namespace Data.SeaHavenIndustries
|
|||
.Property(w => w.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.SiteCode)
|
||||
.HasMaxLength(450);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.SiteCode)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.InternalWONumber)
|
||||
.HasMaxLength(450);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.InternalWONumber)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
|
@ -61,6 +69,23 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
// Phase 1 board indexes (must stay in model to match snapshot / existing DBs).
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.AssignTo);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.ScheduledDate);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => new { w.AssignTo, w.ScheduledDate });
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.WorkOrderType);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.Service)
|
||||
.HasMaxLength(128);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.ExternalWorkOrderId)
|
||||
.HasMaxLength(450);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,27 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// nvarchar(max) cannot be an index key; shrink before indexing.
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "SiteCode",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(450)",
|
||||
maxLength: 450,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "InternalWONumber",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(450)",
|
||||
maxLength: 450,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_SiteCode",
|
||||
table: "workOrders",
|
||||
|
|
@ -43,6 +64,26 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "SiteCode",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(450)",
|
||||
oldMaxLength: 450,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "InternalWONumber",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(450)",
|
||||
oldMaxLength: 450,
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260625120000_Phase6_CompletionSlideOver")]
|
||||
public partial class Phase6_CompletionSlideOver : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
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<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ServiceKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
WorkOrderType = table.Column<int>(type: "int", nullable: true),
|
||||
TemplateUrl = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
|
||||
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
LastModifierUserId = table.Column<int>(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;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
|
@ -5,45 +7,57 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260713120000_Phase7_ExternalWorkOrderIdUnique")]
|
||||
public partial class Phase7_ExternalWorkOrderIdUnique : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(450)",
|
||||
maxLength: 450,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldNullable: true);
|
||||
// Idempotent: column may already be nvarchar(450) in some environments.
|
||||
migrationBuilder.Sql(@"
|
||||
IF COL_LENGTH('workOrders', 'ExternalWorkOrderId') IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.columns c
|
||||
INNER JOIN sys.types t ON c.user_type_id = t.user_type_id
|
||||
WHERE c.object_id = OBJECT_ID(N'dbo.workOrders')
|
||||
AND c.name = N'ExternalWorkOrderId'
|
||||
AND t.name = N'nvarchar'
|
||||
AND c.max_length = -1)
|
||||
BEGIN
|
||||
ALTER TABLE workOrders ALTER COLUMN ExternalWorkOrderId nvarchar(450) NULL;
|
||||
END
|
||||
");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
column: "ExternalWorkOrderId",
|
||||
unique: true,
|
||||
filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = N'IX_workOrders_ExternalWorkOrderId'
|
||||
AND object_id = OBJECT_ID(N'dbo.workOrders'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX IX_workOrders_ExternalWorkOrderId
|
||||
ON workOrders (ExternalWorkOrderId)
|
||||
WHERE [ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> '';
|
||||
END
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_ExternalWorkOrderId",
|
||||
table: "workOrders");
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = N'IX_workOrders_ExternalWorkOrderId'
|
||||
AND object_id = OBJECT_ID(N'dbo.workOrders'))
|
||||
DROP INDEX IX_workOrders_ExternalWorkOrderId ON workOrders;
|
||||
");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(450)",
|
||||
oldMaxLength: 450,
|
||||
oldNullable: true);
|
||||
migrationBuilder.Sql(@"
|
||||
IF COL_LENGTH('workOrders', 'ExternalWorkOrderId') IS NOT NULL
|
||||
ALTER TABLE workOrders ALTER COLUMN ExternalWorkOrderId nvarchar(max) NULL;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260715180000_WoFlagColor")]
|
||||
public partial class WoFlagColor : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FlagColor",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(7)",
|
||||
maxLength: 7,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FlagColor",
|
||||
table: "workOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260717140000_AddEventsCalendarTable")]
|
||||
public partial class AddEventsCalendarTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Events entity existed in the model/DbSet without a discoverable migration.
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'dbo.Events', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE Events (
|
||||
Id int NOT NULL IDENTITY(1,1),
|
||||
Title nvarchar(100) NOT NULL,
|
||||
Description nvarchar(max) NULL,
|
||||
Location nvarchar(150) NULL,
|
||||
RecordID int NULL,
|
||||
RecordName nvarchar(max) NULL,
|
||||
RecordTargetType nvarchar(max) NULL,
|
||||
ReminderMinutes int NOT NULL,
|
||||
EventColor nvarchar(20) NOT NULL,
|
||||
StartDate datetime2 NOT NULL,
|
||||
StartTime nvarchar(10) NULL,
|
||||
EndDate datetime2 NOT NULL,
|
||||
EndTime nvarchar(10) NULL,
|
||||
AllDay 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_Events PRIMARY KEY (Id)
|
||||
);
|
||||
END
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'dbo.Events', N'U') IS NOT NULL
|
||||
DROP TABLE Events;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts", (string)null);
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
|
||||
|
|
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("Addresses", (string)null);
|
||||
b.ToTable("Addresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
|
|
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Assets", (string)null);
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
|
||||
|
|
@ -371,62 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Categories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ServiceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("TemplateUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceKey", "IsActive")
|
||||
.HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive");
|
||||
|
||||
b.ToTable("CompletionDocTemplates", (string)null);
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
|
||||
|
|
@ -493,7 +438,61 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkerOrderId");
|
||||
|
||||
b.ToTable("Comments", (string)null);
|
||||
b.ToTable("Comments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ServiceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("TemplateUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceKey", "IsActive");
|
||||
|
||||
b.ToTable("CompletionDocTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
|
||||
|
|
@ -559,7 +558,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("ContactDetails", (string)null);
|
||||
b.ToTable("ContactDetails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
|
||||
|
|
@ -651,7 +650,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Contacts", (string)null);
|
||||
b.ToTable("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
|
||||
|
|
@ -669,7 +668,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Departments", (string)null);
|
||||
b.ToTable("Departments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
|
||||
|
|
@ -739,9 +738,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Status")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TechPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("VendorNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -760,7 +767,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("Dispatches", (string)null);
|
||||
b.ToTable("Dispatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
|
||||
|
|
@ -819,7 +826,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchChecklistItems", (string)null);
|
||||
b.ToTable("DispatchChecklistItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
|
||||
|
|
@ -873,7 +880,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.ToTable("DispatchSignoffs", (string)null);
|
||||
b.ToTable("DispatchSignoffs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
|
||||
|
|
@ -943,7 +950,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
|
||||
b.ToTable("DispatchUpliftRequests", (string)null);
|
||||
b.ToTable("DispatchUpliftRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
|
|
@ -966,7 +973,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchWorkOrders", (string)null);
|
||||
b.ToTable("DispatchWorkOrders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
|
||||
|
|
@ -996,7 +1003,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DropdownOptions", (string)null);
|
||||
b.ToTable("DropdownOptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
|
||||
|
|
@ -1101,7 +1108,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("RegionId");
|
||||
|
||||
b.ToTable("Employees", (string)null);
|
||||
b.ToTable("Employees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
|
||||
|
|
@ -1141,7 +1148,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("EmployeeId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("EmployeeAddresses", (string)null);
|
||||
b.ToTable("EmployeeAddresses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
|
||||
|
|
@ -1169,7 +1176,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeeEmails", (string)null);
|
||||
b.ToTable("EmployeeEmails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
|
||||
|
|
@ -1199,7 +1206,87 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeePhones", (string)null);
|
||||
b.ToTable("EmployeePhones");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AllDay")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeleterUserId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("EndTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("EventColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("LastModifierUserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("nvarchar(150)");
|
||||
|
||||
b.Property<int?>("RecordID")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RecordName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RecordTargetType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ReminderMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("StartTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("createdby")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
|
||||
|
|
@ -1267,7 +1354,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("FollowUps", (string)null);
|
||||
b.ToTable("FollowUps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
|
||||
|
|
@ -1292,7 +1379,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ForgetPasswordCodes", (string)null);
|
||||
b.ToTable("ForgetPasswordCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
|
||||
|
|
@ -1310,7 +1397,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobTitles", (string)null);
|
||||
b.ToTable("JobTitles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||
|
|
@ -1380,7 +1467,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Locations", (string)null);
|
||||
b.ToTable("Locations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
|
||||
|
|
@ -1438,7 +1525,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("PMSchedules", (string)null);
|
||||
b.ToTable("PMSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
|
||||
|
|
@ -1512,7 +1599,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("Quotes", (string)null);
|
||||
b.ToTable("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
|
||||
|
|
@ -1560,7 +1647,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("QuoteId");
|
||||
|
||||
b.ToTable("QuotesLines", (string)null);
|
||||
b.ToTable("QuotesLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
|
||||
|
|
@ -1578,7 +1665,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Regions", (string)null);
|
||||
b.ToTable("Regions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
|
||||
|
|
@ -1621,7 +1708,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaskListTemplates", (string)null);
|
||||
b.ToTable("TaskListTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
|
|
@ -1666,7 +1753,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("TaskListTemplateId");
|
||||
|
||||
b.ToTable("TaskListTemplateItems", (string)null);
|
||||
b.ToTable("TaskListTemplateItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||
|
|
@ -1768,7 +1855,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Templates", (string)null);
|
||||
b.ToTable("Templates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
|
|
@ -1832,7 +1919,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Vendors", (string)null);
|
||||
b.ToTable("Vendors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
|
||||
|
|
@ -1891,7 +1978,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAccessTokens", (string)null);
|
||||
b.ToTable("VendorAccessTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
|
||||
|
|
@ -1963,8 +2050,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ExtraServices")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("FlagColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("nvarchar(7)");
|
||||
|
||||
b.Property<string>("InternalWONumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<bool?>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
|
@ -1996,6 +2092,18 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("PO")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PocName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("PocNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<string>("PocPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int?>("PrimaryDispatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
|
@ -2029,6 +2137,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ServiceNotes")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
@ -2042,7 +2154,8 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SiteCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
|
@ -2062,12 +2175,20 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<DateOnly?>("TargetWeek")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("TechPhone")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Trade")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("VendorNTE")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("VendorNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<int?>("WorkOrderType")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
|
@ -2087,8 +2208,6 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AssignTo");
|
||||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("ExternalWorkOrderId")
|
||||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
|
@ -2096,9 +2215,6 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("InternalWONumber")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("PrimaryDispatchId");
|
||||
|
|
@ -2110,7 +2226,12 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
b.ToTable("workOrders", (string)null);
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("LifecycleStatus", "ScheduledDate")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
b.ToTable("workOrders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||
|
|
@ -2155,7 +2276,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderAttachments", (string)null);
|
||||
b.ToTable("workOrderAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
|
||||
|
|
@ -2205,7 +2326,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("WorkOrderAuditLogs", (string)null);
|
||||
b.ToTable("WorkOrderAuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
|
||||
|
|
@ -2249,7 +2370,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderCategories", (string)null);
|
||||
b.ToTable("workOrderCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
|
||||
|
|
@ -2296,7 +2417,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("WorkOrderContacts", (string)null);
|
||||
b.ToTable("WorkOrderContacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
|
|
@ -2325,7 +2446,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "FieldName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderFieldLocks", (string)null);
|
||||
b.ToTable("WorkOrderFieldLocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
|
|
@ -2354,7 +2475,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
b.ToTable("WorkOrderWeekRolledLedgers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
|
|
@ -2923,10 +3044,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("WorkOrder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
|
||||
.WithMany()
|
||||
.WithMany("FieldLocks")
|
||||
.HasForeignKey("WorkOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
|
@ -2934,10 +3055,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Navigation("WorkOrder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
|
||||
.WithMany("FieldLocks")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkOrderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ namespace Data.SeaHavenIndustries
|
|||
public string? VerifiedBy { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>Tech/crew phone for this dispatch (overrides Vendor.Phone on board).</summary>
|
||||
[MaxLength(50)]
|
||||
public string? TechPhone { get; set; }
|
||||
/// <summary>Instructions for the vendor for this appointment/dispatch.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? VendorNotes { get; set; }
|
||||
|
||||
[Timestamp]
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ namespace Data.SeaHavenIndustries
|
|||
public class WorkOrder : FullAuditEntity
|
||||
{
|
||||
// --- Core slice ---
|
||||
[MaxLength(450)]
|
||||
public string? InternalWONumber { get; set; }
|
||||
[MaxLength(450)]
|
||||
public string? ExternalWorkOrderId { get; set; }
|
||||
public string? WorkerOrderNumber { get; set; }
|
||||
public string? WorkerOrderTitle { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Customer { get; set; }
|
||||
[MaxLength(450)]
|
||||
public string? SiteCode { get; set; }
|
||||
public string? Building { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
|
|
@ -27,11 +30,31 @@ namespace Data.SeaHavenIndustries
|
|||
public string? LegacyStatus { get; set; }
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? Service { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||
|
||||
// --- Service & Notes / POC (structured create) ---
|
||||
[MaxLength(200)]
|
||||
public string? PocName { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string? PocPhone { get; set; }
|
||||
[MaxLength(2000)]
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? ExtraServices { get; set; }
|
||||
[MaxLength(4000)]
|
||||
public string? ServiceNotes { get; set; }
|
||||
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
|
||||
[MaxLength(50)]
|
||||
public string? TechPhone { get; set; }
|
||||
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? VendorNotes { get; set; }
|
||||
|
||||
// --- Scheduling slice ---
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
|
|
@ -51,6 +74,10 @@ namespace Data.SeaHavenIndustries
|
|||
// --- Completion slice ---
|
||||
public DocStatus? DocStatus { get; set; }
|
||||
|
||||
/// <summary>Board flag color (#RRGGBB from fixed palette). Null = no flag.</summary>
|
||||
[MaxLength(7)]
|
||||
public string? FlagColor { get; set; }
|
||||
|
||||
// --- Legacy SQL columns (drift reconciliation) ---
|
||||
public string? AvettaTask { get; set; }
|
||||
public DateOnly? AssignDate { get; set; }
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.WorkOrderType,
|
||||
w.SiteCode,
|
||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||
Poc = w.WorkOrderContacts!
|
||||
WoPocName = w.PocName,
|
||||
WoPocPhone = w.PocPhone,
|
||||
WoPocNotes = w.PocNotes,
|
||||
ContactPoc = w.WorkOrderContacts!
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new
|
||||
{
|
||||
|
|
@ -52,55 +55,73 @@ 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.FlagColor,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null
|
||||
})
|
||||
.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.FlagColor,
|
||||
w.PrimaryDispatchId,
|
||||
w.RowVersion,
|
||||
w.DispatchRowVersion,
|
||||
isUnscheduled);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,16 +37,49 @@ namespace SeaHaven.DataServices.Helpers
|
|||
return query.Where(w => w.AssignTo != null && ids.Contains(w.AssignTo));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyTypeFilter(
|
||||
/// <summary>
|
||||
/// FE sends <see cref="WorkOrderType.Other"/> (99) as the overdue/isPastDue sentinel — not a real type filter.
|
||||
/// </summary>
|
||||
public static IQueryable<WorkOrder> ApplyTypeAndOverdueFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<WorkOrderType>? types)
|
||||
IReadOnlyList<WorkOrderType>? types,
|
||||
DateTime? utcNow = null)
|
||||
{
|
||||
if (types == null || types.Count == 0)
|
||||
return query;
|
||||
|
||||
return query.Where(w => w.WorkOrderType != null && types.Contains(w.WorkOrderType.Value));
|
||||
var today = (utcNow ?? DateTime.UtcNow).Date;
|
||||
var includeOverdue = types.Contains(WorkOrderType.Other);
|
||||
var typeFilters = types.Where(t => t != WorkOrderType.Other).ToList();
|
||||
|
||||
// Keep past-due predicate inline — EF cannot translate local method calls.
|
||||
if (includeOverdue && typeFilters.Count == 0)
|
||||
{
|
||||
return query.Where(w =>
|
||||
w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date < today
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled);
|
||||
}
|
||||
|
||||
if (includeOverdue)
|
||||
{
|
||||
return query.Where(w =>
|
||||
(w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date < today
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|
||||
|| (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)));
|
||||
}
|
||||
|
||||
return query.Where(w => w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value));
|
||||
}
|
||||
|
||||
public static IQueryable<WorkOrder> ApplyTypeFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<WorkOrderType>? types)
|
||||
=> ApplyTypeAndOverdueFilter(query, types);
|
||||
|
||||
public static IQueryable<WorkOrder> ApplySiteFilter(
|
||||
IQueryable<WorkOrder> query,
|
||||
IReadOnlyList<string>? sites)
|
||||
|
|
@ -132,7 +165,10 @@ namespace SeaHaven.DataServices.Helpers
|
|||
|| (w.ScheduleWeekOnly == true
|
||||
&& w.TargetWeek != null
|
||||
&& w.TargetWeek >= dateFrom
|
||||
&& w.TargetWeek <= dateTo));
|
||||
&& w.TargetWeek <= dateTo)
|
||||
|| (w.ScheduledDate == null
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
|
|
@ -5,6 +7,10 @@ namespace SeaHaven.DataServices.Helpers
|
|||
{
|
||||
public static class WorkOrderBoardSearchFilter
|
||||
{
|
||||
private static readonly Regex PartialDatePattern = new(
|
||||
@"^\d{1,2}/\d{1,2}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public static string? NormalizeSearch(string? search)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(search))
|
||||
|
|
@ -21,10 +27,23 @@ namespace SeaHaven.DataServices.Helpers
|
|||
return query;
|
||||
|
||||
var s = normalized.ToLower();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var exactDate = TryParseExactDate(normalized);
|
||||
var hasExactDate = exactDate.HasValue;
|
||||
var exactDateValue = exactDate?.Date ?? default;
|
||||
|
||||
var partialMonthDay = TryParsePartialMonthDay(normalized);
|
||||
var hasPartialMonthDay = partialMonthDay.HasValue;
|
||||
var partialMonth = partialMonthDay?.Month ?? 0;
|
||||
var partialDay = partialMonthDay?.Day ?? 0;
|
||||
|
||||
// Keep predicates inline — EF Core cannot translate local method calls.
|
||||
return query.Where(w =>
|
||||
(w.SiteCode ?? "").ToLower().Contains(s)
|
||||
|| (w.InternalWONumber ?? "").ToLower().Contains(s)
|
||||
|| (w.WorkerOrderNumber ?? "").ToLower().Contains(s)
|
||||
|| (w.WorkerOrderTitle ?? "").ToLower().Contains(s)
|
||||
|| (w.Description ?? "").ToLower().Contains(s)
|
||||
|| (w.Locations != null && (w.Locations.Name ?? "").ToLower().Contains(s))
|
||||
|| (w.AssignToUser != null && ((w.AssignToUser.FirstName ?? "") + " " + (w.AssignToUser.LastName ?? "")).ToLower().Contains(s))
|
||||
|| (w.Trade ?? "").ToLower().Contains(s)
|
||||
|
|
@ -32,6 +51,19 @@ namespace SeaHaven.DataServices.Helpers
|
|||
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.CompanyName ?? "").ToLower().Contains(s))
|
||||
|| (w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null && (w.PrimaryDispatch.Vendor.ContactName ?? "").ToLower().Contains(s))
|
||||
|| (w.Status ?? "").ToLower().Contains(s)
|
||||
|| (w.WorkOrderType == WorkOrderType.PO && "po".Contains(s))
|
||||
|| (w.WorkOrderType == WorkOrderType.PM && "pm".Contains(s))
|
||||
|| (w.WorkOrderType == WorkOrderType.Emergency && "emergency".Contains(s))
|
||||
|| (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s))
|
||||
|| (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s))
|
||||
|| (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s))
|
||||
|| (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s)))
|
||||
|| (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s))
|
||||
|| ("overdue".Contains(s)
|
||||
&& w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Date < today
|
||||
&& w.LifecycleStatus != LifecycleStatus.Completed
|
||||
&& w.LifecycleStatus != LifecycleStatus.Canceled)
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Incomplete && "incomplete".Contains(s))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Pending && ("pending".Contains(s) || "on hold".Contains(s) || "onhold".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Scheduled && "scheduled".Contains(s))
|
||||
|
|
@ -41,7 +73,61 @@ namespace SeaHaven.DataServices.Helpers
|
|||
|| (w.LifecycleStatus == LifecycleStatus.Completed && ("completed".Contains(s) || "complete".Contains(s) || "done".Contains(s) || "closed".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Rescheduled && "rescheduled".Contains(s))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.Canceled && ("canceled".Contains(s) || "cancelled".Contains(s)))
|
||||
|| (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s))));
|
||||
|| (w.LifecycleStatus == LifecycleStatus.PendingQuote && ("pending quote".Contains(s) || "pendingquote".Contains(s) || "pending approval".Contains(s) || "pendingapproval".Contains(s)))
|
||||
|| (hasExactDate
|
||||
&& ((w.ScheduledDate != null && w.ScheduledDate.Value.Date == exactDateValue)
|
||||
|| (w.DueDate != null && w.DueDate.Value.Date == exactDateValue)))
|
||||
|| (hasPartialMonthDay
|
||||
&& ((w.ScheduledDate != null
|
||||
&& w.ScheduledDate.Value.Month == partialMonth
|
||||
&& w.ScheduledDate.Value.Day == partialDay)
|
||||
|| (w.DueDate != null
|
||||
&& w.DueDate.Value.Month == partialMonth
|
||||
&& w.DueDate.Value.Day == partialDay))));
|
||||
}
|
||||
|
||||
internal static DateTime? TryParseExactDate(string search)
|
||||
{
|
||||
if (PartialDatePattern.IsMatch(search))
|
||||
return null;
|
||||
|
||||
var formats = new[]
|
||||
{
|
||||
"yyyy-MM-dd",
|
||||
"MM/dd/yyyy",
|
||||
"M/d/yyyy",
|
||||
"MM/dd/yy",
|
||||
"M/d/yy"
|
||||
};
|
||||
|
||||
if (DateTime.TryParseExact(search, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
||||
return parsed.Date;
|
||||
|
||||
if (DateTime.TryParse(search, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed))
|
||||
return parsed.Date;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static (int Month, int Day)? TryParsePartialMonthDay(string search)
|
||||
{
|
||||
if (!PartialDatePattern.IsMatch(search))
|
||||
return null;
|
||||
|
||||
var parts = search.Split('/');
|
||||
if (parts.Length != 2)
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var month))
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var day))
|
||||
return null;
|
||||
|
||||
if (month is < 1 or > 12 || day is < 1 or > 31)
|
||||
return null;
|
||||
|
||||
return (month, day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SiteOptionRow>> 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<Locations> AddAsync(Locations location)
|
||||
{
|
||||
location.CreatedDate = DateTime.Now;
|
||||
|
|
|
|||
|
|
@ -33,5 +33,27 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
return await _context.Users.AnyAsync(u => u.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids)
|
||||
{
|
||||
var idList = ids
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (idList.Count == 0)
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
var users = await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => idList.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FirstName, u.LastName })
|
||||
.ToListAsync();
|
||||
|
||||
return users.ToDictionary(
|
||||
u => u.Id,
|
||||
u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
|
||||
var paged = sorted
|
||||
.Skip((query.Page - 1) * query.PageSize)
|
||||
.Skip(query.Page * query.PageSize)
|
||||
.Take(query.PageSize);
|
||||
|
||||
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(paged, isUnscheduled: false);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Locations?> GetByIdAsync(int id);
|
||||
|
|
@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
// For addressbook endpoint - returns simplified DTO
|
||||
Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null);
|
||||
|
||||
Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null);
|
||||
|
||||
Task<Locations> AddAsync(Locations location);
|
||||
Task UpdateAsync(Locations location);
|
||||
Task DeleteAsync(int id);
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<IEnumerable<ApplicationUser>> GetAllAsync();
|
||||
Task<ApplicationUser?> GetByEmailAsync(string email);
|
||||
Task<bool> ExistsAsync(string id);
|
||||
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
DateTime? DispatchApptDate,
|
||||
string? Trade,
|
||||
string? Problem,
|
||||
string? ServiceNotes,
|
||||
string? ExtraServices,
|
||||
DocStatus? DocStatus,
|
||||
string? FlagColor,
|
||||
int? PrimaryDispatchId,
|
||||
byte[]? RowVersion,
|
||||
byte[]? DispatchRowVersion,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ public class PagedResultTests
|
|||
{
|
||||
[Theory]
|
||||
[InlineData(0, 10, 0)]
|
||||
[InlineData(1, 10, 1)]
|
||||
[InlineData(10, 10, 1)]
|
||||
[InlineData(25, 10, 3)]
|
||||
[InlineData(11, 10, 2)]
|
||||
[InlineData(100, 25, 4)]
|
||||
public void TotalPages_IsCeilingOfCountOverPageSize(int totalCount, int pageSize, int expectedPages)
|
||||
{
|
||||
|
|
@ -19,16 +20,32 @@ public class PagedResultTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward()
|
||||
public void HasPrevious_IsFalseOnFirstPage_TrueAfterward_OneBased()
|
||||
{
|
||||
new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeFalse();
|
||||
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasPrevious.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage()
|
||||
public void HasNext_IsTrueBeforeLastPage_FalseOnLastPage_OneBased()
|
||||
{
|
||||
new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeTrue();
|
||||
new PagedResult<string> { Page = 3, PageSize = 10, TotalCount = 30 }.HasNext.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPrevious_And_HasNext_ZeroBased_WhenPageBaseIsZero()
|
||||
{
|
||||
var first = new PagedResult<string> { Page = 0, PageSize = 10, TotalCount = 30, PageBase = 0 };
|
||||
first.HasPrevious.Should().BeFalse();
|
||||
first.HasNext.Should().BeTrue();
|
||||
|
||||
var middle = new PagedResult<string> { Page = 1, PageSize = 10, TotalCount = 30, PageBase = 0 };
|
||||
middle.HasPrevious.Should().BeTrue();
|
||||
middle.HasNext.Should().BeTrue();
|
||||
|
||||
var last = new PagedResult<string> { Page = 2, PageSize = 10, TotalCount = 30, PageBase = 0 };
|
||||
last.HasPrevious.Should().BeTrue();
|
||||
last.HasNext.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,17 @@ namespace SeaHaven.Services.DTOs
|
|||
public int TotalCount { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||
public bool HasPrevious => Page > 1;
|
||||
public bool HasNext => Page < TotalPages;
|
||||
|
||||
/// <summary>
|
||||
/// Page numbering base for <see cref="HasPrevious"/> / <see cref="HasNext"/>.
|
||||
/// Default <c>1</c> (classic APIs). Work-order advanced search sets <c>0</c>.
|
||||
/// </summary>
|
||||
public int PageBase { get; set; } = 1;
|
||||
|
||||
public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||
|
||||
public bool HasPrevious => PageBase <= 0 ? Page > 0 : Page > 1;
|
||||
|
||||
public bool HasNext => PageBase <= 0 ? Page + 1 < TotalPages : Page < TotalPages;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? ApptDate { get; set; }
|
||||
public string? ApptTime { get; set; }
|
||||
public string? Pm { get; set; }
|
||||
public string? ServiceNotes { get; set; }
|
||||
public List<string>? ExtraServices { get; set; }
|
||||
public DocStatus? DocStatus { get; set; }
|
||||
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
|
||||
public string? FlagColor { get; set; }
|
||||
public int? PrimaryDispatchId { get; set; }
|
||||
public byte[]? RowVersion { get; set; }
|
||||
public byte[]? DispatchRowVersion { get; set; }
|
||||
|
|
|
|||
|
|
@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers;
|
|||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
/// <summary>Payload for creating a board work order (SHOC create wizard).</summary>
|
||||
/// <summary>
|
||||
/// Payload for creating a board work order (SHOC create wizard).
|
||||
/// FE follow-up: extend WorkOrderBoardCreatePayload + mapper with PrimaryService,
|
||||
/// ExtraServices, ServiceNotes, PocName, PocPhone, VendorNotes, TechPhone; stop embedding
|
||||
/// POC/vendor/service notes in description (pocNotes already exists). Prefer structured
|
||||
/// fields; keep Trade+Description temporarily for legacy clients.
|
||||
/// </summary>
|
||||
public class WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
public string? WoNumber { get; set; }
|
||||
public WorkOrderType WorkOrderType { get; set; }
|
||||
public string? SiteCode { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>Legacy alias for primary service; prefer <see cref="PrimaryService"/>.</summary>
|
||||
public string? Trade { get; set; }
|
||||
/// <summary>Primary service label; persisted as Trade / board pm.</summary>
|
||||
public string? PrimaryService { get; set; }
|
||||
public List<string>? ExtraServices { get; set; }
|
||||
public string? ServiceNotes { get; set; }
|
||||
public int? LocationId { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string? AssignTo { get; set; }
|
||||
|
|
@ -22,14 +33,18 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateTime? ApptDate { get; set; }
|
||||
public string? ApptTime { get; set; }
|
||||
public int? PocContactId { get; set; }
|
||||
public string? PocName { get; set; }
|
||||
public string? PocPhone { get; set; }
|
||||
public string? PocNotes { get; set; }
|
||||
public string? TechPhone { get; set; }
|
||||
public string? VendorNotes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Advanced search / filter query for the board.</summary>
|
||||
public class WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 50;
|
||||
public int Page { get; set; } = 0;
|
||||
public int PageSize { get; set; } = 100;
|
||||
public string? Search { get; set; }
|
||||
public WorkOrderAdvancedSearchDatePreset? DatePreset { get; set; }
|
||||
public DateOnly? DateFrom { get; set; }
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public DateOnly? OriginalWeek { get; set; }
|
||||
public DateOnly? OriginalDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? VendorNotes { get; set; }
|
||||
}
|
||||
|
||||
public class CompletionDocTemplateDto
|
||||
|
|
@ -63,6 +64,7 @@ namespace SeaHaven.Services.DTOs
|
|||
{
|
||||
public int Id { get; set; }
|
||||
public string? AuthorId { get; set; }
|
||||
public string? AuthorName { get; set; }
|
||||
public string Text { get; set; } = "";
|
||||
public string Time { get; set; } = "";
|
||||
public string? Documents { get; set; }
|
||||
|
|
|
|||
|
|
@ -5,23 +5,69 @@ namespace SeaHaven.Services.Helpers
|
|||
{
|
||||
public static class WorkOrderAuditProjection
|
||||
{
|
||||
public static WorkOrderAuditEntryDto ToDto(WorkOrderAuditLog log)
|
||||
private const string UnassignedLabel = "Unassigned";
|
||||
|
||||
public static WorkOrderAuditEntryDto ToDto(
|
||||
WorkOrderAuditLog log,
|
||||
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
|
||||
{
|
||||
var oldValue = log.OldValue ?? "";
|
||||
var newValue = log.NewValue ?? "";
|
||||
|
||||
if (IsAssignToField(log.FieldName))
|
||||
{
|
||||
oldValue = ResolveAssignToDisplay(oldValue, assignToDisplayNames);
|
||||
newValue = ResolveAssignToDisplay(newValue, assignToDisplayNames);
|
||||
}
|
||||
|
||||
return new WorkOrderAuditEntryDto
|
||||
{
|
||||
Type = MapEventType(log.EventType),
|
||||
DispatcherId = log.UserId,
|
||||
Action = log.Action ?? "",
|
||||
FieldName = log.FieldName,
|
||||
OldValue = log.OldValue ?? "",
|
||||
NewValue = log.NewValue ?? "",
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Time = log.CreatedAt.ToString("o"),
|
||||
DispatchId = log.DispatchId
|
||||
};
|
||||
}
|
||||
|
||||
public static List<WorkOrderAuditEntryDto> ToDtos(IEnumerable<WorkOrderAuditLog> logs)
|
||||
=> logs.Select(ToDto).ToList();
|
||||
public static List<WorkOrderAuditEntryDto> ToDtos(
|
||||
IEnumerable<WorkOrderAuditLog> logs,
|
||||
IReadOnlyDictionary<string, string>? assignToDisplayNames = null)
|
||||
=> logs.Select(log => ToDto(log, assignToDisplayNames)).ToList();
|
||||
|
||||
public static IEnumerable<string> CollectAssignToUserIds(IEnumerable<WorkOrderAuditLog> logs)
|
||||
{
|
||||
return logs
|
||||
.Where(l => IsAssignToField(l.FieldName))
|
||||
.SelectMany(l => new[] { l.OldValue, l.NewValue })
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v!)
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal static bool IsAssignToField(string? fieldName)
|
||||
=> string.Equals(fieldName, "AssignTo", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(fieldName, "AssignedTo", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string ResolveAssignToDisplay(
|
||||
string value,
|
||||
IReadOnlyDictionary<string, string>? assignToDisplayNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return UnassignedLabel;
|
||||
|
||||
if (assignToDisplayNames != null
|
||||
&& assignToDisplayNames.TryGetValue(value, out var name)
|
||||
&& !string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string MapEventType(string? eventType)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ 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 FlagColor = "flagColor";
|
||||
public const string RescheduleCount = "RescheduleCount";
|
||||
|
||||
private static readonly HashSet<string> DispatchFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
|
|
@ -38,7 +42,11 @@ namespace SeaHaven.Services.Helpers
|
|||
VendorId,
|
||||
ApptDate,
|
||||
ApptTime,
|
||||
DocStatus
|
||||
DocStatus,
|
||||
Pm,
|
||||
ServiceNotes,
|
||||
ExtraServices,
|
||||
FlagColor
|
||||
};
|
||||
|
||||
public static bool IsEditable(string field) => Canonicalize(field) != null;
|
||||
|
|
@ -75,6 +83,10 @@ namespace SeaHaven.Services.Helpers
|
|||
ApptDate => "ApptDate",
|
||||
ApptTime => "ApptTime",
|
||||
DocStatus => "DocStatus",
|
||||
Pm => "Trade",
|
||||
ServiceNotes => "ServiceNotes",
|
||||
ExtraServices => "ExtraServices",
|
||||
FlagColor => "FlagColor",
|
||||
_ => canonical
|
||||
};
|
||||
}
|
||||
|
|
|
|||
29
SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs
Normal file
29
SeaHaven.Services/Helpers/WorkOrderBoardSortFields.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderBoardSortFields
|
||||
{
|
||||
public static readonly IReadOnlySet<string> Allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"scheduledDate",
|
||||
"woNumber",
|
||||
"dueDate"
|
||||
};
|
||||
|
||||
public static string NormalizeOrThrow(string? sortBy)
|
||||
{
|
||||
var field = (sortBy ?? "scheduledDate").Trim();
|
||||
if (!Allowed.Contains(field))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"sortBy must be one of: scheduledDate, woNumber, dueDate.");
|
||||
}
|
||||
|
||||
return field.ToLowerInvariant() switch
|
||||
{
|
||||
"wonumber" => "woNumber",
|
||||
"duedate" => "dueDate",
|
||||
_ => "scheduledDate"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,19 +5,48 @@ namespace SeaHaven.Services.Helpers
|
|||
{
|
||||
public static class WorkOrderCommentProjection
|
||||
{
|
||||
public static WorkOrderCommentDto ToDto(Comments comment)
|
||||
public static WorkOrderCommentDto ToDto(
|
||||
Comments comment,
|
||||
IReadOnlyDictionary<string, string>? displayNames = null)
|
||||
{
|
||||
return new WorkOrderCommentDto
|
||||
{
|
||||
Id = comment.Id,
|
||||
AuthorId = comment.UserId,
|
||||
AuthorName = ResolveAuthorName(comment, displayNames),
|
||||
Text = comment.Commenttext ?? "",
|
||||
Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"),
|
||||
Documents = comment.Documents
|
||||
};
|
||||
}
|
||||
|
||||
public static List<WorkOrderCommentDto> ToDtos(IEnumerable<Comments> comments)
|
||||
=> comments.Select(ToDto).ToList();
|
||||
public static List<WorkOrderCommentDto> ToDtos(
|
||||
IEnumerable<Comments> comments,
|
||||
IReadOnlyDictionary<string, string>? displayNames = null)
|
||||
=> comments.Select(c => ToDto(c, displayNames)).ToList();
|
||||
|
||||
public static IEnumerable<string> CollectAuthorUserIds(IEnumerable<Comments> comments)
|
||||
=> comments
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.UserId))
|
||||
.Select(c => c.UserId!)
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
|
||||
private static string? ResolveAuthorName(
|
||||
Comments comment,
|
||||
IReadOnlyDictionary<string, string>? displayNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(comment.UserId)
|
||||
&& displayNames != null
|
||||
&& displayNames.TryGetValue(comment.UserId, out var name)
|
||||
&& !string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(comment.Commenter))
|
||||
return comment.Commenter.Trim();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
SeaHaven.Services/Helpers/WorkOrderFlagColors.cs
Normal file
50
SeaHaven.Services/Helpers/WorkOrderFlagColors.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed board flag palette (must match FE FLAG_COLORS). Values stored as canonical #RRGGBB.
|
||||
/// </summary>
|
||||
public static class WorkOrderFlagColors
|
||||
{
|
||||
public static readonly HashSet<string> Allowed = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"#EF4444",
|
||||
"#F97316",
|
||||
"#F59E0B",
|
||||
"#EAB308",
|
||||
"#22C55E",
|
||||
"#14B8A6",
|
||||
"#3B82F6",
|
||||
"#8B5CF6"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes to # + 6 uppercase hex digits, or null if empty.
|
||||
/// Returns false if the value is non-empty but not a valid hex color.
|
||||
/// </summary>
|
||||
public static bool TryNormalize(string? value, out string? canonical)
|
||||
{
|
||||
canonical = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return true;
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length == 6 && trimmed[0] != '#')
|
||||
trimmed = "#" + trimmed;
|
||||
|
||||
if (trimmed.Length != 7 || trimmed[0] != '#')
|
||||
return false;
|
||||
|
||||
for (var i = 1; i < 7; i++)
|
||||
{
|
||||
if (!Char.IsAsciiHexDigit(trimmed[i]))
|
||||
return false;
|
||||
}
|
||||
|
||||
canonical = "#" + trimmed[1..].ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsAllowed(string? canonical)
|
||||
=> canonical != null && Allowed.Contains(canonical);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,19 +56,19 @@ namespace SeaHaven.Services.Helpers
|
|||
private static WorkOrderSearchDateRange ThisIsoWeek(DateOnly today)
|
||||
{
|
||||
var start = GetIsoWeekStart(today);
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(4));
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange LastIsoWeek(DateOnly today)
|
||||
{
|
||||
var start = GetIsoWeekStart(today).AddDays(-7);
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(4));
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange NextIsoWeek(DateOnly today)
|
||||
{
|
||||
var start = GetIsoWeekStart(today).AddDays(7);
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(6));
|
||||
return new WorkOrderSearchDateRange(start, start.AddDays(4));
|
||||
}
|
||||
|
||||
private static WorkOrderSearchDateRange ThisMonth(DateOnly today)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SiteOptionDTO>> 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<LocationDTO> CreateLocationAsync(CreateLocationDTO dto, string userId)
|
||||
{
|
||||
var validationResult = await _createValidator.ValidateAsync(dto);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
public class WorkOrderAdvancedSearchService : IWorkOrderAdvancedSearchService
|
||||
{
|
||||
private const int DefaultPageSize = 50;
|
||||
private const int DefaultPageSize = 100;
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
private readonly IWorkOrderAdvancedSearchDataService _searchDataService;
|
||||
|
|
@ -22,7 +22,7 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderAdvancedSearchQueryDto query,
|
||||
string? currentUserId)
|
||||
{
|
||||
var page = query.Page < 1 ? 1 : query.Page;
|
||||
var page = query.Page < 0 ? 0 : query.Page;
|
||||
var pageSize = query.PageSize < 1 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
|
||||
|
||||
var dateRange = WorkOrderSearchDateRangeResolver.Resolve(
|
||||
|
|
@ -30,6 +30,8 @@ namespace SeaHaven.Services.Implementation
|
|||
query.DateFrom,
|
||||
query.DateTo);
|
||||
|
||||
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
|
||||
|
||||
var dataQuery = new WorkOrderAdvancedSearchQuery(
|
||||
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
|
||||
dateRange.From,
|
||||
|
|
@ -45,7 +47,7 @@ namespace SeaHaven.Services.Implementation
|
|||
currentUserId,
|
||||
page,
|
||||
pageSize,
|
||||
query.SortBy ?? "scheduledDate",
|
||||
sortBy,
|
||||
query.SortDir ?? "asc");
|
||||
|
||||
var result = await _searchDataService.SearchAsync(dataQuery);
|
||||
|
|
@ -56,7 +58,8 @@ namespace SeaHaven.Services.Implementation
|
|||
Items = result.Rows.Select(r => WorkOrderBoardService.MapRawRow(r, utcNow)).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize
|
||||
PageSize = pageSize,
|
||||
PageBase = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Text.Json;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentValidation;
|
||||
|
|
@ -41,6 +42,24 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
||||
var siteCode = request.SiteCode!.Trim();
|
||||
var primaryService = ResolvePrimaryService(request);
|
||||
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||
var serviceNotes = TrimOrNull(request.ServiceNotes);
|
||||
var pocName = TrimOrNull(request.PocName);
|
||||
var pocPhone = TrimOrNull(request.PocPhone);
|
||||
var pocNotes = TrimOrNull(request.PocNotes);
|
||||
var techPhone = TrimOrNull(request.TechPhone);
|
||||
var vendorNotes = TrimOrNull(request.VendorNotes);
|
||||
var hasVendorDispatch = request.VendorId.HasValue
|
||||
|| request.ApptDate.HasValue
|
||||
|| !string.IsNullOrWhiteSpace(request.ApptTime);
|
||||
|
||||
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
||||
{
|
||||
var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == request.VendorId.Value && v.IsDeleted != true);
|
||||
if (!vendorExists)
|
||||
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
||||
}
|
||||
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
|
|
@ -49,7 +68,12 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderType = request.WorkOrderType,
|
||||
SiteCode = siteCode,
|
||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||
Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(),
|
||||
Trade = primaryService,
|
||||
ExtraServices = extraServicesJson,
|
||||
ServiceNotes = serviceNotes,
|
||||
PocName = pocName,
|
||||
PocPhone = pocPhone,
|
||||
PocNotes = pocNotes,
|
||||
LocationId = request.LocationId,
|
||||
DueDate = request.DueDate?.Date,
|
||||
AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(),
|
||||
|
|
@ -61,6 +85,12 @@ namespace SeaHaven.Services.Implementation
|
|||
istemplate = false
|
||||
};
|
||||
|
||||
if (!hasVendorDispatch)
|
||||
{
|
||||
workOrder.TechPhone = techPhone;
|
||||
workOrder.VendorNotes = vendorNotes;
|
||||
}
|
||||
|
||||
WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder);
|
||||
|
||||
var changes = new List<WorkOrderBoardFieldMutations.BoardFieldChange>
|
||||
|
|
@ -75,6 +105,20 @@ namespace SeaHaven.Services.Implementation
|
|||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description));
|
||||
if (workOrder.Trade != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade));
|
||||
if (workOrder.ExtraServices != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices));
|
||||
if (workOrder.ServiceNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes));
|
||||
if (workOrder.PocName != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName));
|
||||
if (workOrder.PocPhone != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone));
|
||||
if (workOrder.PocNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes));
|
||||
if (workOrder.TechPhone != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone));
|
||||
if (workOrder.VendorNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", workOrder.VendorNotes));
|
||||
if (workOrder.LocationId.HasValue)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString()));
|
||||
if (workOrder.DueDate.HasValue)
|
||||
|
|
@ -95,7 +139,7 @@ namespace SeaHaven.Services.Implementation
|
|||
changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date));
|
||||
|
||||
Dispatch? dispatch = null;
|
||||
if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime))
|
||||
if (hasVendorDispatch)
|
||||
{
|
||||
if (!request.VendorId.HasValue || request.VendorId.Value <= 0)
|
||||
throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields.");
|
||||
|
|
@ -105,7 +149,9 @@ namespace SeaHaven.Services.Implementation
|
|||
VendorId = request.VendorId.Value,
|
||||
Status = "Pending",
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
ScheduledDate = request.ApptDate?.Date
|
||||
ScheduledDate = request.ApptDate?.Date,
|
||||
TechPhone = techPhone,
|
||||
VendorNotes = vendorNotes
|
||||
};
|
||||
_context.Dispatches.Add(dispatch);
|
||||
workOrder.PrimaryDispatch = dispatch;
|
||||
|
|
@ -120,6 +166,11 @@ namespace SeaHaven.Services.Implementation
|
|||
if (apptChange.HasChanged)
|
||||
changes.Add(apptChange);
|
||||
}
|
||||
|
||||
if (techPhone != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", techPhone));
|
||||
if (vendorNotes != null)
|
||||
changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes));
|
||||
}
|
||||
|
||||
_context.workOrders.Add(workOrder);
|
||||
|
|
@ -140,7 +191,7 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
WorkorderId = workOrder.Id,
|
||||
ContactId = request.PocContactId,
|
||||
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
|
||||
Notes = pocNotes
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +201,51 @@ namespace SeaHaven.Services.Implementation
|
|||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||
}
|
||||
|
||||
private static string? ResolvePrimaryService(WorkOrderBoardCreateRequestDto request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.PrimaryService))
|
||||
return request.PrimaryService.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Trade))
|
||||
return request.Trade.Trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? SerializeExtraServices(List<string>? extras)
|
||||
{
|
||||
if (extras == null || extras.Count == 0)
|
||||
return null;
|
||||
|
||||
var normalized = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in extras)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item))
|
||||
continue;
|
||||
var trimmed = item.Trim();
|
||||
if (trimmed.Length > 128)
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
"Each extra service must be at most 128 characters.");
|
||||
if (!seen.Add(trimmed))
|
||||
continue;
|
||||
normalized.Add(trimmed);
|
||||
}
|
||||
|
||||
if (normalized.Count == 0)
|
||||
return null;
|
||||
|
||||
var json = JsonSerializer.Serialize(normalized);
|
||||
if (json.Length > 2000)
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
"extraServices must serialize to at most 2000 characters.");
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private async Task<string> ResolveWoNumberAsync(string? requested)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requested))
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ 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,
|
||||
FlagColor = row.FlagColor,
|
||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||
RowVersion = row.RowVersion,
|
||||
DispatchRowVersion = row.DispatchRowVersion
|
||||
|
|
@ -124,5 +127,21 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
private static string FormatName(string? firstName, string? lastName)
|
||||
=> $"{firstName ?? ""} {lastName ?? ""}".Trim();
|
||||
|
||||
private static List<string>? ParseExtraServices(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||
return list is { Count: > 0 } ? list : null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Text.Json;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -157,6 +158,10 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ApptTime => new List<FieldChange> { ApplyApptTime(workOrder, dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.DocStatus => new List<FieldChange> { ApplyDocStatus(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.Pm => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) },
|
||||
WorkOrderBoardFieldNames.ServiceNotes => new List<FieldChange> { ApplyServiceNotes(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ExtraServices => new List<FieldChange> { ApplyExtraServices(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.FlagColor => new List<FieldChange> { ApplyFlagColor(workOrder, value, auditField) },
|
||||
_ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.")
|
||||
};
|
||||
}
|
||||
|
|
@ -222,6 +227,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 +258,89 @@ namespace SeaHaven.Services.Implementation
|
|||
return FieldChange.ForField(auditField, old, normalized);
|
||||
}
|
||||
|
||||
private static FieldChange ApplyServiceNotes(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
if (normalized != null && normalized.Length > 4000)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "serviceNotes must be at most 4000 characters.");
|
||||
|
||||
var old = workOrder.ServiceNotes;
|
||||
if (old == normalized)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.ServiceNotes = normalized;
|
||||
return FieldChange.ForField(auditField, old, normalized);
|
||||
}
|
||||
|
||||
private static FieldChange ApplyExtraServices(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
string? normalizedJson = null;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
List<string>? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize<List<string>>(value.Trim());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "extraServices must be a JSON string array.");
|
||||
}
|
||||
|
||||
var items = NormalizeExtraServiceList(parsed);
|
||||
if (items.Count > 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(workOrder.Trade))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
"primaryService (pm/trade) is required when extraServices is provided.");
|
||||
}
|
||||
|
||||
items = items
|
||||
.Where(s => !string.Equals(s, workOrder.Trade, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
normalizedJson = items.Count > 0 ? JsonSerializer.Serialize(items) : null;
|
||||
if (normalizedJson != null && normalizedJson.Length > 2000)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
"extraServices must serialize to at most 2000 characters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var old = workOrder.ExtraServices;
|
||||
if (old == normalizedJson)
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.ExtraServices = normalizedJson;
|
||||
return FieldChange.ForField(auditField, old, normalizedJson);
|
||||
}
|
||||
|
||||
private static List<string> NormalizeExtraServiceList(List<string>? extras)
|
||||
{
|
||||
var normalized = new List<string>();
|
||||
if (extras == null || extras.Count == 0)
|
||||
return normalized;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in extras)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item))
|
||||
continue;
|
||||
var trimmed = item.Trim();
|
||||
if (trimmed.Length > 128)
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "Each extra service must be at most 128 characters.");
|
||||
if (!seen.Add(trimmed))
|
||||
continue;
|
||||
normalized.Add(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value);
|
||||
|
|
@ -456,6 +549,24 @@ namespace SeaHaven.Services.Implementation
|
|||
return FieldChange.ForField(auditField, old, parsed.ToString());
|
||||
}
|
||||
|
||||
private static FieldChange ApplyFlagColor(WorkOrder workOrder, string? value, string auditField)
|
||||
{
|
||||
if (!WorkOrderFlagColors.TryNormalize(value, out var canonical))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid flagColor: {value}");
|
||||
|
||||
if (canonical != null && !WorkOrderFlagColors.IsAllowed(canonical))
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"InvalidValue",
|
||||
$"flagColor must be one of the allowed palette values or empty to clear.");
|
||||
|
||||
var old = workOrder.FlagColor;
|
||||
if (string.Equals(old, canonical, StringComparison.OrdinalIgnoreCase))
|
||||
return FieldChange.Unchanged(auditField);
|
||||
|
||||
workOrder.FlagColor = canonical;
|
||||
return FieldChange.ForField(auditField, old, canonical);
|
||||
}
|
||||
|
||||
private static List<FieldChange> ApplyAutoScheduleSideEffects(WorkOrder workOrder)
|
||||
{
|
||||
var changes = new List<FieldChange>();
|
||||
|
|
|
|||
|
|
@ -11,13 +11,16 @@ namespace SeaHaven.Services.Implementation
|
|||
{
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly ICommentDataService _commentData;
|
||||
private readonly IUserDataService _userDataService;
|
||||
|
||||
public WorkOrderCommentService(
|
||||
IWorkOrderDetailDataService detailData,
|
||||
ICommentDataService commentData)
|
||||
ICommentDataService commentData,
|
||||
IUserDataService userDataService)
|
||||
{
|
||||
_detailData = detailData;
|
||||
_commentData = commentData;
|
||||
_userDataService = userDataService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
|
||||
|
|
@ -26,7 +29,8 @@ namespace SeaHaven.Services.Implementation
|
|||
return null;
|
||||
|
||||
var comments = await _detailData.GetCommentsAsync(workOrderId);
|
||||
return WorkOrderCommentProjection.ToDtos(comments);
|
||||
var authorNames = await ResolveAuthorNamesAsync(comments);
|
||||
return WorkOrderCommentProjection.ToDtos(comments, authorNames);
|
||||
}
|
||||
|
||||
public async Task<WorkOrderCommentDto> AddCommentAsync(
|
||||
|
|
@ -53,7 +57,58 @@ namespace SeaHaven.Services.Implementation
|
|||
};
|
||||
|
||||
var saved = await _commentData.AddAsync(comment);
|
||||
return WorkOrderCommentProjection.ToDto(saved);
|
||||
var authorNames = await ResolveAuthorNamesAsync(new[] { saved });
|
||||
return WorkOrderCommentProjection.ToDto(saved, authorNames);
|
||||
}
|
||||
|
||||
public async Task<WorkOrderCommentDto> UpdateCommentAsync(
|
||||
int workOrderId,
|
||||
int commentId,
|
||||
WorkOrderCommentCreateDto request,
|
||||
string? actorId,
|
||||
bool isAdmin)
|
||||
{
|
||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
|
||||
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Text))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required.");
|
||||
|
||||
var comment = await _commentData.GetByIdAsync(commentId);
|
||||
if (comment == null || comment.WorkerOrderId != workOrderId || comment.IsDeleted == true)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Comment not found.");
|
||||
|
||||
if (!IsBoardEditableComment(comment))
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"NotEditable",
|
||||
"Only board comments (CommentType General) can be edited via this endpoint.");
|
||||
|
||||
var isAuthor = !string.IsNullOrWhiteSpace(actorId)
|
||||
&& string.Equals(comment.UserId, actorId, StringComparison.Ordinal);
|
||||
if (!isAuthor && !isAdmin)
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to edit this comment.");
|
||||
|
||||
comment.Commenttext = request.Text.Trim();
|
||||
comment.LastModificationTime = DateTime.UtcNow;
|
||||
await _commentData.UpdateAsync(comment);
|
||||
|
||||
var authorNames = await ResolveAuthorNamesAsync(new[] { comment });
|
||||
return WorkOrderCommentProjection.ToDto(comment, authorNames);
|
||||
}
|
||||
|
||||
private static bool IsBoardEditableComment(Comments comment)
|
||||
=> string.Equals(comment.CommentType, "General", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(comment.RecordType, "WorkOrder", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, string>> ResolveAuthorNamesAsync(
|
||||
IEnumerable<Comments> comments)
|
||||
{
|
||||
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
|
||||
return await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly ICompletionDocTemplateDataService _templateData;
|
||||
private readonly IUserDataService _userDataService;
|
||||
|
||||
public WorkOrderDetailService(
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderDetailDataService detailData,
|
||||
ICompletionDocTemplateDataService templateData)
|
||||
ICompletionDocTemplateDataService templateData,
|
||||
IUserDataService userDataService)
|
||||
{
|
||||
_boardService = boardService;
|
||||
_detailData = detailData;
|
||||
_templateData = templateData;
|
||||
_userDataService = userDataService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
|
||||
|
|
@ -41,12 +44,15 @@ namespace SeaHaven.Services.Implementation
|
|||
extended?.Trade ?? boardRow.Pm,
|
||||
boardRow.WorkOrderType);
|
||||
|
||||
var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments);
|
||||
var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds);
|
||||
|
||||
return new WorkOrderDetailDto
|
||||
{
|
||||
Info = MapInfo(boardRow, extended),
|
||||
Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs),
|
||||
Comments = WorkOrderCommentProjection.ToDtos(comments),
|
||||
Audit = WorkOrderAuditProjection.ToDtos(audit),
|
||||
Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames),
|
||||
Audit = await MapAuditAsync(audit),
|
||||
Media = workOrder == null
|
||||
? new List<WorkOrderMediaFileDto>()
|
||||
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
|
||||
|
|
@ -59,7 +65,14 @@ namespace SeaHaven.Services.Implementation
|
|||
return null;
|
||||
|
||||
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
|
||||
return WorkOrderAuditProjection.ToDtos(logs);
|
||||
return await MapAuditAsync(logs);
|
||||
}
|
||||
|
||||
private async Task<List<WorkOrderAuditEntryDto>> MapAuditAsync(IReadOnlyList<Data.SeaHavenIndustries.WorkOrderAuditLog> logs)
|
||||
{
|
||||
var userIds = WorkOrderAuditProjection.CollectAssignToUserIds(logs);
|
||||
var displayNames = await _userDataService.GetDisplayNamesByIdsAsync(userIds);
|
||||
return WorkOrderAuditProjection.ToDtos(logs, displayNames);
|
||||
}
|
||||
|
||||
private static WorkOrderDetailInfoDto MapInfo(
|
||||
|
|
@ -98,7 +111,10 @@ 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,
|
||||
FlagColor = row.FlagColor,
|
||||
PrimaryDispatchId = row.PrimaryDispatchId,
|
||||
RowVersion = row.RowVersion,
|
||||
DispatchRowVersion = row.DispatchRowVersion,
|
||||
|
|
@ -108,10 +124,27 @@ namespace SeaHaven.Services.Implementation
|
|||
SubTrade = extended?.SubTrade,
|
||||
OriginalWeek = extended?.OriginalWeek,
|
||||
OriginalDate = extended?.OriginalDate,
|
||||
CompletedDate = extended?.CompletedDate
|
||||
CompletedDate = extended?.CompletedDate,
|
||||
VendorNotes = extended?.VendorNotes
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string>? ParseExtraServices(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
||||
return list is { Count: > 0 } ? list : null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkOrderCompletionDto MapCompletion(
|
||||
WorkOrderDetailExtendedFields? extended,
|
||||
Data.SeaHavenIndustries.Enums.DocStatus? docStatus,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<IEnumerable<LocationDTO>> GetAllLocationsAsync();
|
||||
Task<IEnumerable<LocationDTO>> GetLocationsByAccountIdAsync(int accountId);
|
||||
Task<PagedResult<LocationDTO>> GetLocationsPagedAsync(int page, int pageSize, string? search = null);
|
||||
Task<IReadOnlyList<SiteOptionDTO>> GetSiteOptionsAsync(string? search = null);
|
||||
Task<LocationDTO> CreateLocationAsync(CreateLocationDTO dto, string userId);
|
||||
Task<LocationDTO> UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId);
|
||||
Task DeleteLocationAsync(int id, string userId);
|
||||
|
|
|
|||
|
|
@ -7,5 +7,11 @@ namespace SeaHaven.Services.Interfaces
|
|||
{
|
||||
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
|
||||
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
|
||||
Task<WorkOrderCommentDto> UpdateCommentAsync(
|
||||
int workOrderId,
|
||||
int commentId,
|
||||
WorkOrderCommentCreateDto request,
|
||||
string? actorId,
|
||||
bool isAdmin);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,34 @@ namespace SeaHaven.Services.Validation
|
|||
.MaximumLength(128)
|
||||
.When(x => !string.IsNullOrEmpty(x.Trade));
|
||||
|
||||
RuleFor(x => x.PrimaryService)
|
||||
.MaximumLength(128)
|
||||
.When(x => !string.IsNullOrEmpty(x.PrimaryService));
|
||||
|
||||
RuleFor(x => x.ServiceNotes)
|
||||
.MaximumLength(4000)
|
||||
.When(x => !string.IsNullOrEmpty(x.ServiceNotes));
|
||||
|
||||
RuleFor(x => x.PocName)
|
||||
.MaximumLength(200)
|
||||
.When(x => !string.IsNullOrEmpty(x.PocName));
|
||||
|
||||
RuleFor(x => x.PocPhone)
|
||||
.MaximumLength(50)
|
||||
.When(x => !string.IsNullOrEmpty(x.PocPhone));
|
||||
|
||||
RuleFor(x => x.PocNotes)
|
||||
.MaximumLength(2000)
|
||||
.When(x => !string.IsNullOrEmpty(x.PocNotes));
|
||||
|
||||
RuleFor(x => x.TechPhone)
|
||||
.MaximumLength(50)
|
||||
.When(x => !string.IsNullOrEmpty(x.TechPhone));
|
||||
|
||||
RuleFor(x => x.VendorNotes)
|
||||
.MaximumLength(2000)
|
||||
.When(x => !string.IsNullOrEmpty(x.VendorNotes));
|
||||
|
||||
RuleFor(x => x.LocationId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.LocationId.HasValue);
|
||||
|
|
@ -41,6 +69,26 @@ namespace SeaHaven.Services.Validation
|
|||
RuleFor(x => x.PocContactId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.PocContactId.HasValue);
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(HasPrimaryWhenExtras)
|
||||
.WithMessage("primaryService (or trade) is required when extraServices is provided.")
|
||||
.WithName("extraServices");
|
||||
|
||||
RuleForEach(x => x.ExtraServices)
|
||||
.MaximumLength(128)
|
||||
.When(x => x.ExtraServices != null);
|
||||
}
|
||||
|
||||
private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request)
|
||||
{
|
||||
var hasExtras = request.ExtraServices != null
|
||||
&& request.ExtraServices.Any(s => !string.IsNullOrWhiteSpace(s));
|
||||
if (!hasExtras)
|
||||
return true;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(request.PrimaryService)
|
||||
|| !string.IsNullOrWhiteSpace(request.Trade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,4 +180,173 @@ public class WorkOrderBoardCreateServiceTests
|
|||
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
|
||||
Assert.True(result.Id > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_StructuredServiceNotes_PersistsAndReturnsPm()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
PrimaryService = "HVAC PM",
|
||||
ExtraServices = new List<string> { "Filter change", "Coil clean", "Filter change" },
|
||||
ServiceNotes = "Unit on roof"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("HVAC PM", result.Pm);
|
||||
Assert.Equal("Unit on roof", result.ServiceNotes);
|
||||
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("HVAC PM", wo.Trade);
|
||||
Assert.Equal("Unit on roof", wo.ServiceNotes);
|
||||
Assert.Contains("Filter change", wo.ExtraServices);
|
||||
Assert.Contains("Coil clean", wo.ExtraServices);
|
||||
Assert.Equal(1, System.Text.Json.JsonSerializer.Deserialize<List<string>>(wo.ExtraServices!)!
|
||||
.Count(s => s == "Filter change"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PrimaryServiceTakesPrecedenceOverTrade()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.Reactive,
|
||||
SiteCode = "CHI",
|
||||
Trade = "Legacy Trade",
|
||||
PrimaryService = "Plumbing"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("Plumbing", result.Pm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ExtraServicesWithoutPrimary_ThrowsValidation()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
await Assert.ThrowsAsync<FluentValidation.ValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
ExtraServices = new List<string> { "Filter change" }
|
||||
}, "actor-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PocFreeText_WithoutContact_AppearsOnBoardRow()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.Reactive,
|
||||
SiteCode = "BK5",
|
||||
PocName = "Jane Site Lead",
|
||||
PocPhone = "+1 555-0100",
|
||||
PocNotes = "Call 30 min before"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("Jane Site Lead", result.PocName);
|
||||
Assert.Equal("+1 555-0100", result.PocPhone);
|
||||
Assert.Equal("Call 30 min before", result.PocNotes);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("Jane Site Lead", wo.PocName);
|
||||
Assert.Empty(context.WorkOrderContacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutVendor_PersistsTechPhoneAndVendorNotesOnWorkOrder()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
TechPhone = "+1 555-0199",
|
||||
VendorNotes = "Gate code 4421"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("+1 555-0199", result.TechPhone);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("+1 555-0199", wo.TechPhone);
|
||||
Assert.Equal("Gate code 4421", wo.VendorNotes);
|
||||
Assert.Null(wo.PrimaryDispatchId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithVendor_PersistsTechPhoneAndVendorNotesOnDispatch()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.Vendors.Add(new Vendor { Id = 55, CompanyName = "Acme", Phone = "+1 555-0000" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
VendorId = 55,
|
||||
ApptDate = new DateTime(2026, 7, 18),
|
||||
TechPhone = "+1 555-0199",
|
||||
VendorNotes = "Park behind dock"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("+1 555-0199", result.TechPhone);
|
||||
Assert.Equal(55, result.VendorId);
|
||||
|
||||
var wo = await context.workOrders.Include(w => w.PrimaryDispatch).SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Null(wo.TechPhone);
|
||||
Assert.Null(wo.VendorNotes);
|
||||
Assert.NotNull(wo.PrimaryDispatch);
|
||||
Assert.Equal("+1 555-0199", wo.PrimaryDispatch!.TechPhone);
|
||||
Assert.Equal("Park behind dock", wo.PrimaryDispatch.VendorNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_LegacyTradeAndDescription_StillWorks()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
|
||||
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
Trade = "HVAC",
|
||||
Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate"
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("HVAC", result.Pm);
|
||||
Assert.Null(result.PocName);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id);
|
||||
Assert.Equal("HVAC", wo.Trade);
|
||||
Assert.Contains("Vendor notes:", wo.Description);
|
||||
Assert.Null(wo.PocName);
|
||||
Assert.Null(wo.ExtraServices);
|
||||
Assert.Null(wo.ServiceNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_UnknownVendorId_Throws()
|
||||
{
|
||||
var (_, service) = CreateSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
VendorId = 999
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("VendorNotFound", ex.Code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ public class WorkOrderBoardSearchFilterTests
|
|||
[InlineData("HVAC Tech", 1)]
|
||||
[InlineData("Acme HVAC", 1)]
|
||||
[InlineData("sched", 1)]
|
||||
[InlineData("Leak repair", 1)]
|
||||
[InlineData("Reactive", 1)]
|
||||
[InlineData("6/23", 1)]
|
||||
public async Task Apply_MatchesExpectedField(string search, int expectedId)
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
|
@ -73,6 +76,9 @@ public class WorkOrderBoardSearchFilterTests
|
|||
Id = 1,
|
||||
SiteCode = "BK5",
|
||||
InternalWONumber = "10000000001",
|
||||
WorkerOrderTitle = "Leak repair",
|
||||
WorkOrderType = WorkOrderType.Reactive,
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
PrimaryDispatchId = 10
|
||||
});
|
||||
|
|
@ -152,7 +158,7 @@ public class WorkOrderSearchDateRangeResolverTests
|
|||
reference);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 6, 22), range.From);
|
||||
Assert.Equal(new DateOnly(2026, 6, 28), range.To);
|
||||
Assert.Equal(new DateOnly(2026, 6, 26), range.To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -213,7 +219,7 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
DateFrom = weekStart,
|
||||
DateTo = weekStart.AddDays(6),
|
||||
Sites = new List<string> { "BK5" },
|
||||
Page = 1,
|
||||
Page = 0,
|
||||
PageSize = 50
|
||||
}, null);
|
||||
|
||||
|
|
@ -237,4 +243,144 @@ public class WorkOrderAdvancedSearchServiceTests
|
|||
|
||||
Assert.Equal(100, result.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_PageZero_ReturnsFirstPage()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
SiteCode = "BK5",
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000001"
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
SiteCode = "BK6",
|
||||
ScheduledDate = new DateTime(2026, 6, 24),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000002"
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = weekStart,
|
||||
DateTo = weekStart.AddDays(4),
|
||||
Page = 0,
|
||||
PageSize = 1
|
||||
}, null);
|
||||
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal(0, result.Page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_OverdueTypeSentinel_FiltersPastDueOnly()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
ScheduledDate = today.AddDays(-3),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000001"
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
WorkOrderType = WorkOrderType.Other,
|
||||
ScheduledDate = today.AddDays(3),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000002"
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)),
|
||||
DateTo = DateOnly.FromDateTime(today.AddMonths(1)),
|
||||
Types = new List<WorkOrderType> { WorkOrderType.Other }
|
||||
}, null);
|
||||
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal(1, result.Items.First().Id);
|
||||
Assert.True(result.Items.First().IsPastDue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_IncludesUnscheduledRows()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var weekStart = new DateOnly(2026, 6, 22);
|
||||
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
ScheduledDate = new DateTime(2026, 6, 23),
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
InternalWONumber = "10000000001"
|
||||
},
|
||||
new WorkOrder
|
||||
{
|
||||
Id = 2,
|
||||
ScheduledDate = null,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
InternalWONumber = "10000000002"
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
|
||||
var result = await service.SearchAsync(new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom,
|
||||
DateFrom = weekStart,
|
||||
DateTo = weekStart.AddDays(4)
|
||||
}, null);
|
||||
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.Contains(result.Items, i => i.Id == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_InvalidSortBy_Throws()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var dataService = new WorkOrderAdvancedSearchDataService(context);
|
||||
var service = new SeaHaven.Services.Implementation.WorkOrderAdvancedSearchService(dataService);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
|
||||
new SeaHaven.Services.DTOs.WorkOrderAdvancedSearchQueryDto
|
||||
{
|
||||
DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.ThisWeek,
|
||||
SortBy = "invalid"
|
||||
}, null));
|
||||
|
||||
Assert.Contains("sortBy", ex.Message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,33 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
Assert.Equal("PastDueStatusBlocked", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_RejectsOverdueAsWorkOrderType()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
WorkOrderType = WorkOrderType.Reactive,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.WorkOrderType,
|
||||
Value = "Overdue",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("InvalidValue", ex.Code);
|
||||
Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_BlocksEditWhenCanceled()
|
||||
{
|
||||
|
|
@ -224,4 +251,256 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
|
||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_UpdatesPmTrade()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Trade = "HVAC",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.Pm,
|
||||
Value = "Plumbing",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("Plumbing", result.Pm);
|
||||
Assert.Equal("Plumbing", (await context.workOrders.FindAsync(1))!.Trade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_UpdatesServiceNotesAndReturnsOnBoardRow()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Trade = "Backflow Preventers",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ServiceNotes,
|
||||
Value = "service notes",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("service notes", result.ServiceNotes);
|
||||
Assert.Equal("service notes", (await context.workOrders.FindAsync(1))!.ServiceNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_UpdatesExtraServices_WhenPrimaryExists()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
Trade = "Backflow Preventers",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ExtraServices,
|
||||
Value = "[\"Filter change\",\"Coil clean\"]",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices);
|
||||
Assert.Contains("Filter change", (await context.workOrders.FindAsync(1))!.ExtraServices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ExtraServicesWithoutPrimary_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.ExtraServices,
|
||||
Value = "[\"Filter change\"]",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("InvalidValue", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_SetsFlagColor_CanonicalUppercase()
|
||||
{
|
||||
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 result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.FlagColor,
|
||||
Value = "#ef4444",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("#EF4444", result.FlagColor);
|
||||
Assert.Equal("#EF4444", (await context.workOrders.FindAsync(1))!.FlagColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ClearsFlagColor_WithNullOrEmpty()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
FlagColor = "#EF4444",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.FlagColor,
|
||||
Value = "",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Null(cleared.FlagColor);
|
||||
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_ClearsFlagColor_WithNullValue()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
FlagColor = "#3B82F6",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.FlagColor,
|
||||
Value = null,
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Null(cleared.FlagColor);
|
||||
Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_FlagColorOutsidePalette_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.FlagColor,
|
||||
Value = "#000000",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("InvalidValue", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_FlagColorMalformed_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.FlagColor,
|
||||
Value = "not-a-color",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("InvalidValue", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_FlagColor_DoesNotRequireDispatchVersion()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Incomplete,
|
||||
PrimaryDispatchId = 10,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.Dispatches.Add(new Dispatch
|
||||
{
|
||||
Id = 10,
|
||||
WorkOrderId = 1,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 }
|
||||
});
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.FlagColor,
|
||||
Value = "#8B5CF6",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
// no DispatchVersion
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("#8B5CF6", result.FlagColor);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +132,13 @@ public class WorkOrderDetailServiceTests
|
|||
NewValue = "1",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
UserName = "alice",
|
||||
FirstName = "Alice",
|
||||
LastName = "Dispatcher"
|
||||
});
|
||||
context.Comments.Add(new Comments
|
||||
{
|
||||
WorkerOrderId = 1,
|
||||
|
|
@ -150,11 +158,75 @@ public class WorkOrderDetailServiceTests
|
|||
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
|
||||
Assert.Single(detail.Comments);
|
||||
Assert.Equal("user-1", detail.Comments[0].AuthorId);
|
||||
Assert.Equal("Alice Dispatcher", detail.Comments[0].AuthorName);
|
||||
Assert.Equal("Called vendor", detail.Comments[0].Text);
|
||||
Assert.Single(detail.Audit);
|
||||
Assert.Equal("system", detail.Audit[0].Type);
|
||||
Assert.Equal("WeekRolled", detail.Audit[0].Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAudit_ResolvesAssignToUserIdsToDisplayNames()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
const string oldUserId = "a1000001-0001-4000-8000-000000000002";
|
||||
const string newUserId = "b5e356d5-d926-4f92-8673-ee13cddeff0f";
|
||||
|
||||
context.Users.AddRange(
|
||||
new ApplicationUser
|
||||
{
|
||||
Id = oldUserId,
|
||||
UserName = "alice",
|
||||
FirstName = "Alice",
|
||||
LastName = "Dispatcher"
|
||||
},
|
||||
new ApplicationUser
|
||||
{
|
||||
Id = newUserId,
|
||||
UserName = "bob",
|
||||
FirstName = "Bob",
|
||||
LastName = "Tech"
|
||||
});
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000000001",
|
||||
LifecycleStatus = LifecycleStatus.Pending,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = 1,
|
||||
UserId = "actor-1",
|
||||
EventType = "Manual",
|
||||
Action = "AssignmentChanged",
|
||||
FieldName = "AssignTo",
|
||||
OldValue = oldUserId,
|
||||
NewValue = newUserId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = 1,
|
||||
UserId = "actor-1",
|
||||
EventType = "Manual",
|
||||
Action = "AssignmentChanged",
|
||||
FieldName = "AssignTo",
|
||||
OldValue = newUserId,
|
||||
NewValue = "",
|
||||
CreatedAt = DateTime.UtcNow.AddMinutes(1)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var audit = await service.GetAuditAsync(1);
|
||||
|
||||
Assert.NotNull(audit);
|
||||
Assert.Equal(2, audit!.Count);
|
||||
var unassigned = audit.Single(a => a.NewValue == "Unassigned");
|
||||
Assert.Equal("Bob Tech", unassigned.OldValue);
|
||||
var reassigned = audit.Single(a => a.NewValue == "Bob Tech");
|
||||
Assert.Equal("Alice Dispatcher", reassigned.OldValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderCompletionServiceTests
|
||||
|
|
@ -306,18 +378,197 @@ public class WorkOrderCommentServiceTests
|
|||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "user-abc",
|
||||
UserName = "bob",
|
||||
FirstName = "Bob",
|
||||
LastName = "Tech"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var commentData = new CommentDataService(context);
|
||||
var service = new WorkOrderCommentService(detailData, commentData);
|
||||
var userData = new UserDataService(context);
|
||||
var service = new WorkOrderCommentService(detailData, commentData, userData);
|
||||
|
||||
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
|
||||
|
||||
Assert.Equal("user-abc", result.AuthorId);
|
||||
Assert.Equal("Bob Tech", result.AuthorName);
|
||||
Assert.Equal("Note", result.Text);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Time));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetComments_FallsBackToCommenterWhenUserIdMissing()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||
context.Comments.Add(new Comments
|
||||
{
|
||||
WorkerOrderId = 1,
|
||||
Commenter = "Legacy Sync Author",
|
||||
Commenttext = "Synced note",
|
||||
CreatedDate = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var commentData = new CommentDataService(context);
|
||||
var userData = new UserDataService(context);
|
||||
var service = new WorkOrderCommentService(detailData, commentData, userData);
|
||||
|
||||
var comments = await service.GetCommentsAsync(1);
|
||||
|
||||
Assert.NotNull(comments);
|
||||
Assert.Single(comments!);
|
||||
Assert.Null(comments[0].AuthorId);
|
||||
Assert.Equal("Legacy Sync Author", comments[0].AuthorName);
|
||||
Assert.Equal("Synced note", comments[0].Text);
|
||||
}
|
||||
|
||||
private static async Task<(ApplicationDbContext Context, WorkOrderCommentService Service, Comments Comment)> SeedEditableCommentAsync(
|
||||
LifecycleStatus status = LifecycleStatus.Scheduled,
|
||||
string authorId = "user-author",
|
||||
string commentType = "General",
|
||||
string recordType = "WorkOrder",
|
||||
int workOrderId = 1)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
context.workOrders.Add(new WorkOrder { Id = workOrderId, LifecycleStatus = status });
|
||||
context.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = authorId,
|
||||
UserName = "author",
|
||||
FirstName = "Alice",
|
||||
LastName = "Dispatcher"
|
||||
});
|
||||
var comment = new Comments
|
||||
{
|
||||
WorkerOrderId = workOrderId,
|
||||
UserId = authorId,
|
||||
Commenttext = "Original",
|
||||
CommentType = commentType,
|
||||
RecordType = recordType,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
context.Comments.Add(comment);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new WorkOrderCommentService(
|
||||
new WorkOrderDetailDataService(context),
|
||||
new CommentDataService(context),
|
||||
new UserDataService(context));
|
||||
return (context, service, comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateComment_Author_UpdatesText()
|
||||
{
|
||||
var (_, service, comment) = await SeedEditableCommentAsync();
|
||||
|
||||
var result = await service.UpdateCommentAsync(
|
||||
1,
|
||||
comment.Id,
|
||||
new WorkOrderCommentCreateDto { Text = " Updated note " },
|
||||
"user-author",
|
||||
isAdmin: false);
|
||||
|
||||
Assert.Equal("Updated note", result.Text);
|
||||
Assert.Equal("user-author", result.AuthorId);
|
||||
Assert.Equal("Alice Dispatcher", result.AuthorName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateComment_Admin_CanEditOthers()
|
||||
{
|
||||
var (_, service, comment) = await SeedEditableCommentAsync();
|
||||
|
||||
var result = await service.UpdateCommentAsync(
|
||||
1,
|
||||
comment.Id,
|
||||
new WorkOrderCommentCreateDto { Text = "Admin edit" },
|
||||
"admin-user",
|
||||
isAdmin: true);
|
||||
|
||||
Assert.Equal("Admin edit", result.Text);
|
||||
Assert.Equal("user-author", result.AuthorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateComment_NonAuthorNonAdmin_ThrowsForbidden()
|
||||
{
|
||||
var (_, service, comment) = await SeedEditableCommentAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateCommentAsync(
|
||||
1,
|
||||
comment.Id,
|
||||
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||
"other-user",
|
||||
isAdmin: false));
|
||||
|
||||
Assert.Equal("Forbidden", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateComment_LegacyVendorComment_ThrowsNotEditable()
|
||||
{
|
||||
var (_, service, comment) = await SeedEditableCommentAsync(
|
||||
commentType: "vendor",
|
||||
recordType: "WorkOrder");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateCommentAsync(
|
||||
1,
|
||||
comment.Id,
|
||||
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||
"user-author",
|
||||
isAdmin: false));
|
||||
|
||||
Assert.Equal("NotEditable", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateComment_ReadOnlyWorkOrder_Throws()
|
||||
{
|
||||
var (_, service, comment) = await SeedEditableCommentAsync(LifecycleStatus.Canceled);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateCommentAsync(
|
||||
1,
|
||||
comment.Id,
|
||||
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||
"user-author",
|
||||
isAdmin: false));
|
||||
|
||||
Assert.Equal("ReadOnly", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateComment_WrongWorkOrder_ThrowsNotFound()
|
||||
{
|
||||
var (context, service, comment) = await SeedEditableCommentAsync(workOrderId: 1);
|
||||
context.workOrders.Add(new WorkOrder { Id = 2, LifecycleStatus = LifecycleStatus.Scheduled });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateCommentAsync(
|
||||
2,
|
||||
comment.Id,
|
||||
new WorkOrderCommentCreateDto { Text = "Nope" },
|
||||
"user-author",
|
||||
isAdmin: false));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderMediaServiceTests
|
||||
|
|
|
|||
28
docs/work-orders/pr-bodies/README.md
Normal file
28
docs/work-orders/pr-bodies/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# PR bodies (copy-paste)
|
||||
|
||||
Full English PR descriptions are in the plan appendix and summarized below by branch tip.
|
||||
|
||||
| PR | Branch | Tip commit | Depends on |
|
||||
|----|--------|------------|------------|
|
||||
| 1 | `feat/work-orders-phase-4` | `cbcfd9a` Phase4 index shrink | existing Phase 4 PR |
|
||||
| 2 | `feat/work-orders-completion-slideover-and-rollout` | `3e2952f` Phase6 idempotent (then stacked features) | Phase 6 base |
|
||||
| 3 | `feature/wo-structured-service-notes` | `ecc1db3` | after F2+sites on slideover stack |
|
||||
| 4 | `feature/wo-flag-color` | `62572d8` | after service notes |
|
||||
| 5 | `feature/wo-board-search-paging` | `1eaf9b7` | after flag (or notes) |
|
||||
| 6 | `feature/wo-comments-author-edit` | `16b31e5` | after search (controller mixed) |
|
||||
| 7 | `feature/location-sites-vendor-contact` | `3983f76` | after F2 |
|
||||
|
||||
Stacked history on `feat/work-orders-completion-slideover-and-rollout` (`f61aff0`, ahead 7):
|
||||
|
||||
1. fix Phase6 migration
|
||||
2. feat locations/sites + vendor contactName
|
||||
3. feat structured service notes
|
||||
4. feat flagColor
|
||||
5. feat search/paging
|
||||
6. feat comment edit / display names
|
||||
7. docs staff review findings
|
||||
|
||||
Phase4 hardening lives only on `feat/work-orders-phase-4`.
|
||||
|
||||
Full PR markdown: see conversation / plan Apêndice B.
|
||||
Staff findings: [staff-review-findings.md](../staff-review-findings.md)
|
||||
276
docs/work-orders/staff-review-findings.md
Normal file
276
docs/work-orders/staff-review-findings.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Staff Review Findings — Work Orders (Fase 2)
|
||||
|
||||
Adversarial reviews per delivery. Do not treat preliminary inventário notes as final.
|
||||
|
||||
---
|
||||
|
||||
## Entrega A — Structured Service Notes / POC / Tech-Vendor
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Create** `POST /api/workorders/board`: FluentValidation → resolve `primaryService`/`trade` → serialize `extraServices` JSON → persist POC/service/tech/vendor on `WorkOrder`; if vendor/appt present, create `Dispatch` and store tech/vendor notes there instead of WO.
|
||||
2. **Patch** `PATCH .../board` with `pm` | `serviceNotes` | `extraServices`.
|
||||
3. **Read**: board projection prefers WO POC fields over `WorkOrderContacts`; tech phone = Dispatch.TechPhone → WO.TechPhone → Vendor.Phone. Detail exposes `serviceNotes`, `extraServices`, `vendorNotes`.
|
||||
|
||||
### Domain invariants
|
||||
|
||||
- Extra services require a primary service on create.
|
||||
- Tech/vendor notes live on dispatch when a vendor dispatch exists; otherwise on WO.
|
||||
- Board POC free-text overrides linked contact projection.
|
||||
|
||||
### Half-done
|
||||
|
||||
- No board PATCH for `pocName` / `pocPhone` / `pocNotes` / `techPhone` / `vendorNotes` (create-only for those).
|
||||
- `ParseExtraServices` duplicated in board + detail services.
|
||||
- Create validation file has excessive blank lines (noise).
|
||||
- Test `PatchField_ExtraServicesWithoutPrimary_Throws` disagrees with promotion logic in `ApplyExtraServices`.
|
||||
|
||||
### Findings
|
||||
|
||||
#### A1 — ExtraServices without primary: test vs promotion
|
||||
|
||||
- **Severity:** High
|
||||
- **File:** `SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs` (~294–298); test `WorkOrderBoardUpdateServiceTests.cs` (~331)
|
||||
- **Explanation:** Create requires primary when extras exist; PATCH promotes first extra to `Trade` without auditing Trade. Test expects `InvalidValue`.
|
||||
- **Scenario:** Legacy WO with empty Trade; FE patches `extraServices` only.
|
||||
- **Repro:** PATCH `extraServices=["Filter change"]` on WO with null Trade.
|
||||
- **Fix:** Reject when Trade empty (align create + test); do not promote silently.
|
||||
- **When:** **Now** (blocks green CI / domain consistency).
|
||||
|
||||
#### A2 — ExtraServices JSON can exceed nvarchar(2000)
|
||||
|
||||
- **Severity:** High
|
||||
- **File:** Create `SerializeExtraServices`; Update `ApplyExtraServices`
|
||||
- **Explanation:** Per-item max 128; no check on serialized JSON length vs column 2000.
|
||||
- **Scenario:** Many extras → `DbUpdateException` 500.
|
||||
- **Repro:** PATCH/create with ~20×128-char labels.
|
||||
- **Fix:** After serialize, throw `InvalidValue` if length > 2000.
|
||||
- **When:** **Now**.
|
||||
|
||||
#### A3 — Create does not cap ExtraServices JSON length / per-item in serializer
|
||||
|
||||
- **Severity:** Medium
|
||||
- **File:** `WorkOrderBoardCreateService.SerializeExtraServices`
|
||||
- **Explanation:** FluentValidation caps each item at 128 but create serializer does not re-validate length or total JSON; update path has item length check, create does not.
|
||||
- **Fix:** Share normalize helper; enforce item + JSON caps on create.
|
||||
- **When:** **Now** (with A2).
|
||||
|
||||
#### A4 — POC / tech / vendor notes not patchable on board
|
||||
|
||||
- **Severity:** Medium
|
||||
- **File:** `WorkOrderBoardFieldNames.cs` / UpdateService
|
||||
- **Explanation:** Create persists structured POC/tech/vendor; no PATCH fields → FE cannot edit after create without another API.
|
||||
- **When:** **Later** if FE has another edit path; otherwise before FE ship.
|
||||
|
||||
#### A5 — Dual POC sources can diverge
|
||||
|
||||
- **Severity:** Low
|
||||
- **File:** CreateService + Projection
|
||||
- **Explanation:** `PocContactId` + free-text both allowed; board prefers free-text; contact row may hold different notes.
|
||||
- **When:** Later (document or clear contact notes when free-text set).
|
||||
|
||||
#### A6 — Duplicate ParseExtraServices
|
||||
|
||||
- **Severity:** Low
|
||||
- **File:** `WorkOrderBoardService.cs`, `WorkOrderDetailService.cs`
|
||||
- **When:** Later (extract helper).
|
||||
|
||||
### Verdict A
|
||||
|
||||
**Merge blocked** until A1–A3 fixed. A4 is a product gap for FE edit flows.
|
||||
|
||||
---
|
||||
|
||||
## Entrega B — Flag Color
|
||||
|
||||
### How it works
|
||||
|
||||
PATCH `flagColor` → normalize `#RRGGBB` → whitelist palette → persist on WO → project on board/detail. Distinct from dispatcher avatar `color`.
|
||||
|
||||
### Half-done
|
||||
|
||||
- None material. Migration + model + PATCH + tests present.
|
||||
|
||||
### Findings
|
||||
|
||||
#### B1 — No finding Critical/High
|
||||
|
||||
Palette enforcement and clear semantics are consistent with tests.
|
||||
|
||||
#### B2 — Snapshot/model coupled with Entrega A
|
||||
|
||||
- **Severity:** Low (process)
|
||||
- **Explanation:** Same `WorkerOrder` / snapshot file as service notes; branch split needs careful ordering.
|
||||
- **When:** During organize.
|
||||
|
||||
### Verdict B
|
||||
|
||||
**OK to merge** after A schema lands (or same branch ordered commits). No code blockers unique to B.
|
||||
|
||||
---
|
||||
|
||||
## Entrega C — Search / overdue / paging
|
||||
|
||||
### How it works
|
||||
|
||||
Advanced search: normalize page (≥0), pageSize≤100, resolve Mon–Fri presets, whitelist sortBy, apply type/overdue/unscheduled filters, project board rows. Board GET maps `ArgumentException` → 400.
|
||||
|
||||
### Half-done / risks
|
||||
|
||||
- Shared `PagedResult` flipped to 0-based helpers while Location/Contact/etc. still use 1-based `page` defaults.
|
||||
|
||||
### Findings
|
||||
|
||||
#### C1 — PagedResult 0-based breaks 1-based APIs
|
||||
|
||||
- **Severity:** Critical
|
||||
- **File:** `SeaHaven.Services/DTOs/ContactDTOs.cs`
|
||||
- **Explanation:** `HasPrevious`/`HasNext` assume 0-based; Location etc. pass `page=1` on first page → `HasPrevious=true` incorrectly; last-page `HasNext` wrong for 0-based search too if mixed.
|
||||
- **Scenario:** Any paged Location/Contact/Vendor list UI using helpers.
|
||||
- **Fix:** Restore 1-based helpers by default; opt-in 0-based for WO search only (`PageBase` or dedicated DTO).
|
||||
- **When:** **Now**.
|
||||
|
||||
#### C2 — Unscheduled always included in date range
|
||||
|
||||
- **Severity:** High
|
||||
- **File:** `WorkOrderBoardQueryFilters.ApplyDateRangeFilter`
|
||||
- **Explanation:** Every advanced search with a date range also returns all unscheduled non-terminal WOs → unbounded result growth.
|
||||
- **Scenario:** Narrow custom range + large backlog of undated WOs.
|
||||
- **Fix:** Explicit `includeUnscheduled` or cap; confirm FE intent.
|
||||
- **When:** Confirm with FE; fix before prod if unintentional.
|
||||
|
||||
#### C3 — WorkOrderType.Other = overdue sentinel
|
||||
|
||||
- **Severity:** High (domain)
|
||||
- **File:** `WorkOrderBoardQueryFilters.ApplyTypeAndOverdueFilter`
|
||||
- **Explanation:** Cannot filter real `Other` types via `types=99`.
|
||||
- **When:** Document now; long-term separate `isPastDue` query param.
|
||||
|
||||
#### C4 — Label search `label.Contains(s)` matches short tokens broadly
|
||||
|
||||
- **Severity:** Medium
|
||||
- **File:** `WorkOrderBoardSearchFilter`
|
||||
- **When:** Later / monitor perf.
|
||||
|
||||
#### C5 — Overdue uses UTC date vs possibly local scheduled dates
|
||||
|
||||
- **Severity:** Medium
|
||||
- **File:** QueryFilters / search filter
|
||||
- **When:** Later if US sites report off-by-one day.
|
||||
|
||||
#### C6 — Search helper methods not EF-translatable (FIXED)
|
||||
|
||||
- **Severity:** Critical
|
||||
- **File:** `WorkOrderBoardSearchFilter.Apply`
|
||||
- **Explanation:** Refactor extracted `Matches*` private methods; EF Core InMemory/SQL cannot translate them → all text searches throw at runtime.
|
||||
- **Repro:** Any `search` with length ≥ 2 on board/search.
|
||||
- **Fix:** Inline predicates in the `Where` expression (applied).
|
||||
- **When:** **Now** — fixed during Fase 2.
|
||||
|
||||
### Verdict C
|
||||
|
||||
**Merge blocked** on C1 (fixed via `PageBase`) and C6 (fixed via inline predicates). C2 needs product confirmation. Prefer separate PR.
|
||||
|
||||
---
|
||||
|
||||
## Entrega D — Comments edit + display names
|
||||
|
||||
### How it works
|
||||
|
||||
`GetDisplayNamesByIdsAsync` → comment/audit projections. PATCH comment: author or Admin; General+WorkOrder only; read-only WO blocked.
|
||||
|
||||
### Half-done
|
||||
|
||||
- Comment edit not written to audit log.
|
||||
|
||||
### Findings
|
||||
|
||||
#### D1 — Comment updates leave no audit trail
|
||||
|
||||
- **Severity:** Medium
|
||||
- **File:** `WorkOrderCommentService.UpdateCommentAsync`
|
||||
- **When:** Later if compliance requires it; note in PR.
|
||||
|
||||
#### D2 — No max length on comment text on update
|
||||
|
||||
- **Severity:** Low (pre-existing on create)
|
||||
- **When:** Later.
|
||||
|
||||
### Verdict D
|
||||
|
||||
**OK to merge** with D1 noted. No Critical/High code defects found.
|
||||
|
||||
---
|
||||
|
||||
## Entrega E — Sites + Vendor ContactName
|
||||
|
||||
### How it works
|
||||
|
||||
`GET /api/locations/sites?search=` → Id/Code/City/State (`Code` ← `Locations.Name`). Vendor Dropdown adds `contactName`.
|
||||
|
||||
### Findings
|
||||
|
||||
#### E1 — GetSites unbounded
|
||||
|
||||
- **Severity:** Medium
|
||||
- **File:** `LocationDataService.GetSiteOptionsAsync`
|
||||
- **Explanation:** No Take/limit; large Locations table → heavy payload.
|
||||
- **When:** Later or add Take(500) if volume unknown.
|
||||
|
||||
#### E2 — No soft-delete filter
|
||||
|
||||
- **Severity:** Low
|
||||
- **Explanation:** Locations inherit audit entity; if soft-delete used elsewhere, sites may include deleted.
|
||||
- **When:** Later if soft-delete applies to Locations.
|
||||
|
||||
### Verdict E
|
||||
|
||||
**OK to merge** independently.
|
||||
|
||||
---
|
||||
|
||||
## Entrega F1 — Phase4 migration hardening
|
||||
|
||||
### How it works
|
||||
|
||||
Before creating indexes, alter `SiteCode`/`InternalWONumber` to nvarchar(450).
|
||||
|
||||
### Findings
|
||||
|
||||
#### F1-1 — Editing already-shipped migration
|
||||
|
||||
- **Severity:** High (ops)
|
||||
- **File:** `20260624200000_Phase4_SearchIndexes.cs`
|
||||
- **Explanation:** Environments that already applied old `Up` will not re-run. Failed partial applies need manual repair or a **new** migration.
|
||||
- **When:** Verify `__EFMigrationsHistory` per environment **before** merge. Prefer new corrective migration if any env already applied.
|
||||
|
||||
### Verdict F1
|
||||
|
||||
**Conditional merge** after env check. Apply on `feat/work-orders-phase-4` only.
|
||||
|
||||
---
|
||||
|
||||
## Entrega F2 — Phase6 migration hardening
|
||||
|
||||
### How it works
|
||||
|
||||
Idempotent SQL for Category column, CompletionDocTemplates, index; EF attributes for discovery.
|
||||
|
||||
### Findings
|
||||
|
||||
#### F2-1 — Same applied-migration caveat as F1
|
||||
|
||||
- **Severity:** High (ops)
|
||||
- **When:** Verify env history; idempotent SQL helps partial DBs if migration re-run manually, not if EF skips applied migrations.
|
||||
|
||||
### Verdict F2
|
||||
|
||||
**Conditional merge** on slideover branch after env check.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting mandatory fixes before organize
|
||||
|
||||
1. A1 — ExtraServices without primary throws
|
||||
2. A2/A3 — JSON length ≤ 2000
|
||||
3. C1 — PagedResult 1-based default + WO search 0-based opt-in
|
||||
Loading…
Add table
Reference in a new issue