mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge pull request #22 from Sea-Haven-Industries/feature/wo-flag-color
feat(work-orders): add board flagColor with fixed palette
This commit is contained in:
commit
8be07a9c5f
78 changed files with 4508 additions and 179 deletions
17
.env.example
17
.env.example
|
|
@ -1,11 +1,11 @@
|
|||
# Sea Haven Industries — shoc-backend required configuration
|
||||
# Sea Haven Industries ÔÇö shoc-backend required configuration
|
||||
#
|
||||
# This file documents the secrets that used to be hardcoded in appsettings*.json
|
||||
# and source files. Copy the values into one of the supported configuration sources;
|
||||
# do NOT commit real values.
|
||||
#
|
||||
# .NET resolves configuration in this order (later wins):
|
||||
# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only)
|
||||
# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only)
|
||||
# 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value"
|
||||
# 3. Environment variables (use "__" as the section separator)
|
||||
#
|
||||
|
|
@ -32,3 +32,16 @@ GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
# AWS_REGION=us-east-2
|
||||
|
||||
# --- Work Order ingest (Lambda cutover) ---
|
||||
# Leave Enabled=false until ApiKey is a real secret (min 32 chars). Do not use appsettings placeholders.
|
||||
WorkOrderIngest__Enabled=false
|
||||
WorkOrderIngest__ApiKey=<shared-secret-for-X-Ingest-Key-min-32-chars>
|
||||
|
||||
# --- Dynamo sync bridge (keep false after Lambda cutover; avoid dual-run with ingest) ---
|
||||
Sync__Enabled=true
|
||||
|
||||
# --- Legacy endpoint deprecation headers ---
|
||||
LegacyEndpoints__DeprecationEnabled=false
|
||||
LegacyEndpoints__SunsetDate=2026-12-31
|
||||
|
||||
|
|
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,12 @@ 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,32 +1,49 @@
|
|||
using Amazon.DynamoDBv2;
|
||||
using Amazon.DynamoDBv2.Model;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class SyncController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly AmazonDynamoDBClient _dynamo;
|
||||
private readonly SyncOptions _syncOptions;
|
||||
private const string WO_TABLE = "WorkOrders";
|
||||
private const string COMMENTS_TABLE = "WorkOrderComments";
|
||||
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
|
||||
|
||||
public SyncController(ApplicationDbContext db)
|
||||
public SyncController(ApplicationDbContext db, IOptions<SyncOptions> syncOptions)
|
||||
{
|
||||
_db = db;
|
||||
_syncOptions = syncOptions.Value;
|
||||
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
|
||||
}
|
||||
|
||||
private IActionResult? SyncDisabledResult()
|
||||
{
|
||||
if (_syncOptions.Enabled)
|
||||
return null;
|
||||
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
|
||||
{
|
||||
message = "DynamoDB sync bridge is disabled. Use POST /api/workorders/ingest."
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("WorkOrders")]
|
||||
public async Task<IActionResult> SyncWorkOrders()
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
var synced = 0;
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
|
|
@ -62,12 +79,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
var existing = await _db.workOrders
|
||||
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId);
|
||||
|
||||
var locationId = await ResolveLocationId(
|
||||
var (locationId, locationWasCreated) = await ResolveLocationId(
|
||||
GetString(item, "site_code"),
|
||||
GetString(item, "building"),
|
||||
GetString(item, "address"));
|
||||
|
||||
if (locationId.HasValue && !locationsCreated.Equals(0))
|
||||
if (locationWasCreated)
|
||||
locationsCreated++;
|
||||
|
||||
if (existing == null)
|
||||
|
|
@ -138,6 +155,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("Comments")]
|
||||
public async Task<IActionResult> SyncComments()
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var synced = 0;
|
||||
var created = 0;
|
||||
var skipped = 0;
|
||||
|
|
@ -212,6 +232,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("BackfillInternalWONumbers")]
|
||||
public async Task<IActionResult> BackfillInternalWONumbers()
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var wosMissing = await _db.workOrders
|
||||
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
|
||||
.OrderBy(w => w.Id)
|
||||
|
|
@ -243,6 +266,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("BackfillDispatchNumbers")]
|
||||
public async Task<IActionResult> BackfillDispatchNumbers()
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var missing = await _db.Dispatches
|
||||
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
|
||||
.OrderBy(d => d.Id)
|
||||
|
|
@ -270,6 +296,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("VendorReplies")]
|
||||
public async Task<IActionResult> SyncVendorReplies()
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var synced = 0;
|
||||
var created = 0;
|
||||
var skipped = 0;
|
||||
|
|
@ -341,6 +370,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("BackfillCommentTypes")]
|
||||
public async Task<IActionResult> BackfillCommentTypes()
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var updated = await _db.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')");
|
||||
return Ok(new { updated });
|
||||
|
|
@ -349,6 +381,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
[HttpPost("All")]
|
||||
public async Task<IActionResult> SyncAll()
|
||||
{
|
||||
if (SyncDisabledResult() is { } disabled)
|
||||
return disabled;
|
||||
|
||||
var woResult = await SyncWorkOrders() as OkObjectResult;
|
||||
var commentResult = await SyncComments() as OkObjectResult;
|
||||
|
||||
|
|
@ -359,17 +394,17 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
});
|
||||
}
|
||||
|
||||
private async Task<int?> ResolveLocationId(string? siteCode, string? building, string? address)
|
||||
private async Task<(int? LocationId, bool Created)> ResolveLocationId(string? siteCode, string? building, string? address)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
||||
return null;
|
||||
return (null, false);
|
||||
|
||||
var matchCode = siteCode ?? building;
|
||||
var existing = await _db.Locations
|
||||
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode);
|
||||
|
||||
if (existing != null)
|
||||
return existing.Id;
|
||||
return (existing.Id, false);
|
||||
|
||||
var location = new Locations
|
||||
{
|
||||
|
|
@ -382,7 +417,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_db.Locations.Add(location);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return location.Id;
|
||||
return (location.Id, true);
|
||||
}
|
||||
|
||||
private static string? GetString(Dictionary<string, AttributeValue> item, string key)
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
v.CompanyName,
|
||||
v.ContactName,
|
||||
v.PreferredContact,
|
||||
|
||||
v.TradeSpecialties,
|
||||
v.Address,
|
||||
v.City,
|
||||
|
|
@ -348,6 +349,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
v.CompanyName,
|
||||
v.ContactName,
|
||||
v.PreferredContact,
|
||||
|
||||
v.TradeSpecialties,
|
||||
address = string.Join(", ", addr),
|
||||
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
private readonly IWorkOrderBoardCreateService _boardCreateService;
|
||||
private readonly IWorkOrderBoardCancelService _boardCancelService;
|
||||
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
|
||||
private readonly IWorkOrderDetailService _workOrderDetailService;
|
||||
private readonly IWorkOrderCommentService _workOrderCommentService;
|
||||
private readonly IWorkOrderCompletionService _workOrderCompletionService;
|
||||
private readonly IWorkOrderMediaService _workOrderMediaService;
|
||||
|
||||
public WorkOrderController(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
|
|
@ -71,7 +75,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
IWorkOrderBoardUpdateService boardUpdateService,
|
||||
IWorkOrderBoardCreateService boardCreateService,
|
||||
IWorkOrderBoardCancelService boardCancelService,
|
||||
IWorkOrderAdvancedSearchService advancedSearchService)
|
||||
IWorkOrderAdvancedSearchService advancedSearchService,
|
||||
IWorkOrderDetailService workOrderDetailService,
|
||||
IWorkOrderCommentService workOrderCommentService,
|
||||
IWorkOrderCompletionService workOrderCompletionService,
|
||||
IWorkOrderMediaService workOrderMediaService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_workOrderService = workOrderService;
|
||||
|
|
@ -93,6 +101,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
_boardCreateService = boardCreateService;
|
||||
_boardCancelService = boardCancelService;
|
||||
_advancedSearchService = advancedSearchService;
|
||||
_workOrderDetailService = workOrderDetailService;
|
||||
_workOrderCommentService = workOrderCommentService;
|
||||
_workOrderCompletionService = workOrderCompletionService;
|
||||
_workOrderMediaService = workOrderMediaService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -268,6 +280,216 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/detail")]
|
||||
public async Task<IActionResult> GetDetail(int id)
|
||||
{
|
||||
var detail = await _workOrderDetailService.GetDetailAsync(id);
|
||||
if (detail == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(detail);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/audit")]
|
||||
public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50)
|
||||
{
|
||||
var audit = await _workOrderDetailService.GetAuditAsync(id, limit);
|
||||
if (audit == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(audit);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/comments")]
|
||||
public async Task<IActionResult> GetBoardComments(int id)
|
||||
{
|
||||
var comments = await _workOrderCommentService.GetCommentsAsync(id);
|
||||
if (comments == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(comments);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/comments")]
|
||||
public async Task<IActionResult> AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId);
|
||||
return Ok(comment);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(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,
|
||||
[FromQuery] WorkOrderType? workOrderType = null)
|
||||
{
|
||||
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
|
||||
return Ok(templates);
|
||||
}
|
||||
|
||||
[HttpGet("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> GetCompletionTemplate(int id)
|
||||
{
|
||||
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
|
||||
if (template == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Template not found." });
|
||||
return Ok(template);
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("completion-templates")]
|
||||
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
|
||||
return Ok(created);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPut("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
|
||||
return Ok(updated);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("completion-templates/{id:int}")]
|
||||
public async Task<IActionResult> DeleteCompletionTemplate(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _workOrderCompletionService.DeleteTemplateAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/completion-doc")]
|
||||
[RequestSizeLimit(30_000_000)]
|
||||
public async Task<IActionResult> UploadCompletionDoc(
|
||||
int id,
|
||||
[FromForm] IFormFile file,
|
||||
[FromForm] string? signOffName = null,
|
||||
[FromForm] string? signOffSignature = null,
|
||||
[FromForm] string? workOrderVersion = null)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
||||
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var fileUrl = await SaveFileAsync(file);
|
||||
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
|
||||
id,
|
||||
new WorkOrderCompletionDocUploadDto
|
||||
{
|
||||
SignOffName = signOffName,
|
||||
SignOffSignature = signOffSignature,
|
||||
WorkOrderVersion = workOrderVersion
|
||||
},
|
||||
fileUrl,
|
||||
actorId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
|
||||
{
|
||||
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/media")]
|
||||
public async Task<IActionResult> GetMedia(int id)
|
||||
{
|
||||
var media = await _workOrderMediaService.GetMediaAsync(id);
|
||||
if (media == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
|
||||
return Ok(media);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/media")]
|
||||
[RequestSizeLimit(30_000_000)]
|
||||
public async Task<IActionResult> AddMedia(
|
||||
int id,
|
||||
[FromForm] WorkOrderMediaCategory category,
|
||||
[FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new Response { Status = "Error", Message = "file is required." });
|
||||
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var fileUrl = await SaveFileAsync(file);
|
||||
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
|
||||
return Ok(media);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}/media/{mediaId:int}")]
|
||||
public async Task<IActionResult> DeleteMedia(int id, int mediaId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId);
|
||||
return NoContent();
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
{
|
||||
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex)
|
||||
{
|
||||
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("AddWorkorder")]
|
||||
public async Task<IActionResult> Addworkorder([FromForm] Workorder_DTO model)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
using Api.SeaHavenIndustries.Filters;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/workorders")]
|
||||
public class WorkOrderIngestController : ControllerBase
|
||||
{
|
||||
private const int MaxBatchSize = 100;
|
||||
private readonly IWorkOrderIngestService _ingestService;
|
||||
|
||||
public WorkOrderIngestController(IWorkOrderIngestService ingestService)
|
||||
{
|
||||
_ingestService = ingestService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target).
|
||||
/// Auth: X-Ingest-Key header.
|
||||
/// </summary>
|
||||
[HttpPost("ingest")]
|
||||
[IngestApiKey]
|
||||
[ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WorkOrderIngestBatchResultDto>> Ingest(
|
||||
[FromBody] WorkOrderIngestBatchRequestDto? batch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = NormalizeItems(batch);
|
||||
if (items.Count == 0)
|
||||
return BadRequest("At least one item with externalWorkOrderId is required.");
|
||||
|
||||
if (items.Count > MaxBatchSize)
|
||||
return BadRequest($"Max {MaxBatchSize} items per request.");
|
||||
|
||||
var result = await _ingestService.UpsertBatchAsync(items, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
private static List<WorkOrderIngestPayloadDto> NormalizeItems(WorkOrderIngestBatchRequestDto? batch)
|
||||
{
|
||||
if (batch?.Items is { Count: > 0 })
|
||||
return batch.Items;
|
||||
|
||||
return new List<WorkOrderIngestPayloadDto>();
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
{
|
||||
/// <summary>Ops health and coexistence metrics (Fase 7).</summary>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[ApiController]
|
||||
[Route("api/workorders/ops")]
|
||||
public class WorkOrderOpsController : ControllerBase
|
||||
{
|
||||
private readonly IWorkOrderOpsHealthService _healthService;
|
||||
|
||||
public WorkOrderOpsController(IWorkOrderOpsHealthService healthService)
|
||||
{
|
||||
_healthService = healthService;
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
[ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WorkOrderOpsHealthDto>> GetHealth(CancellationToken cancellationToken)
|
||||
{
|
||||
var health = await _healthService.GetHealthAsync(cancellationToken);
|
||||
return Ok(health);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
67
Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Filters
|
||||
{
|
||||
/// <summary>Validates X-Ingest-Key for POST /api/workorders/ingest.</summary>
|
||||
public class IngestApiKeyFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly WorkOrderIngestOptions _options;
|
||||
|
||||
public IngestApiKeyFilter(IOptions<WorkOrderIngestOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
context.Result = new ObjectResult(new { message = "Work order ingest is disabled." })
|
||||
{
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey)
|
||||
|| _options.ApiKey.Contains("${", StringComparison.Ordinal))
|
||||
{
|
||||
context.Result = new ObjectResult(new { message = "Ingest API key is not configured." })
|
||||
{
|
||||
StatusCode = StatusCodes.Status503ServiceUnavailable
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided)
|
||||
|| !FixedTimeEquals(provided.ToString(), _options.ApiKey))
|
||||
{
|
||||
context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." });
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string provided, string expected)
|
||||
{
|
||||
var providedBytes = Encoding.UTF8.GetBytes(provided);
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(expected);
|
||||
return providedBytes.Length == expectedBytes.Length
|
||||
&& CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class IngestApiKeyAttribute : ServiceFilterAttribute
|
||||
{
|
||||
public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter))
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
|
||||
namespace Api.SeaHavenIndustries.HostedServices
|
||||
{
|
||||
|
|
@ -33,8 +34,8 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (_lastRunDate != today)
|
||||
{
|
||||
_lastRunDate = today;
|
||||
await RunJobAsync(stoppingToken);
|
||||
if (await RunJobAsync(stoppingToken))
|
||||
_lastRunDate = today;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
}
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(CancellationToken stoppingToken)
|
||||
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -52,11 +53,13 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
|
||||
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
|
||||
_runState.LastPastDueCacheError = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_runState.LastPastDueCacheError = ex.Message;
|
||||
_logger.LogError(ex, "PastDue cache hosted job failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +70,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
||||
runAt = new TimeOnly(0, 10);
|
||||
|
||||
var current = TimeOnly.FromDateTime(now);
|
||||
return current >= runAt && current < runAt.AddMinutes(1);
|
||||
return TimeOnly.FromDateTime(now) >= runAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
|
||||
namespace Api.SeaHavenIndustries.HostedServices
|
||||
{
|
||||
|
|
@ -34,8 +35,8 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (_lastRunDate != today)
|
||||
{
|
||||
_lastRunDate = today;
|
||||
await RunJobAsync(stoppingToken);
|
||||
if (await RunJobAsync(stoppingToken))
|
||||
_lastRunDate = today;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
}
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(CancellationToken stoppingToken)
|
||||
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -55,11 +56,13 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
|
||||
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
|
||||
_runState.LastWeekRolledError = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_runState.LastWeekRolledError = ex.Message;
|
||||
_logger.LogError(ex, "WeekRolled hosted job failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,8 +78,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
|||
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
||||
runAt = new TimeOnly(0, 5);
|
||||
|
||||
var current = TimeOnly.FromDateTime(now);
|
||||
return current >= runAt && current < runAt.AddMinutes(1);
|
||||
return TimeOnly.FromDateTime(now) >= runAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
using Api.SeaHavenIndustries.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Middleware
|
||||
{
|
||||
/// <summary>Adds Sunset/Deprecation headers to legacy WorkOrder REST routes.</summary>
|
||||
public class LegacyDeprecationMiddleware
|
||||
{
|
||||
private static readonly HashSet<string> LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"GetWorkOrderList",
|
||||
"AddWorkorder",
|
||||
"EditWorkorder",
|
||||
"ChangeStatus",
|
||||
"ChangeAssignment",
|
||||
"DeleteWorkorder",
|
||||
"GetWorkorderById"
|
||||
};
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly LegacyEndpointOptions _options;
|
||||
|
||||
public LegacyDeprecationMiddleware(RequestDelegate next, IOptions<LegacyEndpointOptions> options)
|
||||
{
|
||||
_next = next;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (_options.DeprecationEnabled)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
var isLegacyWo = (path.Contains("/WorkOrder", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains("/workorders", StringComparison.OrdinalIgnoreCase))
|
||||
&& LegacyPathSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (isLegacyWo)
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
context.Response.Headers["Deprecation"] = "true";
|
||||
if (!string.IsNullOrWhiteSpace(_options.SunsetDate)
|
||||
&& DateTimeOffset.TryParse(_options.SunsetDate, out var sunset))
|
||||
{
|
||||
context.Response.Headers["Sunset"] = sunset.UtcDateTime.ToString("r");
|
||||
}
|
||||
context.Response.Headers["Link"] = "</api/workorders/board>; rel=\"successor-version\"";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
public class LegacyEndpointOptions
|
||||
{
|
||||
public const string SectionName = "LegacyEndpoints";
|
||||
|
||||
public bool DeprecationEnabled { get; set; }
|
||||
|
||||
/// <summary>ISO date returned in Sunset header (e.g. 2026-12-31).</summary>
|
||||
public string? SunsetDate { get; set; }
|
||||
}
|
||||
}
|
||||
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
public class SyncOptions
|
||||
{
|
||||
public const string SectionName = "Sync";
|
||||
|
||||
/// <summary>When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
}
|
||||
13
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
13
Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
public class WorkOrderIngestOptions
|
||||
{
|
||||
public const string SectionName = "WorkOrderIngest";
|
||||
|
||||
/// <summary>When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Shared secret for X-Ingest-Key header (Lambda / service accounts).</summary>
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
namespace Api.SeaHavenIndustries.Options
|
||||
{
|
||||
/// <summary>In-memory last-run timestamps for hosted domain jobs (ops health).</summary>
|
||||
public class WorkOrderJobRunState
|
||||
{
|
||||
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||
public string? LastWeekRolledError { get; set; }
|
||||
public string? LastPastDueCacheError { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using Api.SeaHavenIndustries.Helper;
|
||||
using Api.SeaHavenIndustries.HostedServices;
|
||||
using Api.SeaHavenIndustries.Middleware;
|
||||
using Api.SeaHavenIndustries.Options;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
|
@ -11,6 +12,7 @@ using Microsoft.OpenApi.Models;
|
|||
using System.Text;
|
||||
using SeaHaven.DataServices.DependencyInjection;
|
||||
using SeaHaven.Services.DependencyInjection;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
ConfigurationManager configuration = builder.Configuration;
|
||||
|
|
@ -59,9 +61,35 @@ builder.Services.AddBusinessServices();
|
|||
|
||||
builder.Services.Configure<WorkOrderJobsOptions>(
|
||||
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
||||
builder.Services.AddSingleton<WorkOrderJobRunState>();
|
||||
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
||||
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
||||
.Validate(
|
||||
o => !o.Enabled
|
||||
|| (!string.IsNullOrWhiteSpace(o.ApiKey)
|
||||
&& !o.ApiKey.Contains("${", StringComparison.Ordinal)
|
||||
&& o.ApiKey.Length >= 32),
|
||||
"WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.Configure<SyncOptions>(
|
||||
builder.Configuration.GetSection(SyncOptions.SectionName));
|
||||
builder.Services.Configure<LegacyEndpointOptions>(
|
||||
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
|
||||
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
|
||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
|
||||
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
|
||||
builder.Services.AddHostedService<PastDueCacheHostedService>();
|
||||
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
|
||||
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
|
||||
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
|
||||
Microsoft.Extensions.Options.IOptions<LegacyEndpointOptions>>(
|
||||
(health, sync, ingest, legacy) =>
|
||||
{
|
||||
health.SyncEnabled = sync.Value.Enabled;
|
||||
health.IngestEnabled = ingest.Value.Enabled;
|
||||
health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled;
|
||||
health.LegacySunsetDate = legacy.Value.SunsetDate;
|
||||
});
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
|
|
@ -128,6 +156,7 @@ if (!app.Environment.IsDevelopment())
|
|||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseCors();
|
||||
app.UseMiddleware<LegacyDeprecationMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
//"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
|
||||
|
||||
|
|
@ -34,5 +34,17 @@
|
|||
"WorkOrderJobs": {
|
||||
"WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" },
|
||||
"PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"WorkOrderIngest": {
|
||||
"Enabled": false,
|
||||
"ApiKey": "${WORKORDER_INGEST_API_KEY}"
|
||||
},
|
||||
"Sync": {
|
||||
"Enabled": true
|
||||
},
|
||||
"LegacyEndpoints": {
|
||||
"DeprecationEnabled": false,
|
||||
"SunsetDate": "2026-12-31"
|
||||
},
|
||||
"FrontendBaseUrl": "https://shoc.seahaven.com"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart })
|
||||
.IsUnique();
|
||||
|
||||
builder.Entity<CompletionDocTemplate>()
|
||||
.HasIndex(t => new { t.ServiceKey, t.IsActive });
|
||||
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
|
@ -56,6 +60,7 @@ namespace Data.SeaHavenIndustries
|
|||
.Property(w => w.InternalWONumber)
|
||||
.HasMaxLength(11);
|
||||
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.SiteCode)
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
|
@ -68,6 +73,16 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate })
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.Property(w => w.ExternalWorkOrderId)
|
||||
.HasMaxLength(450);
|
||||
|
||||
builder.Entity<WorkOrder>()
|
||||
.HasIndex(w => w.ExternalWorkOrderId)
|
||||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
||||
|
||||
builder.Entity<Dispatch>()
|
||||
.Property(d => d.RowVersion)
|
||||
.IsRowVersion();
|
||||
|
|
@ -110,6 +125,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderContacts> WorkOrderContacts { get; set; }
|
||||
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
|
||||
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
|
||||
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
|
||||
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
|
||||
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
|
||||
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
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.Sql(@"
|
||||
IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE workOrderAttachments ADD Category int NULL;
|
||||
END
|
||||
");
|
||||
|
||||
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.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
|
||||
SET DocStatus = 1
|
||||
WHERE DocStatus IS NULL
|
||||
AND SignOffAttachment IS NOT NULL
|
||||
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE workOrders
|
||||
SET DocStatus = NULL
|
||||
WHERE DocStatus = 1
|
||||
AND SignOffAttachment IS NOT NULL
|
||||
AND LTRIM(RTRIM(SignOffAttachment)) <> '';
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL
|
||||
DROP TABLE CompletionDocTemplates;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL
|
||||
ALTER TABLE workOrderAttachments DROP COLUMN Category;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_workOrders_ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
column: "ExternalWorkOrderId",
|
||||
unique: true,
|
||||
filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_workOrders_ExternalWorkOrderId",
|
||||
table: "workOrders");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ExternalWorkOrderId",
|
||||
table: "workOrders",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(450)",
|
||||
oldMaxLength: 450,
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
b.ToTable("Accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b =>
|
||||
|
|
@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("Addresses");
|
||||
b.ToTable("Addresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b =>
|
||||
|
|
@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
b.ToTable("Assets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Category", b =>
|
||||
|
|
@ -371,7 +371,62 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Categories");
|
||||
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);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b =>
|
||||
|
|
@ -438,7 +493,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkerOrderId");
|
||||
|
||||
b.ToTable("Comments");
|
||||
b.ToTable("Comments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
|
||||
|
|
@ -504,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("ContactId");
|
||||
|
||||
b.ToTable("ContactDetails");
|
||||
b.ToTable("ContactDetails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
|
||||
|
|
@ -596,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Contacts");
|
||||
b.ToTable("Contacts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Department", b =>
|
||||
|
|
@ -614,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Departments");
|
||||
b.ToTable("Departments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
|
||||
|
|
@ -704,9 +759,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");
|
||||
|
||||
|
|
@ -725,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("Dispatches");
|
||||
b.ToTable("Dispatches", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b =>
|
||||
|
|
@ -784,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchChecklistItems");
|
||||
b.ToTable("DispatchChecklistItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b =>
|
||||
|
|
@ -838,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("DispatchId");
|
||||
|
||||
b.ToTable("DispatchSignoffs");
|
||||
b.ToTable("DispatchSignoffs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b =>
|
||||
|
|
@ -908,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.IsUnique()
|
||||
.HasFilter("[Status] = 'Pending'");
|
||||
|
||||
b.ToTable("DispatchUpliftRequests");
|
||||
b.ToTable("DispatchUpliftRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
|
||||
|
|
@ -931,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("DispatchWorkOrders");
|
||||
b.ToTable("DispatchWorkOrders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
|
||||
|
|
@ -961,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DropdownOptions");
|
||||
b.ToTable("DropdownOptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>
|
||||
|
|
@ -1066,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("RegionId");
|
||||
|
||||
b.ToTable("Employees");
|
||||
b.ToTable("Employees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b =>
|
||||
|
|
@ -1106,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("EmployeeId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("EmployeeAddresses");
|
||||
b.ToTable("EmployeeAddresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b =>
|
||||
|
|
@ -1134,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeeEmails");
|
||||
b.ToTable("EmployeeEmails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b =>
|
||||
|
|
@ -1164,7 +1227,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.ToTable("EmployeePhones");
|
||||
b.ToTable("EmployeePhones", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
|
||||
|
|
@ -1312,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("FollowUps");
|
||||
b.ToTable("FollowUps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b =>
|
||||
|
|
@ -1337,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ForgetPasswordCodes");
|
||||
b.ToTable("ForgetPasswordCodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b =>
|
||||
|
|
@ -1355,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobTitles");
|
||||
b.ToTable("JobTitles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
|
||||
|
|
@ -1425,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Locations");
|
||||
b.ToTable("Locations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
|
||||
|
|
@ -1483,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("PMSchedules");
|
||||
b.ToTable("PMSchedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b =>
|
||||
|
|
@ -1557,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("Quotes");
|
||||
b.ToTable("Quotes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b =>
|
||||
|
|
@ -1605,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("QuoteId");
|
||||
|
||||
b.ToTable("QuotesLines");
|
||||
b.ToTable("QuotesLines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Region", b =>
|
||||
|
|
@ -1623,7 +1686,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Regions");
|
||||
b.ToTable("Regions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
|
||||
|
|
@ -1725,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaskListTemplates");
|
||||
b.ToTable("TaskListTemplates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
|
||||
|
|
@ -1770,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("TaskListTemplateId");
|
||||
|
||||
b.ToTable("TaskListTemplateItems");
|
||||
b.ToTable("TaskListTemplateItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||
|
|
@ -1872,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.ToTable("Templates");
|
||||
b.ToTable("Templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
|
|
@ -1960,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("Vendors");
|
||||
b.ToTable("Vendors", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
|
||||
|
|
@ -2019,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorAccessTokens");
|
||||
b.ToTable("VendorAccessTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
|
||||
|
|
@ -2277,7 +2340,16 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ExternalWorkOrderId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.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")
|
||||
.HasMaxLength(11)
|
||||
|
|
@ -2313,6 +2385,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");
|
||||
|
||||
|
|
@ -2346,6 +2430,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)");
|
||||
|
||||
|
|
@ -2380,12 +2468,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");
|
||||
|
||||
|
|
@ -2407,6 +2503,10 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("AssignTo", "ScheduledDate");
|
||||
|
||||
b.HasIndex("ExternalWorkOrderId")
|
||||
.IsUnique()
|
||||
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
|
||||
|
||||
b.HasIndex("InternalWONumber")
|
||||
.HasFilter("[istemplate] = 0");
|
||||
|
||||
|
|
@ -2424,7 +2524,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderType");
|
||||
|
||||
b.ToTable("workOrders");
|
||||
b.ToTable("workOrders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
|
||||
|
|
@ -2438,6 +2538,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Attachments")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("Category")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CreatedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
|
|
@ -2466,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderAttachments");
|
||||
b.ToTable("workOrderAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b =>
|
||||
|
|
@ -2516,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkOrderId");
|
||||
|
||||
b.ToTable("WorkOrderAuditLogs");
|
||||
b.ToTable("WorkOrderAuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b =>
|
||||
|
|
@ -2560,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("workOrderCategories");
|
||||
b.ToTable("workOrderCategories", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b =>
|
||||
|
|
@ -2607,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
|
||||
b.HasIndex("WorkorderId");
|
||||
|
||||
b.ToTable("WorkOrderContacts");
|
||||
b.ToTable("WorkOrderContacts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
|
||||
|
|
@ -2636,7 +2739,36 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.HasIndex("WorkOrderId", "FieldName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderFieldLocks");
|
||||
b.ToTable("WorkOrderFieldLocks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateOnly>("SourceWeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int>("WorkOrderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkOrderId", "SourceWeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
|
||||
|
|
|
|||
13
Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs
Normal file
13
Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class CompletionDocTemplate : FullAuditEntity
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string ServiceKey { get; set; } = "";
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
public string TemplateUrl { get; set; } = "";
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
using System;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
public class WorkOrderAttachments : FullAuditEntity
|
||||
{
|
||||
public string? Attachments { get; set; }
|
||||
public WorkOrderMediaCategory? Category { get; set; }
|
||||
public int? WorkorderId { get; set; }
|
||||
[ForeignKey(nameof(WorkorderId))]
|
||||
public virtual WorkOrder? WorkOrder { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@ namespace Data.SeaHavenIndustries
|
|||
[ForeignKey(nameof(PrimaryDispatchId))]
|
||||
public virtual Dispatch? PrimaryDispatch { get; set; }
|
||||
|
||||
// --- Service & Notes / POC (structured create) ---
|
||||
[MaxLength(200)]
|
||||
public string? PocName { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string? PocPhone { get; set; }
|
||||
[MaxLength(2000)]
|
||||
public string? PocNotes { get; set; }
|
||||
/// <summary>JSON array of extra service labels beyond primary (Trade).</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? ExtraServices { get; set; }
|
||||
[MaxLength(4000)]
|
||||
public string? ServiceNotes { get; set; }
|
||||
/// <summary>Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone.</summary>
|
||||
[MaxLength(50)]
|
||||
public string? TechPhone { get; set; }
|
||||
/// <summary>Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes.</summary>
|
||||
[MaxLength(2000)]
|
||||
public string? VendorNotes { get; set; }
|
||||
|
||||
// --- Scheduling slice ---
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
|
|
@ -52,6 +71,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,5 +143,6 @@ namespace SeaHaven.DataServices.Helpers
|
|||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public CompletionDocTemplateDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
|
||||
{
|
||||
var query = _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.IsActive && t.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(serviceKey))
|
||||
query = query.Where(t => t.ServiceKey == serviceKey);
|
||||
|
||||
if (workOrderType.HasValue)
|
||||
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
|
||||
|
||||
return await query
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(MapRow)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
|
||||
{
|
||||
return await _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Id == id && t.IsDeleted != true)
|
||||
.Select(MapRow)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trade))
|
||||
{
|
||||
var byTrade = await _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade)
|
||||
.OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1)
|
||||
.ThenBy(t => t.Name)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(MapRow)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (byTrade != null)
|
||||
return byTrade;
|
||||
}
|
||||
|
||||
if (!workOrderType.HasValue)
|
||||
return null;
|
||||
|
||||
return await _context.CompletionDocTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType)
|
||||
.OrderBy(t => t.Name)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(MapRow)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
|
||||
{
|
||||
template.CreatedDate = DateTime.UtcNow;
|
||||
await _context.CompletionDocTemplates.AddAsync(template);
|
||||
await _context.SaveChangesAsync();
|
||||
return template;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(CompletionDocTemplate template)
|
||||
{
|
||||
template.LastModificationTime = DateTime.UtcNow;
|
||||
_context.CompletionDocTemplates.Update(template);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var entity = await _context.CompletionDocTemplates.FindAsync(id);
|
||||
if (entity == null)
|
||||
return false;
|
||||
|
||||
entity.IsDeleted = true;
|
||||
entity.DeletionTime = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
|
||||
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
{
|
||||
public class WorkOrderDetailDataService : IWorkOrderDetailDataService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public WorkOrderDetailDataService(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(int workOrderId)
|
||||
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.AnyAsync(w => w.Id == workOrderId);
|
||||
|
||||
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
|
||||
{
|
||||
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.Where(w => w.Id == workOrderId)
|
||||
.Select(w => new WorkOrderDetailExtendedFields(
|
||||
w.Description,
|
||||
w.Trade,
|
||||
w.Problem,
|
||||
w.SubTrade,
|
||||
w.OriginalWeek,
|
||||
w.OriginalDate,
|
||||
w.CompletedDate,
|
||||
w.SignOffName,
|
||||
w.SignOffAttachment,
|
||||
w.SignOffSignature,
|
||||
w.ExtraServices,
|
||||
w.ServiceNotes,
|
||||
w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null
|
||||
? w.PrimaryDispatch.VendorNotes
|
||||
: w.VendorNotes))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId)
|
||||
{
|
||||
return await _context.Comments
|
||||
.AsNoTracking()
|
||||
.Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true)
|
||||
.OrderByDescending(c => c.CreatedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null)
|
||||
{
|
||||
var query = _context.WorkOrderAuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(a => a.WorkOrderId == workOrderId)
|
||||
.OrderByDescending(a => a.CreatedAt);
|
||||
|
||||
if (limit.HasValue)
|
||||
return await query.Take(limit.Value).ToListAsync();
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
|
||||
{
|
||||
return await _context.workOrderAttachments
|
||||
.AsNoTracking()
|
||||
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
|
||||
.OrderByDescending(a => a.CreatedDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
|
||||
{
|
||||
return await _context.DispatchSignoffs
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true)
|
||||
.OrderByDescending(s => s.SignedAt)
|
||||
.Select(s => new DispatchSignoffRow(
|
||||
s.DispatchId,
|
||||
s.SignoffType,
|
||||
s.Name,
|
||||
s.SignedAt))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
|
||||
{
|
||||
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public interface IWorkOrderDetailDataService
|
||||
{
|
||||
Task<bool> ExistsAsync(int workOrderId);
|
||||
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
|
||||
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
|
||||
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
|
||||
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
|
||||
}
|
||||
|
||||
public interface ICompletionDocTemplateDataService
|
||||
{
|
||||
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
|
||||
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
|
||||
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
|
||||
Task UpdateAsync(CompletionDocTemplate template);
|
||||
Task<bool> DeleteAsync(int id);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
33
SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
Normal file
33
SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.DataServices.Interfaces
|
||||
{
|
||||
public record WorkOrderDetailExtendedFields(
|
||||
string? Description,
|
||||
string? Trade,
|
||||
string? Problem,
|
||||
string? SubTrade,
|
||||
DateOnly? OriginalWeek,
|
||||
DateOnly? OriginalDate,
|
||||
DateTime? CompletedDate,
|
||||
string? SignOffName,
|
||||
string? SignOffAttachment,
|
||||
string? SignOffSignature,
|
||||
string? ExtraServices,
|
||||
string? ServiceNotes,
|
||||
string? VendorNotes);
|
||||
|
||||
public record DispatchSignoffRow(
|
||||
int DispatchId,
|
||||
string? SignoffType,
|
||||
string? Name,
|
||||
DateTime? SignedAt);
|
||||
|
||||
public record CompletionDocTemplateRow(
|
||||
int Id,
|
||||
string Name,
|
||||
string ServiceKey,
|
||||
WorkOrderType? WorkOrderType,
|
||||
string TemplateUrl,
|
||||
bool IsActive);
|
||||
}
|
||||
|
|
@ -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,7 +33,11 @@ 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>
|
||||
|
|
|
|||
106
SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs
Normal file
106
SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
|
||||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public class WorkOrderDetailDto
|
||||
{
|
||||
public WorkOrderDetailInfoDto Info { get; set; } = new();
|
||||
public WorkOrderCompletionDto Completion { get; set; } = new();
|
||||
public List<WorkOrderCommentDto> Comments { get; set; } = new();
|
||||
public List<WorkOrderAuditEntryDto> Audit { get; set; } = new();
|
||||
public List<WorkOrderMediaFileDto> Media { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WorkOrderDetailInfoDto : WorkOrderBoardRowDto
|
||||
{
|
||||
public string? Description { get; set; }
|
||||
public string? Trade { get; set; }
|
||||
public string? Problem { get; set; }
|
||||
public string? SubTrade { get; set; }
|
||||
public DateOnly? OriginalWeek { get; set; }
|
||||
public DateOnly? OriginalDate { get; set; }
|
||||
public DateTime? CompletedDate { get; set; }
|
||||
public string? VendorNotes { get; set; }
|
||||
}
|
||||
|
||||
public class CompletionDocTemplateDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string ServiceKey { get; set; } = "";
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
public string TemplateUrl { get; set; } = "";
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class CompletionDocTemplateCreateDto
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string ServiceKey { get; set; } = "";
|
||||
public WorkOrderType? WorkOrderType { get; set; }
|
||||
public string TemplateUrl { get; set; } = "";
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class WorkOrderCompletionDto
|
||||
{
|
||||
public DocStatus? DocStatus { get; set; }
|
||||
public CompletionDocTemplateDto? Template { get; set; }
|
||||
public string? SignOffName { get; set; }
|
||||
public string? SignOffAttachment { get; set; }
|
||||
public string? SignOffSignature { get; set; }
|
||||
public List<DispatchSignoffDto> DispatchSignoffs { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DispatchSignoffDto
|
||||
{
|
||||
public int DispatchId { get; set; }
|
||||
public string? SignoffType { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? SignedAt { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderCommentDto
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
public class WorkOrderCommentCreateDto
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
}
|
||||
|
||||
public class WorkOrderAuditEntryDto
|
||||
{
|
||||
public string Type { get; set; } = "manual";
|
||||
public string? DispatcherId { get; set; }
|
||||
public string Action { get; set; } = "";
|
||||
public string? FieldName { get; set; }
|
||||
public string OldValue { get; set; } = "";
|
||||
public string NewValue { get; set; } = "";
|
||||
public string Time { get; set; } = "";
|
||||
public int? DispatchId { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderMediaFileDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public WorkOrderMediaCategory Category { get; set; }
|
||||
public string Url { get; set; } = "";
|
||||
public string? FileName { get; set; }
|
||||
public string? UploadedAt { get; set; }
|
||||
public bool IsLegacy { get; set; }
|
||||
}
|
||||
|
||||
public class WorkOrderCompletionDocUploadDto
|
||||
{
|
||||
public string? SignOffName { get; set; }
|
||||
public string? SignOffSignature { get; set; }
|
||||
public string? WorkOrderVersion { get; set; }
|
||||
}
|
||||
}
|
||||
54
SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs
Normal file
54
SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
namespace SeaHaven.Services.DTOs
|
||||
{
|
||||
public sealed class WorkOrderIngestPayloadDto
|
||||
{
|
||||
public required string ExternalWorkOrderId { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? WoStatus { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
public string? Customer { get; set; }
|
||||
public string? SiteCode { get; set; }
|
||||
public string? Building { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public DateTime? DateReported { get; set; }
|
||||
public DateTime? ScheduledStart { get; set; }
|
||||
public string? SourceEmailS3Key { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WorkOrderIngestBatchRequestDto
|
||||
{
|
||||
public List<WorkOrderIngestPayloadDto>? Items { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WorkOrderIngestItemResultDto
|
||||
{
|
||||
public required string ExternalWorkOrderId { get; set; }
|
||||
public int WorkOrderId { get; set; }
|
||||
public bool Created { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WorkOrderIngestBatchResultDto
|
||||
{
|
||||
public int Created { get; set; }
|
||||
public int Updated { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
public List<WorkOrderIngestItemResultDto> Results { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class WorkOrderOpsHealthDto
|
||||
{
|
||||
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||
public string? LastWeekRolledError { get; set; }
|
||||
public string? LastPastDueCacheError { get; set; }
|
||||
public int SyncRejectedLast24h { get; set; }
|
||||
public int FieldLockCount { get; set; }
|
||||
public bool SyncEnabled { get; set; }
|
||||
public bool IngestEnabled { get; set; }
|
||||
public bool LegacyDeprecationEnabled { get; set; }
|
||||
public string? LegacySunsetDate { get; set; }
|
||||
public DateTime CheckedAtUtc { get; set; }
|
||||
}
|
||||
}
|
||||
87
SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs
Normal file
87
SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderAuditProjection
|
||||
{
|
||||
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 = oldValue,
|
||||
NewValue = newValue,
|
||||
Time = log.CreatedAt.ToString("o"),
|
||||
DispatchId = log.DispatchId
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventType))
|
||||
return "manual";
|
||||
|
||||
return eventType.ToLowerInvariant() switch
|
||||
{
|
||||
"manual" => "manual",
|
||||
"system" => "system",
|
||||
"sync" => "sync",
|
||||
"vendor" => "vendor",
|
||||
_ => eventType.ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
52
SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs
Normal file
52
SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderCommentProjection
|
||||
{
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
27
SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs
Normal file
27
SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
/// <summary>DynamoDB / ingest field mapping shared by Sync and direct ingest.</summary>
|
||||
public static class WorkOrderIngestFieldMapper
|
||||
{
|
||||
public static string? MapStatus(string? dynamoStatus)
|
||||
{
|
||||
return dynamoStatus switch
|
||||
{
|
||||
"new" => "Open",
|
||||
"assigned" => "Open",
|
||||
"in_progress" => "In Progress",
|
||||
"on_hold" => "On Hold",
|
||||
"completed" => "Done",
|
||||
"cancelled" => "Cancelled",
|
||||
"unknown" => "Open",
|
||||
_ => string.IsNullOrWhiteSpace(dynamoStatus) ? null : "Open"
|
||||
};
|
||||
}
|
||||
|
||||
public static string? MapSeverityToPriority(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity)) return null;
|
||||
return $"Sev {severity}";
|
||||
}
|
||||
}
|
||||
}
|
||||
91
SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs
Normal file
91
SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Helpers
|
||||
{
|
||||
public static class WorkOrderMediaProjection
|
||||
{
|
||||
public static List<WorkOrderMediaFileDto> ProjectAll(
|
||||
WorkOrder workOrder,
|
||||
IEnumerable<WorkOrderAttachments> attachments)
|
||||
{
|
||||
var media = new List<WorkOrderMediaFileDto>();
|
||||
var attachmentId = -1;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workOrder.BeforPhotoAttachment))
|
||||
{
|
||||
media.Add(new WorkOrderMediaFileDto
|
||||
{
|
||||
Id = attachmentId--,
|
||||
Category = WorkOrderMediaCategory.Before,
|
||||
Url = workOrder.BeforPhotoAttachment,
|
||||
FileName = ExtractFileName(workOrder.BeforPhotoAttachment),
|
||||
IsLegacy = true
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workOrder.AfterPhotoAttachment))
|
||||
{
|
||||
media.Add(new WorkOrderMediaFileDto
|
||||
{
|
||||
Id = attachmentId--,
|
||||
Category = WorkOrderMediaCategory.After,
|
||||
Url = workOrder.AfterPhotoAttachment,
|
||||
FileName = ExtractFileName(workOrder.AfterPhotoAttachment),
|
||||
IsLegacy = true
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workOrder.SignOffAttachment))
|
||||
{
|
||||
media.Add(new WorkOrderMediaFileDto
|
||||
{
|
||||
Id = attachmentId--,
|
||||
Category = WorkOrderMediaCategory.Completion,
|
||||
Url = workOrder.SignOffAttachment,
|
||||
FileName = ExtractFileName(workOrder.SignOffAttachment),
|
||||
IsLegacy = true
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var attachment in attachments.Where(a => a.IsDeleted != true))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Attachments))
|
||||
continue;
|
||||
|
||||
media.Add(new WorkOrderMediaFileDto
|
||||
{
|
||||
Id = attachment.Id,
|
||||
Category = attachment.Category ?? WorkOrderMediaCategory.Extra,
|
||||
Url = attachment.Attachments,
|
||||
FileName = ExtractFileName(attachment.Attachments),
|
||||
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
|
||||
IsLegacy = false
|
||||
});
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
private static string? ExtractFileName(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return null;
|
||||
|
||||
var lastSlash = url.LastIndexOf('/');
|
||||
var segment = lastSlash >= 0 && lastSlash < url.Length - 1
|
||||
? url[(lastSlash + 1)..]
|
||||
: url;
|
||||
|
||||
try
|
||||
{
|
||||
return Uri.UnescapeDataString(segment);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -123,6 +123,5 @@ namespace SeaHaven.Services.Implementation
|
|||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
// Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and
|
||||
|
|
@ -139,6 +190,7 @@ namespace SeaHaven.Services.Implementation
|
|||
dispatch.WorkOrderId = workOrder.Id;
|
||||
}
|
||||
|
||||
|
||||
await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber);
|
||||
await StageChangesAsync(workOrder.Id, changes, actorId, dispatch);
|
||||
|
||||
|
|
@ -168,6 +220,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))
|
||||
|
|
|
|||
|
|
@ -109,7 +109,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
|
||||
|
|
@ -118,5 +121,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;
|
||||
|
|
@ -186,6 +187,10 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrderBoardFieldNames.ApptDate => new List<FieldChange> { ApplyApptDate(dispatch!, value, auditField) },
|
||||
WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value),
|
||||
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.")
|
||||
};
|
||||
}
|
||||
|
|
@ -260,6 +265,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}");
|
||||
|
||||
|
|
@ -286,6 +296,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);
|
||||
|
|
@ -503,6 +596,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>();
|
||||
|
|
|
|||
59
SeaHaven.Services/Implementation/WorkOrderCommentService.cs
Normal file
59
SeaHaven.Services/Implementation/WorkOrderCommentService.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderCommentService : IWorkOrderCommentService
|
||||
{
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly ICommentDataService _commentData;
|
||||
|
||||
public WorkOrderCommentService(
|
||||
IWorkOrderDetailDataService detailData,
|
||||
ICommentDataService commentData)
|
||||
{
|
||||
_detailData = detailData;
|
||||
_commentData = commentData;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId)
|
||||
{
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
return null;
|
||||
|
||||
var comments = await _detailData.GetCommentsAsync(workOrderId);
|
||||
return WorkOrderCommentProjection.ToDtos(comments);
|
||||
}
|
||||
|
||||
public async Task<WorkOrderCommentDto> AddCommentAsync(
|
||||
int workOrderId,
|
||||
WorkOrderCommentCreateDto request,
|
||||
string? actorId,
|
||||
string? documentUrl = null)
|
||||
{
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Text))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required.");
|
||||
|
||||
var comment = new Comments
|
||||
{
|
||||
WorkerOrderId = workOrderId,
|
||||
UserId = actorId,
|
||||
Commenttext = request.Text.Trim(),
|
||||
CommentType = "General",
|
||||
RecordType = "WorkOrder",
|
||||
Documents = documentUrl,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var saved = await _commentData.AddAsync(comment);
|
||||
return WorkOrderCommentProjection.ToDto(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
178
SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
Normal file
178
SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderCompletionService : IWorkOrderCompletionService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly ICompletionDocTemplateDataService _templateData;
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly IWorkOrderAuditService _auditService;
|
||||
|
||||
public WorkOrderCompletionService(
|
||||
ApplicationDbContext context,
|
||||
ICompletionDocTemplateDataService templateData,
|
||||
IWorkOrderDetailDataService detailData,
|
||||
IWorkOrderAuditService auditService)
|
||||
{
|
||||
_context = context;
|
||||
_templateData = templateData;
|
||||
_detailData = detailData;
|
||||
_auditService = auditService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
|
||||
{
|
||||
var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType);
|
||||
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
|
||||
{
|
||||
var row = await _templateData.GetByIdAsync(id);
|
||||
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
|
||||
}
|
||||
|
||||
public async Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
||||
int workOrderId,
|
||||
WorkOrderCompletionDocUploadDto request,
|
||||
string fileUrl,
|
||||
string? actorId)
|
||||
{
|
||||
var workOrder = await _context.workOrders
|
||||
.FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null));
|
||||
|
||||
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.");
|
||||
|
||||
var workOrderVersion = ParseRowVersion(request.WorkOrderVersion);
|
||||
if (workOrderVersion == null)
|
||||
throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required.");
|
||||
|
||||
if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion))
|
||||
throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry.");
|
||||
|
||||
_context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion;
|
||||
|
||||
var oldDocStatus = workOrder.DocStatus?.ToString();
|
||||
var oldAttachment = workOrder.SignOffAttachment;
|
||||
|
||||
workOrder.SignOffAttachment = fileUrl;
|
||||
if (!string.IsNullOrWhiteSpace(request.SignOffName))
|
||||
workOrder.SignOffName = request.SignOffName.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.SignOffSignature))
|
||||
workOrder.SignOffSignature = request.SignOffSignature.Trim();
|
||||
|
||||
workOrder.DocStatus = DocStatus.Yes;
|
||||
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId);
|
||||
if (oldDocStatus != DocStatus.Yes.ToString())
|
||||
await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
|
||||
var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType);
|
||||
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
|
||||
|
||||
return new WorkOrderCompletionDto
|
||||
{
|
||||
DocStatus = workOrder.DocStatus,
|
||||
Template = template == null ? null : WorkOrderDetailService.MapTemplate(template),
|
||||
SignOffName = extended?.SignOffName,
|
||||
SignOffAttachment = extended?.SignOffAttachment,
|
||||
SignOffSignature = extended?.SignOffSignature,
|
||||
DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto
|
||||
{
|
||||
DispatchId = s.DispatchId,
|
||||
SignoffType = s.SignoffType,
|
||||
Name = s.Name,
|
||||
SignedAt = s.SignedAt?.ToUniversalTime().ToString("o")
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
ValidateTemplateRequest(request);
|
||||
|
||||
var entity = new CompletionDocTemplate
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
ServiceKey = request.ServiceKey.Trim(),
|
||||
WorkOrderType = request.WorkOrderType,
|
||||
TemplateUrl = request.TemplateUrl.Trim(),
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
var created = await _templateData.CreateAsync(entity);
|
||||
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
||||
created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive));
|
||||
}
|
||||
|
||||
public async Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
ValidateTemplateRequest(request);
|
||||
|
||||
var entity = await _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
|
||||
if (entity == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.ServiceKey = request.ServiceKey.Trim();
|
||||
entity.WorkOrderType = request.WorkOrderType;
|
||||
entity.TemplateUrl = request.TemplateUrl.Trim();
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await _templateData.UpdateAsync(entity);
|
||||
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
|
||||
entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive));
|
||||
}
|
||||
|
||||
public async Task DeleteTemplateAsync(int id)
|
||||
{
|
||||
if (!await _templateData.DeleteAsync(id))
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
|
||||
}
|
||||
|
||||
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TemplateUrl))
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required.");
|
||||
|
||||
if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ParseRowVersion(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
182
SeaHaven.Services/Implementation/WorkOrderDetailService.cs
Normal file
182
SeaHaven.Services/Implementation/WorkOrderDetailService.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderDetailService : IWorkOrderDetailService
|
||||
{
|
||||
private readonly IWorkOrderBoardService _boardService;
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
private readonly ICompletionDocTemplateDataService _templateData;
|
||||
private readonly IUserDataService _userDataService;
|
||||
|
||||
public WorkOrderDetailService(
|
||||
IWorkOrderBoardService boardService,
|
||||
IWorkOrderDetailDataService detailData,
|
||||
ICompletionDocTemplateDataService templateData,
|
||||
IUserDataService userDataService)
|
||||
{
|
||||
_boardService = boardService;
|
||||
_detailData = detailData;
|
||||
_templateData = templateData;
|
||||
_userDataService = userDataService;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId)
|
||||
{
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
return null;
|
||||
|
||||
var boardRow = await _boardService.GetBoardRowAsync(workOrderId);
|
||||
if (boardRow == null)
|
||||
return null;
|
||||
|
||||
var extended = await _detailData.GetExtendedFieldsAsync(workOrderId);
|
||||
var comments = await _detailData.GetCommentsAsync(workOrderId);
|
||||
var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50);
|
||||
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
|
||||
var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId);
|
||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||
|
||||
var template = await _templateData.ResolveForWorkOrderAsync(
|
||||
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, authorNames),
|
||||
Audit = await MapAuditAsync(audit),
|
||||
Media = workOrder == null
|
||||
? new List<WorkOrderMediaFileDto>()
|
||||
: WorkOrderMediaProjection.ProjectAll(workOrder, attachments)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50)
|
||||
{
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
return null;
|
||||
|
||||
var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit);
|
||||
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(
|
||||
WorkOrderBoardRowDto row,
|
||||
WorkOrderDetailExtendedFields? extended)
|
||||
{
|
||||
return new WorkOrderDetailInfoDto
|
||||
{
|
||||
Id = row.Id,
|
||||
WoNumber = row.WoNumber,
|
||||
RescheduleCount = row.RescheduleCount,
|
||||
CarriedOver = row.CarriedOver,
|
||||
WorkOrderType = row.WorkOrderType,
|
||||
IsPastDue = row.IsPastDue,
|
||||
SiteCode = row.SiteCode,
|
||||
LocationName = row.LocationName,
|
||||
PocName = row.PocName,
|
||||
PocPhone = row.PocPhone,
|
||||
PocNotes = row.PocNotes,
|
||||
LifecycleStatus = row.LifecycleStatus,
|
||||
LifecycleStatusLabel = row.LifecycleStatusLabel,
|
||||
LegacyStatus = row.LegacyStatus,
|
||||
DispatcherId = row.DispatcherId,
|
||||
DispatcherName = row.DispatcherName,
|
||||
Initials = row.Initials,
|
||||
Color = row.Color,
|
||||
DueDate = row.DueDate,
|
||||
ScheduledDate = row.ScheduledDate,
|
||||
TargetWeek = row.TargetWeek,
|
||||
ScheduleWeekOnly = row.ScheduleWeekOnly,
|
||||
DayGroup = row.DayGroup,
|
||||
VendorId = row.VendorId,
|
||||
VendorName = row.VendorName,
|
||||
TechName = row.TechName,
|
||||
TechPhone = row.TechPhone,
|
||||
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,
|
||||
Description = extended?.Description,
|
||||
Trade = extended?.Trade,
|
||||
Problem = extended?.Problem,
|
||||
SubTrade = extended?.SubTrade,
|
||||
OriginalWeek = extended?.OriginalWeek,
|
||||
OriginalDate = extended?.OriginalDate,
|
||||
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,
|
||||
CompletionDocTemplateRow? template,
|
||||
IReadOnlyList<DispatchSignoffRow> signoffs)
|
||||
{
|
||||
return new WorkOrderCompletionDto
|
||||
{
|
||||
DocStatus = docStatus,
|
||||
Template = template == null ? null : MapTemplate(template),
|
||||
SignOffName = extended?.SignOffName,
|
||||
SignOffAttachment = extended?.SignOffAttachment,
|
||||
SignOffSignature = extended?.SignOffSignature,
|
||||
DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto
|
||||
{
|
||||
DispatchId = s.DispatchId,
|
||||
SignoffType = s.SignoffType,
|
||||
Name = s.Name,
|
||||
SignedAt = s.SignedAt?.ToUniversalTime().ToString("o")
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
internal static CompletionDocTemplateDto MapTemplate(CompletionDocTemplateRow template)
|
||||
=> new()
|
||||
{
|
||||
Id = template.Id,
|
||||
Name = template.Name,
|
||||
ServiceKey = template.ServiceKey,
|
||||
WorkOrderType = template.WorkOrderType,
|
||||
TemplateUrl = template.TemplateUrl,
|
||||
IsActive = template.IsActive
|
||||
};
|
||||
}
|
||||
}
|
||||
216
SeaHaven.Services/Implementation/WorkOrderIngestService.cs
Normal file
216
SeaHaven.Services/Implementation/WorkOrderIngestService.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderIngestService : IWorkOrderIngestService
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly ISyncFieldMergePolicy _mergePolicy;
|
||||
private readonly IWorkOrderFieldLockService _fieldLocks;
|
||||
private readonly IWorkOrderAuditService _audit;
|
||||
|
||||
public WorkOrderIngestService(
|
||||
ApplicationDbContext db,
|
||||
ISyncFieldMergePolicy mergePolicy,
|
||||
IWorkOrderFieldLockService fieldLocks,
|
||||
IWorkOrderAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_mergePolicy = mergePolicy;
|
||||
_fieldLocks = fieldLocks;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
|
||||
IReadOnlyList<WorkOrderIngestPayloadDto> items,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new WorkOrderIngestBatchResultDto();
|
||||
if (items.Count == 0)
|
||||
return result;
|
||||
|
||||
var useTransaction = _db.Database.IsRelational();
|
||||
await using var transaction = useTransaction
|
||||
? await _db.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
var nextSeed = await AllocateNextWoSeedAsync(cancellationToken);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.ExternalWorkOrderId))
|
||||
{
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken);
|
||||
nextSeed = updatedSeed;
|
||||
result.Results.Add(upsert);
|
||||
if (upsert.Created)
|
||||
result.Created++;
|
||||
else
|
||||
result.Updated++;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction != null)
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction != null)
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<(WorkOrderIngestItemResultDto Result, int NextSeed)> UpsertOneAsync(
|
||||
WorkOrderIngestPayloadDto item,
|
||||
int nextSeed,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _db.workOrders
|
||||
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == item.ExternalWorkOrderId, cancellationToken);
|
||||
|
||||
var locationId = await ResolveLocationIdAsync(
|
||||
item.SiteCode, item.Building, item.Address, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed);
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
InternalWONumber = woNumber,
|
||||
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||
WorkerOrderNumber = item.ExternalWorkOrderId,
|
||||
WorkerOrderTitle = item.Description,
|
||||
Description = item.Description,
|
||||
Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus) ?? "Open",
|
||||
Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity),
|
||||
Severity = item.Severity,
|
||||
Customer = item.Customer,
|
||||
SiteCode = item.SiteCode,
|
||||
Building = item.Building,
|
||||
LocationId = locationId,
|
||||
DueDate = item.DueDate,
|
||||
DateReported = item.DateReported,
|
||||
ScheduledStart = item.ScheduledStart,
|
||||
SourceEmailS3Key = item.SourceEmailS3Key,
|
||||
CreatedDate = item.CreatedAt ?? DateTime.UtcNow,
|
||||
istemplate = false
|
||||
};
|
||||
_db.workOrders.Add(wo);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (new WorkOrderIngestItemResultDto
|
||||
{
|
||||
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||
WorkOrderId = wo.Id,
|
||||
Created = true
|
||||
}, updatedSeed);
|
||||
}
|
||||
|
||||
var syncContext = new WorkOrderSyncContext
|
||||
{
|
||||
WorkOrder = existing,
|
||||
FieldLocks = _fieldLocks,
|
||||
Audit = _audit
|
||||
};
|
||||
|
||||
if (item.Description != null)
|
||||
{
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "Description", item.Description);
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "WorkerOrderTitle", item.Description);
|
||||
}
|
||||
|
||||
var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus);
|
||||
if (mappedStatus != null)
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus);
|
||||
|
||||
var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity);
|
||||
if (priority != null)
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority);
|
||||
|
||||
if (item.Severity != null)
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "Severity", item.Severity);
|
||||
|
||||
if (item.SiteCode != null)
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "SiteCode", item.SiteCode);
|
||||
|
||||
if (item.Building != null)
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "Building", item.Building);
|
||||
|
||||
if (locationId.HasValue)
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "LocationId", locationId.Value);
|
||||
|
||||
if (item.DueDate.HasValue)
|
||||
await _mergePolicy.TryApplyAsync(syncContext, "DueDate", item.DueDate.Value);
|
||||
|
||||
if (item.Customer != null)
|
||||
existing.Customer = item.Customer;
|
||||
|
||||
// Sync-owned metadata (not in ShocOwnedFieldSet)
|
||||
existing.DateReported = item.DateReported ?? existing.DateReported;
|
||||
existing.ScheduledStart = item.ScheduledStart ?? existing.ScheduledStart;
|
||||
existing.SourceEmailS3Key = item.SourceEmailS3Key ?? existing.SourceEmailS3Key;
|
||||
|
||||
return (new WorkOrderIngestItemResultDto
|
||||
{
|
||||
ExternalWorkOrderId = item.ExternalWorkOrderId,
|
||||
WorkOrderId = existing.Id,
|
||||
Created = false
|
||||
}, nextSeed);
|
||||
}
|
||||
|
||||
private async Task<int> AllocateNextWoSeedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var maxId = await _db.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0;
|
||||
return Math.Max(maxId + 1, 1);
|
||||
}
|
||||
|
||||
private static (string Number, int NextSeed) AllocateInternalWoNumber(int seed)
|
||||
{
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _))
|
||||
normalized = seed.ToString().PadLeft(11, '0');
|
||||
return (normalized, seed + 1);
|
||||
}
|
||||
|
||||
private async Task<int?> ResolveLocationIdAsync(
|
||||
string? siteCode,
|
||||
string? building,
|
||||
string? address,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
||||
return null;
|
||||
|
||||
var matchCode = siteCode ?? building;
|
||||
var existing = await _db.Locations
|
||||
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
return existing.Id;
|
||||
|
||||
var location = new Locations
|
||||
{
|
||||
Name = matchCode,
|
||||
Title = building,
|
||||
Address1 = address,
|
||||
Status = "Active"
|
||||
};
|
||||
|
||||
_db.Locations.Add(location);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return location.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
134
SeaHaven.Services/Implementation/WorkOrderMediaService.cs
Normal file
134
SeaHaven.Services/Implementation/WorkOrderMediaService.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderMediaService : IWorkOrderMediaService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IWorkOrderDetailDataService _detailData;
|
||||
|
||||
public WorkOrderMediaService(ApplicationDbContext context, IWorkOrderDetailDataService detailData)
|
||||
{
|
||||
_context = context;
|
||||
_detailData = detailData;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId)
|
||||
{
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
return null;
|
||||
|
||||
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
|
||||
if (workOrder == null)
|
||||
return null;
|
||||
|
||||
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
|
||||
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
|
||||
}
|
||||
|
||||
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
|
||||
int workOrderId,
|
||||
WorkOrderMediaCategory category,
|
||||
string fileUrl,
|
||||
string? actorId)
|
||||
{
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == 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 (category == WorkOrderMediaCategory.Completion)
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"UseCompletionDocEndpoint",
|
||||
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
|
||||
}
|
||||
|
||||
if (category == WorkOrderMediaCategory.Before)
|
||||
{
|
||||
workOrder.BeforPhotoAttachment = fileUrl;
|
||||
await _context.SaveChangesAsync();
|
||||
return new WorkOrderMediaFileDto
|
||||
{
|
||||
Id = -1,
|
||||
Category = category,
|
||||
Url = fileUrl,
|
||||
IsLegacy = true
|
||||
};
|
||||
}
|
||||
|
||||
if (category == WorkOrderMediaCategory.After)
|
||||
{
|
||||
workOrder.AfterPhotoAttachment = fileUrl;
|
||||
await _context.SaveChangesAsync();
|
||||
return new WorkOrderMediaFileDto
|
||||
{
|
||||
Id = -2,
|
||||
Category = category,
|
||||
Url = fileUrl,
|
||||
IsLegacy = true
|
||||
};
|
||||
}
|
||||
|
||||
var attachment = new WorkOrderAttachments
|
||||
{
|
||||
WorkorderId = workOrderId,
|
||||
Attachments = fileUrl,
|
||||
Category = category,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
createdby = actorId
|
||||
};
|
||||
|
||||
_context.workOrderAttachments.Add(attachment);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new WorkOrderMediaFileDto
|
||||
{
|
||||
Id = attachment.Id,
|
||||
Category = category,
|
||||
Url = fileUrl,
|
||||
UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"),
|
||||
IsLegacy = false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId)
|
||||
{
|
||||
if (mediaId <= 0)
|
||||
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint.");
|
||||
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == 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.");
|
||||
|
||||
var attachment = await _context.workOrderAttachments
|
||||
.FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true);
|
||||
|
||||
if (attachment == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Media not found.");
|
||||
|
||||
attachment.IsDeleted = true;
|
||||
attachment.DeletionTime = DateTime.UtcNow;
|
||||
attachment.DeleterUserId = actorId;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly WorkOrderJobRunStateAccessor _runState;
|
||||
private readonly WorkOrderOpsHealthOptions _options;
|
||||
|
||||
public WorkOrderOpsHealthService(
|
||||
ApplicationDbContext db,
|
||||
WorkOrderJobRunStateAccessor runState,
|
||||
IOptions<WorkOrderOpsHealthOptions> options)
|
||||
{
|
||||
_db = db;
|
||||
_runState = runState;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var since = DateTime.UtcNow.AddHours(-24);
|
||||
|
||||
var syncRejected = await _db.WorkOrderAuditLogs
|
||||
.CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken);
|
||||
|
||||
var fieldLocks = await _db.WorkOrderFieldLocks.CountAsync(cancellationToken);
|
||||
|
||||
var state = _runState.State;
|
||||
DateTime? ledgerWeekRolled = null;
|
||||
if (await _db.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken))
|
||||
{
|
||||
ledgerWeekRolled = await _db.WorkOrderWeekRolledLedgers
|
||||
.MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken);
|
||||
}
|
||||
|
||||
return new WorkOrderOpsHealthDto
|
||||
{
|
||||
LastWeekRolledRunUtc = state.LastWeekRolledRunUtc ?? ledgerWeekRolled,
|
||||
LastPastDueCacheRunUtc = state.LastPastDueCacheRunUtc,
|
||||
LastWeekRolledError = state.LastWeekRolledError,
|
||||
LastPastDueCacheError = state.LastPastDueCacheError,
|
||||
SyncRejectedLast24h = syncRejected,
|
||||
FieldLockCount = fieldLocks,
|
||||
SyncEnabled = _options.SyncEnabled,
|
||||
IngestEnabled = _options.IngestEnabled,
|
||||
LegacyDeprecationEnabled = _options.LegacyDeprecationEnabled,
|
||||
LegacySunsetDate = _options.LegacySunsetDate,
|
||||
CheckedAtUtc = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Options bridged from API configuration for health snapshots.</summary>
|
||||
public class WorkOrderOpsHealthOptions
|
||||
{
|
||||
public bool SyncEnabled { get; set; } = true;
|
||||
public bool IngestEnabled { get; set; } = true;
|
||||
public bool LegacyDeprecationEnabled { get; set; }
|
||||
public string? LegacySunsetDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Shared in-memory job run timestamps (API hosted services + ops health).</summary>
|
||||
public class WorkOrderJobRunStateAccessor
|
||||
{
|
||||
public WorkOrderJobRunState State { get; } = new();
|
||||
}
|
||||
|
||||
public class WorkOrderJobRunState
|
||||
{
|
||||
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||
public string? LastWeekRolledError { get; set; }
|
||||
public string? LastPastDueCacheError { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
11
SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs
Normal file
11
SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderCommentService
|
||||
{
|
||||
Task<IReadOnlyList<WorkOrderCommentDto>?> GetCommentsAsync(int workOrderId);
|
||||
Task<WorkOrderCommentDto> AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null);
|
||||
}
|
||||
}
|
||||
19
SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs
Normal file
19
SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderCompletionService
|
||||
{
|
||||
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
|
||||
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
|
||||
Task<WorkOrderCompletionDto> UploadCompletionDocAsync(
|
||||
int workOrderId,
|
||||
WorkOrderCompletionDocUploadDto request,
|
||||
string fileUrl,
|
||||
string? actorId);
|
||||
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
|
||||
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
|
||||
Task DeleteTemplateAsync(int id);
|
||||
}
|
||||
}
|
||||
10
SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs
Normal file
10
SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderDetailService
|
||||
{
|
||||
Task<WorkOrderDetailDto?> GetDetailAsync(int workOrderId);
|
||||
Task<IReadOnlyList<WorkOrderAuditEntryDto>?> GetAuditAsync(int workOrderId, int limit = 50);
|
||||
}
|
||||
}
|
||||
12
SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs
Normal file
12
SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderMediaService
|
||||
{
|
||||
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId);
|
||||
Task<WorkOrderMediaFileDto> AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId);
|
||||
Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId);
|
||||
}
|
||||
}
|
||||
16
SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs
Normal file
16
SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using SeaHaven.Services.DTOs;
|
||||
|
||||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
public interface IWorkOrderIngestService
|
||||
{
|
||||
Task<WorkOrderIngestBatchResultDto> UpsertBatchAsync(
|
||||
IReadOnlyList<WorkOrderIngestPayloadDto> items,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IWorkOrderOpsHealthService
|
||||
{
|
||||
Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,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()
|
||||
{
|
||||
|
|
@ -182,6 +209,303 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_NormalizesWoNumber()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000009999",
|
||||
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.WoNumber,
|
||||
Value = "12345",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("00000012345", result.WoNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_RejectsDuplicateWoNumber()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
|
||||
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.WoNumber,
|
||||
Value = "22222",
|
||||
WorkOrderVersion = ToVersion(wo!)
|
||||
}, "actor-1"));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
|
||||
{
|
||||
|
|
@ -232,51 +556,6 @@ public class WorkOrderBoardUpdateServiceTests
|
|||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_NormalizesWoNumber()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000009999",
|
||||
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.WoNumber,
|
||||
Value = "12345",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal("00000012345", result.WoNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_RejectsDuplicateWoNumber()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.AddRange(
|
||||
new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } },
|
||||
new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var wo = await context.workOrders.FindAsync(1);
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
|
||||
{
|
||||
Field = WorkOrderBoardFieldNames.WoNumber,
|
||||
Value = "22222",
|
||||
WorkOrderVersion = ToVersion(wo!)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("DuplicateWoNumber", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo()
|
||||
{
|
||||
|
|
|
|||
406
SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
Normal file
406
SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderDocStatusPatchTests
|
||||
{
|
||||
private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var service = new WorkOrderBoardUpdateService(context, boardData, audit);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
private static string ToVersion(WorkOrder workOrder)
|
||||
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||
|
||||
[Theory]
|
||||
[InlineData("Yes", DocStatus.Yes)]
|
||||
[InlineData("No", DocStatus.No)]
|
||||
[InlineData("NotNeeded", DocStatus.NotNeeded)]
|
||||
public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected)
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
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.DocStatus,
|
||||
Value = value,
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1");
|
||||
|
||||
Assert.Equal(expected, result.DocStatus);
|
||||
var audit = await context.WorkOrderAuditLogs.SingleAsync();
|
||||
Assert.Equal("DocStatus", audit.FieldName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Canceled,
|
||||
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.DocStatus,
|
||||
Value = "Yes",
|
||||
WorkOrderVersion = ToVersion(wo)
|
||||
}, "actor-1"));
|
||||
|
||||
Assert.Equal("CanceledReadOnly", ex.Code);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderDetailServiceTests
|
||||
{
|
||||
private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var templateData = new CompletionDocTemplateDataService(context);
|
||||
var userData = new UserDataService(context);
|
||||
var service = new WorkOrderDetailService(boardService, detailData, templateData, userData);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDetail_ReturnsDerivedFieldsAndFeAuditMapping()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
InternalWONumber = "00000000001",
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
ScheduledDate = DateTime.UtcNow.AddDays(-2),
|
||||
Trade = "HVAC PM",
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
DocStatus = DocStatus.No,
|
||||
Description = "Leak",
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
context.CompletionDocTemplates.Add(new CompletionDocTemplate
|
||||
{
|
||||
Name = "HVAC PM Completion",
|
||||
ServiceKey = "HVAC PM",
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
TemplateUrl = "https://example.com/hvac.pdf",
|
||||
IsActive = true
|
||||
});
|
||||
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = 1,
|
||||
EventType = "System",
|
||||
Action = "WeekRolled",
|
||||
FieldName = "CarriedOver",
|
||||
OldValue = "0",
|
||||
NewValue = "1",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
context.Comments.Add(new Comments
|
||||
{
|
||||
WorkerOrderId = 1,
|
||||
UserId = "user-1",
|
||||
Commenttext = "Called vendor",
|
||||
CreatedDate = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var detail = await service.GetDetailAsync(1);
|
||||
|
||||
Assert.NotNull(detail);
|
||||
Assert.True(detail!.Info.IsPastDue);
|
||||
Assert.Equal("HVAC PM", detail.Info.Trade);
|
||||
Assert.Equal("Leak", detail.Info.Description);
|
||||
Assert.NotNull(detail.Completion.Template);
|
||||
Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name);
|
||||
Assert.Single(detail.Comments);
|
||||
Assert.Equal("user-1", detail.Comments[0].AuthorId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderCompletionServiceTests
|
||||
{
|
||||
private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
var templateData = new CompletionDocTemplateDataService(context);
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var service = new WorkOrderCompletionService(context, templateData, detailData, audit);
|
||||
return (context, service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDoc_SetsDocStatusYesAndSignOff()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
Trade = "HVAC PM",
|
||||
DocStatus = DocStatus.No,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var result = await service.UploadCompletionDocAsync(
|
||||
1,
|
||||
new WorkOrderCompletionDocUploadDto
|
||||
{
|
||||
SignOffName = "Jane Dispatcher",
|
||||
WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!)
|
||||
},
|
||||
"https://example.com/signed.pdf",
|
||||
"actor-1");
|
||||
|
||||
Assert.Equal(DocStatus.Yes, result.DocStatus);
|
||||
Assert.Equal("https://example.com/signed.pdf", result.SignOffAttachment);
|
||||
Assert.Equal("Jane Dispatcher", result.SignOffName);
|
||||
|
||||
var updated = await context.workOrders.FindAsync(1);
|
||||
Assert.Equal(DocStatus.Yes, updated!.DocStatus);
|
||||
Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDoc_SecondUpload_DoesNotDuplicateFieldLocks()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
var wo = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
DocStatus = DocStatus.No,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
context.workOrders.Add(wo);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var version = Convert.ToBase64String(wo.RowVersion!);
|
||||
await service.UploadCompletionDocAsync(
|
||||
1,
|
||||
new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version },
|
||||
"https://example.com/first.pdf",
|
||||
"actor-1");
|
||||
|
||||
// Simulate a new request scope: clear local tracker then reload
|
||||
context.ChangeTracker.Clear();
|
||||
var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1);
|
||||
// InMemory does not bump rowversion; keep same bytes for the second call
|
||||
reloaded.RowVersion ??= new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
|
||||
|
||||
await service.UploadCompletionDocAsync(
|
||||
1,
|
||||
new WorkOrderCompletionDocUploadDto
|
||||
{
|
||||
WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!)
|
||||
},
|
||||
"https://example.com/second.pdf",
|
||||
"actor-1");
|
||||
|
||||
var locks = await context.WorkOrderFieldLocks.ToListAsync();
|
||||
Assert.Equal(1, locks.Count(l => l.FieldName == "SignOffAttachment"));
|
||||
Assert.Equal(1, locks.Count(l => l.FieldName == "DocStatus"));
|
||||
Assert.Equal("https://example.com/second.pdf", reloaded.SignOffAttachment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDoc_MissingVersion_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UploadCompletionDocAsync(
|
||||
1,
|
||||
new WorkOrderCompletionDocUploadDto(),
|
||||
"https://example.com/signed.pdf",
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("WorkOrderVersionRequired", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadCompletionDoc_ReadOnly_Throws()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Completed
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UploadCompletionDocAsync(
|
||||
1,
|
||||
new WorkOrderCompletionDocUploadDto
|
||||
{
|
||||
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
|
||||
},
|
||||
"https://example.com/signed.pdf",
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("ReadOnly", ex.Code);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderCommentServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AddComment_ReturnsFeSchema()
|
||||
{
|
||||
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 });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var commentData = new CommentDataService(context);
|
||||
var service = new WorkOrderCommentService(detailData, commentData);
|
||||
|
||||
var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc");
|
||||
|
||||
Assert.Equal("user-abc", result.AuthorId);
|
||||
Assert.Equal("Note", result.Text);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Time));
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkOrderMediaServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
BeforPhotoAttachment = "https://example.com/before.jpg",
|
||||
AfterPhotoAttachment = "https://example.com/after.jpg"
|
||||
});
|
||||
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||
{
|
||||
WorkorderId = 1,
|
||||
Attachments = "https://example.com/extra.pdf",
|
||||
Category = WorkOrderMediaCategory.Extra
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var detailData = new WorkOrderDetailDataService(context);
|
||||
var service = new WorkOrderMediaService(context, detailData);
|
||||
var media = await service.GetMediaAsync(1);
|
||||
|
||||
Assert.NotNull(media);
|
||||
Assert.Equal(3, media!.Count);
|
||||
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Before);
|
||||
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.After);
|
||||
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var context = new ApplicationDbContext(options);
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Completed
|
||||
});
|
||||
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||
{
|
||||
Id = 10,
|
||||
WorkorderId = 1,
|
||||
Attachments = "https://example.com/extra.pdf",
|
||||
Category = WorkOrderMediaCategory.Extra
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context));
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.DeleteMediaAsync(1, 10, "actor-1"));
|
||||
|
||||
Assert.Equal("ReadOnly", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint()
|
||||
{
|
||||
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
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context));
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1"));
|
||||
|
||||
Assert.Equal("UseCompletionDocEndpoint", ex.Code);
|
||||
}
|
||||
}
|
||||
241
SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs
Normal file
241
SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class WorkOrderPhase7CoexistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void IngestFieldMapper_MapsDynamoStatus()
|
||||
{
|
||||
Assert.Equal("Open", WorkOrderIngestFieldMapper.MapStatus("new"));
|
||||
Assert.Equal("In Progress", WorkOrderIngestFieldMapper.MapStatus("in_progress"));
|
||||
Assert.Equal("Done", WorkOrderIngestFieldMapper.MapStatus("completed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestService_CreatesThenUpdates_IdempotentByExternalId()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
|
||||
|
||||
var payload = new WorkOrderIngestPayloadDto
|
||||
{
|
||||
ExternalWorkOrderId = "ext-100",
|
||||
Description = "Initial",
|
||||
WoStatus = "new",
|
||||
Severity = "2",
|
||||
SiteCode = "BK1"
|
||||
};
|
||||
|
||||
var create = await ingest.UpsertBatchAsync(new[] { payload });
|
||||
Assert.Equal(1, create.Created);
|
||||
Assert.Equal(0, create.Updated);
|
||||
|
||||
payload.Description = "Updated via ingest";
|
||||
var update = await ingest.UpsertBatchAsync(new[] { payload });
|
||||
Assert.Equal(0, update.Created);
|
||||
Assert.Equal(1, update.Updated);
|
||||
|
||||
var wo = await context.workOrders.SingleAsync();
|
||||
Assert.Equal("Updated via ingest", wo.Description);
|
||||
Assert.Equal("ext-100", wo.ExternalWorkOrderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestRespectsFieldLock_AfterShocBoardCreate()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var createService = new WorkOrderBoardCreateService(
|
||||
context, boardData, boardService, audit, new WorkOrderBoardCreateValidation());
|
||||
var policy = new SyncFieldMergePolicy(fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(context, policy, fieldLocks, audit);
|
||||
|
||||
await createService.CreateAsync(new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5"
|
||||
}, "dispatcher-1");
|
||||
|
||||
var wo = await context.workOrders.SingleAsync();
|
||||
wo.ExternalWorkOrderId = "ext-shoc-1";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await ingest.UpsertBatchAsync(new[]
|
||||
{
|
||||
new WorkOrderIngestPayloadDto
|
||||
{
|
||||
ExternalWorkOrderId = "ext-shoc-1",
|
||||
SiteCode = "SYNC-SITE"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal("BK5", wo.SiteCode);
|
||||
Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => l.Action == "SyncRejected"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestRespectsLocationIdFieldLock()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
context.Locations.Add(new Locations { Id = 1, Name = "BK1", Status = "Active" });
|
||||
context.Locations.Add(new Locations { Id = 2, Name = "BK2", Status = "Active" });
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 10,
|
||||
ExternalWorkOrderId = "ext-loc-1",
|
||||
LocationId = 1,
|
||||
SiteCode = "BK1",
|
||||
istemplate = false
|
||||
});
|
||||
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||
{
|
||||
WorkOrderId = 10,
|
||||
FieldName = "LocationId",
|
||||
LockedAt = DateTime.UtcNow,
|
||||
LockedByUserId = "dispatcher-1"
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
|
||||
|
||||
await ingest.UpsertBatchAsync(new[]
|
||||
{
|
||||
new WorkOrderIngestPayloadDto
|
||||
{
|
||||
ExternalWorkOrderId = "ext-loc-1",
|
||||
SiteCode = "BK2",
|
||||
Building = "BK2"
|
||||
}
|
||||
});
|
||||
|
||||
var wo = await context.workOrders.SingleAsync(w => w.Id == 10);
|
||||
Assert.Equal(1, wo.LocationId);
|
||||
Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l =>
|
||||
l.Action == "SyncRejected" && l.FieldName == "LocationId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatch_SkipsItemsWithoutExternalId()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
var fieldLocks = new WorkOrderFieldLockService(context);
|
||||
var audit = new WorkOrderAuditService(context, fieldLocks);
|
||||
var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit);
|
||||
|
||||
var result = await ingest.UpsertBatchAsync(new[]
|
||||
{
|
||||
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" },
|
||||
new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" }
|
||||
});
|
||||
|
||||
Assert.Equal(1, result.Created);
|
||||
Assert.Equal(1, result.Skipped);
|
||||
Assert.Equal(1, result.Results.Count);
|
||||
Assert.Equal("ext-ok", result.Results[0].ExternalWorkOrderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpsHealth_ReturnsCoexistenceMetrics()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||
{
|
||||
WorkOrderId = 1,
|
||||
FieldName = "SiteCode",
|
||||
LockedAt = DateTime.UtcNow,
|
||||
LockedByUserId = "u1"
|
||||
});
|
||||
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
||||
{
|
||||
WorkOrderId = 1,
|
||||
Action = "SyncRejected",
|
||||
FieldName = "Description",
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var runState = new WorkOrderJobRunStateAccessor
|
||||
{
|
||||
State = { LastWeekRolledRunUtc = DateTime.UtcNow.AddDays(-1) }
|
||||
};
|
||||
|
||||
var healthOptions = Options.Create(new WorkOrderOpsHealthOptions
|
||||
{
|
||||
SyncEnabled = true,
|
||||
IngestEnabled = true,
|
||||
LegacyDeprecationEnabled = true,
|
||||
LegacySunsetDate = "2026-12-31"
|
||||
});
|
||||
|
||||
var service = new WorkOrderOpsHealthService(context, runState, healthOptions);
|
||||
var health = await service.GetHealthAsync();
|
||||
|
||||
Assert.Equal(1, health.FieldLockCount);
|
||||
Assert.Equal(1, health.SyncRejectedLast24h);
|
||||
Assert.True(health.SyncEnabled);
|
||||
Assert.True(health.IngestEnabled);
|
||||
Assert.Equal("2026-12-31", health.LegacySunsetDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardAndLegacyEndpoints_CanCoexist_InSameDatabase()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
WorkerOrderTitle = "Legacy row",
|
||||
Status = "Open",
|
||||
InternalWONumber = "10000001",
|
||||
istemplate = false
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData);
|
||||
var count = await context.workOrders.CountAsync();
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.NotNull(boardService);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,14 +22,22 @@ namespace SeaHavenIndustries.Data.Services
|
|||
private UserManager<ApplicationUser> _userManager;
|
||||
private UploadFileHp _uploadFileHelper;
|
||||
private readonly SendMessage _sendemail;
|
||||
private readonly BlazorWorkOrderSunsetGuard _sunsetGuard;
|
||||
|
||||
public WorkorderService(SendMessage sendemail, ApplicationDbContext context, IHttpContextAccessor httpContextAccessor, UserManager<ApplicationUser> userManager, UploadFileHp uploadFileHelper)
|
||||
public WorkorderService(
|
||||
SendMessage sendemail,
|
||||
ApplicationDbContext context,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
UploadFileHp uploadFileHelper,
|
||||
BlazorWorkOrderSunsetGuard sunsetGuard)
|
||||
{
|
||||
_context = context;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_userManager = userManager;
|
||||
_uploadFileHelper = uploadFileHelper;
|
||||
_sendemail = sendemail;
|
||||
_sunsetGuard = sunsetGuard;
|
||||
}
|
||||
public async Task<List<WorkOrder>> Getworkorder()
|
||||
{
|
||||
|
|
@ -178,6 +186,7 @@ namespace SeaHavenIndustries.Data.Services
|
|||
}
|
||||
public async Task<string> Addworkorder(WorkOrder model, List<FileUploadViewModel> files)
|
||||
{
|
||||
_sunsetGuard.ThrowIfMutationsBlocked();
|
||||
try
|
||||
{
|
||||
_context.ChangeTracker.Clear();
|
||||
|
|
@ -425,6 +434,7 @@ namespace SeaHavenIndustries.Data.Services
|
|||
}
|
||||
public async Task<string> Deleteworkorder(int id)
|
||||
{
|
||||
_sunsetGuard.ThrowIfMutationsBlocked();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
try
|
||||
|
|
|
|||
26
SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs
Normal file
26
SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using SeaHavenIndustries.Options;
|
||||
|
||||
namespace SeaHavenIndustries.Helper
|
||||
{
|
||||
public class BlazorWorkOrderSunsetGuard
|
||||
{
|
||||
private readonly BlazorWorkOrderSunsetOptions _options;
|
||||
|
||||
public BlazorWorkOrderSunsetGuard(IOptions<BlazorWorkOrderSunsetOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public bool IsSunsetEnabled => _options.Enabled;
|
||||
|
||||
public string ShocBaseUrl => _options.ShocBaseUrl.TrimEnd('/');
|
||||
|
||||
public void ThrowIfMutationsBlocked()
|
||||
{
|
||||
if (_options.Enabled)
|
||||
throw new InvalidOperationException(
|
||||
"Work Orders module has moved to SHOC. Editing via Blazor is disabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs
Normal file
11
SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace SeaHavenIndustries.Options
|
||||
{
|
||||
public class BlazorWorkOrderSunsetOptions
|
||||
{
|
||||
public const string SectionName = "BlazorWorkOrderSunset";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string ShocBaseUrl { get; set; } = "https://shoc.seahaven.com";
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,9 @@ builder.Services.AddScoped<UploadFileHp>();
|
|||
builder.Services.AddScoped<NorthwindService>();
|
||||
builder.Services.AddScoped<SendMessage>();
|
||||
builder.Services.AddScoped<PdfService>();
|
||||
builder.Services.Configure<SeaHavenIndustries.Options.BlazorWorkOrderSunsetOptions>(
|
||||
builder.Configuration.GetSection(SeaHavenIndustries.Options.BlazorWorkOrderSunsetOptions.SectionName));
|
||||
builder.Services.AddSingleton<BlazorWorkOrderSunsetGuard>();
|
||||
|
||||
builder.Services.AddSession(options =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
{
|
||||
"ConnectionStrings": {
|
||||
//"DefaultConnection": "Server=.;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
|
||||
//"DefaultConnection": "Server=.\\SqlExpress;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true",
|
||||
|
||||
// Provide the real value via environment variable ConnectionStrings__DefaultConnection,
|
||||
// user-secrets, or appsettings.Development.json (which is gitignored). Do NOT commit credentials.
|
||||
"DefaultConnection": "${CONNECTION_STRING}",
|
||||
"excelconnection": "Provider=Microsoft.ACE.OLEDB.12.0;Data Source={0};Extended Properties='Excel 8.0;HDR=YES'"
|
||||
},
|
||||
|
|
@ -15,12 +10,14 @@
|
|||
}
|
||||
},
|
||||
"SendGrid": {
|
||||
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
|
||||
"ApiKey": "${SENDGRID_API_KEY}"
|
||||
},
|
||||
"GoogleMaps": {
|
||||
// Provide the real value via environment variable GoogleMaps__ApiKey or user-secrets.
|
||||
"ApiKey": "${GOOGLE_MAPS_API_KEY}"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"BlazorWorkOrderSunset": {
|
||||
"Enabled": false,
|
||||
"ShocBaseUrl": "https://shoc.seahaven.com"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue