Add multi-WO dispatch support with junction table

- DispatchWorkOrder junction table for 1:N dispatch-to-WO relationship
- Make Dispatch.WorkOrderId nullable (backward compat)
- Add AcceptToken and AcknowledgedAt to Dispatch model
- Dispatch_DTO accepts WorkOrderIds array
- DispatchToVendor creates junction rows, email lists all WOs in table
- GetDispatches queries both junction table and direct FK
- GetDispatchById includes workOrders list from junction table
- Migration with DispatchWorkOrder table
This commit is contained in:
Adam Moussa 2026-04-17 15:16:28 -04:00
parent 1f4bd12cd7
commit be190836a4
8 changed files with 2895 additions and 40 deletions

View file

@ -1031,10 +1031,13 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
try try
{ {
var workOrder = await _db.workOrders.Include(w => w.Locations) var workOrders = await _db.workOrders.Include(w => w.Locations)
.FirstOrDefaultAsync(w => w.Id == model.WorkOrderId); .Where(w => model.WorkOrderIds.Contains(w.Id))
if (workOrder == null) .ToListAsync();
return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); if (workOrders.Count == 0)
return BadRequest(new Response { Status = "Error", Message = "No work orders found" });
var primaryWO = workOrders.First();
var lastPO = await _db.Dispatches var lastPO = await _db.Dispatches
.Where(d => d.PONumber != null) .Where(d => d.PONumber != null)
@ -1069,11 +1072,11 @@ namespace Api.SeaHavenIndustries.Controllers
var dispatchNumber = $"DSP-{nextDSP:D5}"; var dispatchNumber = $"DSP-{nextDSP:D5}";
nextDSP++; nextDSP++;
var replyTo = $"wo-{workOrder.InternalWONumber}-{dispatchNumber}@int.seahaven.com"; var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com";
var dispatch = new Dispatch var dispatch = new Dispatch
{ {
WorkOrderId = model.WorkOrderId, WorkOrderId = primaryWO.Id,
VendorId = vendorId, VendorId = vendorId,
DispatchNumber = dispatchNumber, DispatchNumber = dispatchNumber,
PONumber = poNumber, PONumber = poNumber,
@ -1082,50 +1085,60 @@ namespace Api.SeaHavenIndustries.Controllers
Status = "Sent", Status = "Sent",
ScheduledDate = model.ScheduledDate, ScheduledDate = model.ScheduledDate,
DispatchedAt = DateTime.UtcNow, DispatchedAt = DateTime.UtcNow,
ReplyToAddress = replyTo ReplyToAddress = replyTo,
AcceptToken = Guid.NewGuid().ToString(),
DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList()
}; };
var subject = $"[WO-{workOrder.InternalWONumber}-{dispatchNumber}] {workOrder.WorkerOrderTitle}"; var woListHtml = string.Join("", workOrders.Select(wo =>
var locationName = workOrder.Locations?.Name ?? ""; $"<tr><td style='padding:6px 8px;border:1px solid #ddd;'>{wo.InternalWONumber}</td>" +
var locationAddr = workOrder.Locations?.Address ?? ""; $"<td style='padding:6px 8px;border:1px solid #ddd;'>{wo.WorkerOrderTitle}</td>" +
$"<td style='padding:6px 8px;border:1px solid #ddd;'>{wo.Priority}</td>" +
$"<td style='padding:6px 8px;border:1px solid #ddd;'>{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}</td></tr>"));
var locationName = primaryWO.Locations?.Name ?? "";
var locationAddr = primaryWO.Locations?.Address ?? "";
var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {locationName}";
var html = $@" var html = $@"
<h2>Work Order Dispatch</h2> <h2>Work Order Dispatch — {dispatchNumber}</h2>
<p><strong>Location:</strong> {locationName} {locationAddr}</p>
<p><strong>Vendor PO:</strong> {poNumber} &nbsp; <strong>NTE:</strong> ${model.NTEAmount?.ToString("F2") ?? "N/A"}</p>
<h3>Work Orders</h3>
<table style='border-collapse:collapse;width:100%;font-family:Arial,sans-serif;'> <table style='border-collapse:collapse;width:100%;font-family:Arial,sans-serif;'>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>WO Number</td><td style='padding:8px;border:1px solid #ddd;'>{workOrder.InternalWONumber}</td></tr> <tr style='background:#f5f5f5;'>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Title</td><td style='padding:8px;border:1px solid #ddd;'>{workOrder.WorkerOrderTitle}</td></tr> <th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>WO #</th>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Location</td><td style='padding:8px;border:1px solid #ddd;'>{locationName} {locationAddr}</td></tr> <th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>Title</th>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Priority</td><td style='padding:8px;border:1px solid #ddd;'>{workOrder.Priority}</td></tr> <th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>Priority</th>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Due Date</td><td style='padding:8px;border:1px solid #ddd;'>{workOrder.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}</td></tr> <th style='padding:6px 8px;border:1px solid #ddd;text-align:left;'>Due Date</th>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Trade</td><td style='padding:8px;border:1px solid #ddd;'>{workOrder.Trade}</td></tr> </tr>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Sub-Trade</td><td style='padding:8px;border:1px solid #ddd;'>{workOrder.SubTrade}</td></tr> {woListHtml}
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Problem</td><td style='padding:8px;border:1px solid #ddd;'>{workOrder.Problem}</td></tr>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>NTE</td><td style='padding:8px;border:1px solid #ddd;'>${model.NTEAmount?.ToString("F2") ?? "N/A"}</td></tr>
<tr><td style='padding:8px;font-weight:bold;border:1px solid #ddd;'>Vendor PO</td><td style='padding:8px;border:1px solid #ddd;'>{poNumber}</td></tr>
</table> </table>
<h3>Description</h3> <h3>Description</h3>
<p>{model.Description ?? workOrder.Description}</p> <p>{model.Description ?? primaryWO.Description}</p>
<hr/>
<br/><p style='color:#666;font-size:13px;'>{senderName}<br/>Sea Haven Industries</p> <br/><p style='color:#666;font-size:13px;'>{senderName}<br/>Sea Haven Industries</p>
<hr/><p style='color:#999;font-size:12px;'>Reply to this email to communicate about this work order.</p>"; <hr/><p style='color:#999;font-size:12px;'>Reply to this email to communicate about this dispatch.</p>";
var emailSent = await _sendMessage.SendDispatchEmail(vendor.Email, subject, html, replyTo); var emailSent = await _sendMessage.SendDispatchEmail(vendor.Email, subject, html, replyTo);
dispatch.EmailSent = emailSent; dispatch.EmailSent = emailSent;
_db.Dispatches.Add(dispatch); _db.Dispatches.Add(dispatch);
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog foreach (var wo in workOrders)
{ {
WorkOrderId = model.WorkOrderId, _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
UserId = userId, {
FieldName = "Dispatch", WorkOrderId = wo.Id,
OldValue = "", UserId = userId,
NewValue = $"{vendor.CompanyName} ({poNumber})", FieldName = "Dispatch",
Action = "dispatch", OldValue = "",
CreatedAt = DateTime.UtcNow NewValue = $"{vendor.CompanyName} ({dispatchNumber})",
}); Action = "dispatch",
CreatedAt = DateTime.UtcNow
});
}
dispatches.Add(new { dispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, dispatch.Status }); dispatches.Add(new { dispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, dispatch.Status, workOrderCount = workOrders.Count });
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@ -1140,8 +1153,21 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("GetDispatches/{workOrderId}")] [HttpGet("GetDispatches/{workOrderId}")]
public async Task<IActionResult> GetDispatches(int workOrderId) public async Task<IActionResult> GetDispatches(int workOrderId)
{ {
var data = await _db.Dispatches var dispatchIds = await _db.DispatchWorkOrders
.Where(dwo => dwo.WorkOrderId == workOrderId)
.Select(dwo => dwo.DispatchId)
.Distinct()
.ToListAsync();
var directIds = await _db.Dispatches
.Where(d => d.WorkOrderId == workOrderId) .Where(d => d.WorkOrderId == workOrderId)
.Select(d => d.Id)
.ToListAsync();
var allIds = dispatchIds.Union(directIds).Distinct().ToList();
var data = await _db.Dispatches
.Where(d => allIds.Contains(d.Id))
.Include(d => d.Vendor) .Include(d => d.Vendor)
.OrderByDescending(d => d.DispatchedAt) .OrderByDescending(d => d.DispatchedAt)
.Select(d => new .Select(d => new
@ -1184,11 +1210,19 @@ namespace Api.SeaHavenIndustries.Controllers
x.DispatchedAt, x.DispatchedAt,
x.EmailSent, x.EmailSent,
x.WorkOrderId, x.WorkOrderId,
x.AcknowledgedAt,
woNumber = x.WorkOrder != null ? x.WorkOrder.InternalWONumber : "", woNumber = x.WorkOrder != null ? x.WorkOrder.InternalWONumber : "",
woTitle = x.WorkOrder != null ? x.WorkOrder.WorkerOrderTitle : "", woTitle = x.WorkOrder != null ? x.WorkOrder.WorkerOrderTitle : "",
vendorName = x.Vendor != null ? x.Vendor.CompanyName : "", vendorName = x.Vendor != null ? x.Vendor.CompanyName : "",
vendorEmail = x.Vendor != null ? x.Vendor.Email : "", vendorEmail = x.Vendor != null ? x.Vendor.Email : "",
vendorId = x.VendorId, vendorId = x.VendorId,
workOrders = _db.DispatchWorkOrders
.Where(dwo => dwo.DispatchId == x.Id)
.Select(dwo => new {
dwo.WorkOrderId,
woNumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : "",
woTitle = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : ""
}).ToList(),
comments = _db.Comments comments = _db.Comments
.Where(c => c.DispatchId == x.Id) .Where(c => c.DispatchId == x.Id)
.OrderBy(c => c.CreatedDate) .OrderBy(c => c.CreatedDate)
@ -1223,7 +1257,7 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{ {
WorkOrderId = dispatch.WorkOrderId, WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = userId, UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status", FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = dispatch.Status ?? "", OldValue = dispatch.Status ?? "",

View file

@ -2,7 +2,7 @@ namespace Api.SeaHavenIndustries.DTOs
{ {
public class Dispatch_DTO public class Dispatch_DTO
{ {
public int WorkOrderId { get; set; } public List<int> WorkOrderIds { get; set; } = new();
public List<int> VendorIds { get; set; } = new(); public List<int> VendorIds { get; set; } = new();
public decimal? NTEAmount { get; set; } public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }

View file

@ -41,6 +41,7 @@ namespace Data.SeaHavenIndustries
public DbSet<DropdownOption> DropdownOptions { get; set; } public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; } public DbSet<Vendor> Vendors { get; set; }
public DbSet<Dispatch> Dispatches { get; set; } public DbSet<Dispatch> Dispatches { get; set; }
public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; }
public DbSet<TaskListTemplate> TaskListTemplates { get; set; } public DbSet<TaskListTemplate> TaskListTemplates { get; set; }
public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; } public DbSet<TaskListTemplateItem> TaskListTemplateItems { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class MultiWODispatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "WorkOrderId",
table: "Dispatches",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.AddColumn<string>(
name: "AcceptToken",
table: "Dispatches",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "AcknowledgedAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.CreateTable(
name: "DispatchWorkOrders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DispatchId = table.Column<int>(type: "int", nullable: false),
WorkOrderId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DispatchWorkOrders", x => x.Id);
table.ForeignKey(
name: "FK_DispatchWorkOrders_Dispatches_DispatchId",
column: x => x.DispatchId,
principalTable: "Dispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DispatchWorkOrders_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_DispatchWorkOrders_DispatchId",
table: "DispatchWorkOrders",
column: "DispatchId");
migrationBuilder.CreateIndex(
name: "IX_DispatchWorkOrders_WorkOrderId",
table: "DispatchWorkOrders",
column: "WorkOrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DispatchWorkOrders");
migrationBuilder.DropColumn(
name: "AcceptToken",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "AcknowledgedAt",
table: "Dispatches");
migrationBuilder.AlterColumn<int>(
name: "WorkOrderId",
table: "Dispatches",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
}
}
}

View file

@ -640,6 +640,12 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AcceptToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate") b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@ -691,7 +697,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int>("VendorId") b.Property<int>("VendorId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int>("WorkOrderId") b.Property<int?>("WorkOrderId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("createdby") b.Property<string>("createdby")
@ -706,6 +712,29 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Dispatches"); b.ToTable("Dispatches");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DispatchId")
.HasColumnType("int");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("DispatchId");
b.HasIndex("WorkOrderId");
b.ToTable("DispatchWorkOrders");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -2181,10 +2210,28 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany("Dispatches") .WithMany("Dispatches")
.HasForeignKey("WorkOrderId") .HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Vendor");
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b =>
{
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
.WithMany("DispatchWorkOrders")
.HasForeignKey("DispatchId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Vendor"); b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Dispatch");
b.Navigation("WorkOrder"); b.Navigation("WorkOrder");
}); });
@ -2542,6 +2589,8 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b =>
{ {
b.Navigation("Comments"); b.Navigation("Comments");
b.Navigation("DispatchWorkOrders");
}); });
modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b =>

View file

@ -4,7 +4,7 @@ namespace Data.SeaHavenIndustries
{ {
public class Dispatch : FullAuditEntity public class Dispatch : FullAuditEntity
{ {
public int WorkOrderId { get; set; } public int? WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))] [ForeignKey(nameof(WorkOrderId))]
public virtual WorkOrder? WorkOrder { get; set; } public virtual WorkOrder? WorkOrder { get; set; }
@ -20,8 +20,11 @@ namespace Data.SeaHavenIndustries
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? CompletedDate { get; set; } public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; } public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
public bool EmailSent { get; set; } public bool EmailSent { get; set; }
public string? ReplyToAddress { get; set; } public string? ReplyToAddress { get; set; }
public string? AcceptToken { get; set; }
public ICollection<Comments>? Comments { get; set; } public ICollection<Comments>? Comments { get; set; }
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
} }
} }

View file

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class DispatchWorkOrder
{
public int Id { get; set; }
public int DispatchId { get; set; }
[ForeignKey(nameof(DispatchId))]
public virtual Dispatch? Dispatch { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public virtual WorkOrder? WorkOrder { get; set; }
}
}