Merge pull request #10 from Sea-Haven-Industries/feat/work-orders-domain-foundation

feat(work-orders): Phase 0 domain foundation with FE alignment
This commit is contained in:
Alexandre Brandizzi 2026-07-21 13:58:18 -03:00 • committed by GitHub
commit 32b9113057
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 5363 additions and 221 deletions

View file

@ -21,7 +21,7 @@ using SeaHaven.DataServices.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
// [Authorize]
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]

View file

@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Filters
{
public class ConcurrencyExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is not DbUpdateConcurrencyException) return;
context.Result = new ConflictObjectResult(new
{
status = "Conflict",
message = "The record was modified by another user. Refresh and retry.",
code = 409
});
context.ExceptionHandled = true;
}
}
}

View file

@ -22,7 +22,10 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddControllers().AddJsonOptions(options =>
builder.Services.AddControllers(options =>
{
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
});

View file

@ -33,6 +33,24 @@ namespace Data.SeaHavenIndustries
.HasIndex(u => new { u.DispatchId, u.Status })
.IsUnique()
.HasFilter("[Status] = 'Pending'");
builder.Entity<WorkOrderFieldLock>()
.HasIndex(l => new { l.WorkOrderId, l.FieldName })
.IsUnique();
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
.IsRowVersion();
builder.Entity<Dispatch>()
.Property(d => d.RowVersion)
.IsRowVersion();
builder.Entity<WorkOrder>()
.HasOne(w => w.PrimaryDispatch)
.WithMany()
.HasForeignKey(w => w.PrimaryDispatchId)
.OnDelete(DeleteBehavior.Restrict);
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -47,6 +65,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
@ -88,6 +107,8 @@ namespace Data.SeaHavenIndustries
public DateTime? DeletedDate { get; set; }
public bool? IsDeleted { get; set; }
public string? UserImage { get; set; }
public string? Initials { get; set; }
public string? Color { get; set; }
public int? Type { get; set; } // 1 for users 0 for admin
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; }

View file

@ -1,92 +1,171 @@
namespace Data.SeaHavenIndustries.Enums
{
public enum WorkOrderStatus
{
Open = 1,
InProgress = 2,
Completed = 3,
Cancelled = 4,
OnHold = 5
}
public enum WorkOrderPriority
{
Low = 1,
Medium = 2,
High = 3,
Critical = 4
}
public enum AuditAction
{
Create = 1,
Edit = 2,
Delete = 3,
StatusChange = 4,
AssignmentChange = 5,
Dispatch = 6,
DispatchUpdate = 7,
DispatchVerify = 8
}
public enum ContactType
{
Primary = 1,
Secondary = 2,
Billing = 3,
Technical = 4
}
public enum AssetStatus
{
Active = 1,
Inactive = 2,
UnderMaintenance = 3,
Retired = 4
}
public enum DispatchStatus
{
Pending = 1,
Assigned = 2,
InProgress = 3,
Completed = 4,
Cancelled = 5
}
public enum FollowUpStatus
{
Open = 1,
InProgress = 2,
Resolved = 3,
Closed = 4
}
public enum PMScheduleFrequency
{
Daily = 1,
Weekly = 2,
Monthly = 3,
Quarterly = 4,
SemiAnnual = 5,
Annual = 6
}
public enum CommentType
{
General = 1,
Internal = 2,
Customer = 3,
Vendor = 4,
System = 5
}
public enum RecordType
{
WorkOrder = 1,
Dispatch = 2,
Quote = 3,
Contact = 4,
Asset = 5
}
}
namespace Data.SeaHavenIndustries.Enums
{
/// <summary>Legacy enum — prefer LifecycleStatus for board operations.</summary>
public enum WorkOrderStatus
{
Open = 1,
InProgress = 2,
Completed = 3,
Cancelled = 4,
OnHold = 5
}
/// <summary>10 operational lifecycle values aligned with SHOC board (FE labels).</summary>
public enum LifecycleStatus
{
Incomplete = 1,
Pending = 2,
Scheduled = 3,
EnRoute = 4,
OnSite = 5,
InProgress = 6,
Completed = 7,
Rescheduled = 8,
Canceled = 9,
PendingQuote = 10
}
/// <summary>Board work-order types. Legacy values retained for backward compatibility.</summary>
public enum WorkOrderType
{
PO = 1,
PM = 2,
Emergency = 3,
Project = 4,
Inspection = 5,
Reactive = 6,
AddOn = 7,
Other = 99
}
public enum DocStatus
{
Yes = 1,
No = 2,
NotNeeded = 3
}
[Flags]
public enum OperationalFlags
{
None = 0,
PastDue = 1
}
public enum AuditEventType
{
Manual = 1,
System = 2,
Sync = 3,
Vendor = 4
}
public enum AuditActorType
{
Dispatcher = 1,
Vendor = 2,
System = 3,
Sync = 4
}
public enum AuditActionType
{
FieldChanged = 1,
StatusChanged = 2,
AssignmentChanged = 3,
WeekRolled = 4,
SyncRejected = 5,
Create = 6,
Delete = 7
}
public enum WorkOrderPriority
{
Low = 1,
Medium = 2,
High = 3,
Critical = 4
}
public enum AuditAction
{
Create = 1,
Edit = 2,
Delete = 3,
StatusChange = 4,
AssignmentChange = 5,
Dispatch = 6,
DispatchUpdate = 7,
DispatchVerify = 8
}
public enum ContactType
{
Primary = 1,
Secondary = 2,
Billing = 3,
Technical = 4
}
public enum AssetStatus
{
Active = 1,
Inactive = 2,
UnderMaintenance = 3,
Retired = 4
}
public enum DispatchStatus
{
Pending = 1,
Assigned = 2,
InProgress = 3,
Completed = 4,
Cancelled = 5
}
public enum FollowUpStatus
{
Open = 1,
InProgress = 2,
Resolved = 3,
Closed = 4
}
public enum PMScheduleFrequency
{
Daily = 1,
Weekly = 2,
Monthly = 3,
Quarterly = 4,
SemiAnnual = 5,
Annual = 6
}
public enum CommentType
{
General = 1,
Internal = 2,
Customer = 3,
Vendor = 4,
System = 5
}
public enum RecordType
{
WorkOrder = 1,
Dispatch = 2,
Quote = 3,
Contact = 4,
Asset = 5
}
public enum WorkOrderMediaCategory
{
Before = 1,
After = 2,
Extra = 3,
Completion = 4
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,744 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase0_DomainFoundation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches");
migrationBuilder.DropForeignKey(
name: "FK_Locations_Accounts_AccountId",
table: "Locations");
migrationBuilder.DropIndex(
name: "IX_Locations_AccountId",
table: "Locations");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "Regions");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "Regions");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "Regions");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "Regions");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "Regions");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "Regions");
migrationBuilder.DropColumn(
name: "createdby",
table: "Regions");
migrationBuilder.DropColumn(
name: "AccountId",
table: "Locations");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "createdby",
table: "JobTitles");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "createdby",
table: "EmployeeEmails");
migrationBuilder.DropColumn(
name: "CreatedDate",
table: "Departments");
migrationBuilder.DropColumn(
name: "DeleterUserId",
table: "Departments");
migrationBuilder.DropColumn(
name: "DeletionTime",
table: "Departments");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "Departments");
migrationBuilder.DropColumn(
name: "LastModificationTime",
table: "Departments");
migrationBuilder.DropColumn(
name: "LastModifierUserId",
table: "Departments");
migrationBuilder.DropColumn(
name: "createdby",
table: "Departments");
migrationBuilder.RenameColumn(
name: "ZipCode",
table: "Locations",
newName: "Zip");
migrationBuilder.RenameColumn(
name: "Phone",
table: "Locations",
newName: "PhoneNumber");
migrationBuilder.RenameColumn(
name: "ContactEmail",
table: "Locations",
newName: "Email");
migrationBuilder.RenameColumn(
name: "Contact",
table: "Locations",
newName: "Address2");
migrationBuilder.RenameColumn(
name: "Address",
table: "Locations",
newName: "Address1");
migrationBuilder.AddColumn<DateOnly>(
name: "AssignDate",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AvettaTask",
table: "workOrders",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CarriedOver",
table: "workOrders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "DocStatus",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LegacyStatus",
table: "workOrders",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LifecycleStatus",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OperationalFlags",
table: "workOrders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<DateOnly>(
name: "OriginalDate",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "OriginalWeek",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PrimaryDispatchId",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RescheduleCount",
table: "workOrders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "workOrders",
type: "rowversion",
rowVersion: true,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "ScheduleWeekOnly",
table: "workOrders",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ScheduledEnd",
table: "workOrders",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "TargetWeek",
table: "workOrders",
type: "date",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WorkOrderType",
table: "workOrders",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Service",
table: "workOrders",
type: "nvarchar(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "WorkOrderContacts",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ActorType",
table: "WorkOrderAuditLogs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CorrelationId",
table: "WorkOrderAuditLogs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DispatchId",
table: "WorkOrderAuditLogs",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "EventType",
table: "WorkOrderAuditLogs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "Dispatches",
type: "rowversion",
rowVersion: true,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Color",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Initials",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "WorkOrderFieldLocks",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
FieldName = table.Column<string>(type: "nvarchar(450)", nullable: false),
LockedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LockedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WorkOrderFieldLocks", x => x.Id);
table.ForeignKey(
name: "FK_WorkOrderFieldLocks_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_workOrders_PrimaryDispatchId",
table: "workOrders",
column: "PrimaryDispatchId");
migrationBuilder.CreateIndex(
name: "IX_WorkOrderFieldLocks_WorkOrderId_FieldName",
table: "WorkOrderFieldLocks",
columns: new[] { "WorkOrderId", "FieldName" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches",
column: "WorkOrderId",
principalTable: "workOrders",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_workOrders_Dispatches_PrimaryDispatchId",
table: "workOrders",
column: "PrimaryDispatchId",
principalTable: "Dispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.Sql("""
UPDATE workOrders SET LegacyStatus = Status WHERE LegacyStatus IS NULL AND Status IS NOT NULL;
""");
migrationBuilder.Sql("""
UPDATE workOrders SET LifecycleStatus = CASE
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('open', 'incomplete') THEN 1
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending', 'onhold', 'on hold') THEN 2
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('scheduled') THEN 3
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('en route', 'enroute') THEN 4
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('on site', 'onsite') THEN 5
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('inprogress', 'in progress') THEN 6
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('completed', 'complete', 'done', 'closed') THEN 7
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('rescheduled') THEN 8
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('cancelled', 'canceled') THEN 9
WHEN LOWER(LTRIM(RTRIM(Status))) IN ('pending quote', 'pendingquote', 'pendingapproval', 'pending approval') THEN 10
ELSE 1
END
WHERE LifecycleStatus IS NULL AND Status IS NOT NULL;
""");
migrationBuilder.Sql("""
UPDATE wo SET wo.PrimaryDispatchId = latest.DispatchId
FROM workOrders wo
INNER JOIN (
SELECT WorkOrderId, MAX(Id) AS DispatchId
FROM Dispatches
WHERE WorkOrderId IS NOT NULL
GROUP BY WorkOrderId
) latest ON wo.Id = latest.WorkOrderId
WHERE wo.PrimaryDispatchId IS NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches");
migrationBuilder.DropForeignKey(
name: "FK_workOrders_Dispatches_PrimaryDispatchId",
table: "workOrders");
migrationBuilder.DropTable(
name: "WorkOrderFieldLocks");
migrationBuilder.DropIndex(
name: "IX_workOrders_PrimaryDispatchId",
table: "workOrders");
migrationBuilder.DropColumn(
name: "AssignDate",
table: "workOrders");
migrationBuilder.DropColumn(
name: "AvettaTask",
table: "workOrders");
migrationBuilder.DropColumn(
name: "CarriedOver",
table: "workOrders");
migrationBuilder.DropColumn(
name: "DocStatus",
table: "workOrders");
migrationBuilder.DropColumn(
name: "LegacyStatus",
table: "workOrders");
migrationBuilder.DropColumn(
name: "LifecycleStatus",
table: "workOrders");
migrationBuilder.DropColumn(
name: "OperationalFlags",
table: "workOrders");
migrationBuilder.DropColumn(
name: "OriginalDate",
table: "workOrders");
migrationBuilder.DropColumn(
name: "OriginalWeek",
table: "workOrders");
migrationBuilder.DropColumn(
name: "PrimaryDispatchId",
table: "workOrders");
migrationBuilder.DropColumn(
name: "RescheduleCount",
table: "workOrders");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ScheduleWeekOnly",
table: "workOrders");
migrationBuilder.DropColumn(
name: "ScheduledEnd",
table: "workOrders");
migrationBuilder.DropColumn(
name: "TargetWeek",
table: "workOrders");
migrationBuilder.DropColumn(
name: "WorkOrderType",
table: "workOrders");
migrationBuilder.DropColumn(
name: "Service",
table: "workOrders");
migrationBuilder.DropColumn(
name: "Notes",
table: "WorkOrderContacts");
migrationBuilder.DropColumn(
name: "ActorType",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "CorrelationId",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "DispatchId",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "EventType",
table: "WorkOrderAuditLogs");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "Color",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "Initials",
table: "AspNetUsers");
migrationBuilder.RenameColumn(
name: "Zip",
table: "Locations",
newName: "ZipCode");
migrationBuilder.RenameColumn(
name: "PhoneNumber",
table: "Locations",
newName: "Phone");
migrationBuilder.RenameColumn(
name: "Email",
table: "Locations",
newName: "ContactEmail");
migrationBuilder.RenameColumn(
name: "Address2",
table: "Locations",
newName: "Contact");
migrationBuilder.RenameColumn(
name: "Address1",
table: "Locations",
newName: "Address");
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "Regions",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "Regions",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "Regions",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "Regions",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "Regions",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "Regions",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "Regions",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AccountId",
table: "Locations",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "JobTitles",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "JobTitles",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "JobTitles",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "JobTitles",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "JobTitles",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "JobTitles",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "JobTitles",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "EmployeeEmails",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "EmployeeEmails",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "EmployeeEmails",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "EmployeeEmails",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "EmployeeEmails",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "EmployeeEmails",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "EmployeeEmails",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedDate",
table: "Departments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeleterUserId",
table: "Departments",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "DeletionTime",
table: "Departments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "Departments",
type: "bit",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastModificationTime",
table: "Departments",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "LastModifierUserId",
table: "Departments",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "createdby",
table: "Departments",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Locations_AccountId",
table: "Locations",
column: "AccountId");
migrationBuilder.AddForeignKey(
name: "FK_Dispatches_workOrders_WorkOrderId",
table: "Dispatches",
column: "WorkOrderId",
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Locations_Accounts_AccountId",
table: "Locations",
column: "AccountId",
principalTable: "Accounts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}

View file

@ -186,6 +186,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("Color")
.HasColumnType("nvarchar(max)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
@ -209,6 +212,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("FirstName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Initials")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -601,32 +607,11 @@ namespace Data.SeaHavenIndustries.Migrations
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?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Departments");
@ -688,6 +673,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("ReplyToAddress")
.HasColumnType("nvarchar(max)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<DateTime?>("ScheduledDate")
.HasColumnType("datetime2");
@ -1111,36 +1101,15 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<int?>("EmailTypeId")
.HasColumnType("int");
b.Property<int>("EmployeeId")
.HasColumnType("int");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<bool?>("IsPrimary")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("EmployeeId");
@ -1279,32 +1248,11 @@ namespace Data.SeaHavenIndustries.Migrations
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?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("JobTitles");
@ -1318,21 +1266,15 @@ namespace Data.SeaHavenIndustries.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Address1")
.HasColumnType("nvarchar(max)");
b.Property<string>("Address")
b.Property<string>("Address2")
.HasColumnType("nvarchar(max)");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
b.Property<string>("Contact")
.HasColumnType("nvarchar(max)");
b.Property<string>("ContactEmail")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
@ -1342,6 +1284,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1360,7 +1305,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("State")
@ -1372,7 +1317,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Title")
.HasColumnType("nvarchar(max)");
b.Property<string>("ZipCode")
b.Property<string>("Zip")
.HasColumnType("nvarchar(max)");
b.Property<string>("createdby")
@ -1380,8 +1325,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Locations");
});
@ -1573,32 +1516,11 @@ namespace Data.SeaHavenIndustries.Migrations
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?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Regions");
@ -1931,12 +1853,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("AfterPhotoIssue")
.HasColumnType("nvarchar(max)");
b.Property<DateOnly?>("AssignDate")
.HasColumnType("date");
b.Property<string>("AssignTo")
.HasColumnType("nvarchar(450)");
b.Property<string>("Attachments")
.HasColumnType("nvarchar(max)");
b.Property<string>("AvettaTask")
.HasColumnType("nvarchar(max)");
b.Property<string>("BeforPhotoAttachment")
.HasColumnType("nvarchar(max)");
@ -1946,6 +1874,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Building")
.HasColumnType("nvarchar(max)");
b.Property<int>("CarriedOver")
.HasColumnType("int");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -1967,6 +1898,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DocStatus")
.HasColumnType("int");
b.Property<DateTime?>("DueDate")
.HasColumnType("datetime2");
@ -1985,27 +1919,63 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("LegacyStatus")
.HasColumnType("nvarchar(max)");
b.Property<int?>("LifecycleStatus")
.HasColumnType("int");
b.Property<int?>("LocationId")
.HasColumnType("int");
b.Property<int>("OperationalFlags")
.HasColumnType("int");
b.Property<DateOnly?>("OriginalDate")
.HasColumnType("date");
b.Property<DateOnly?>("OriginalWeek")
.HasColumnType("date");
b.Property<string>("PO")
.HasColumnType("nvarchar(max)");
b.Property<int?>("PrimaryDispatchId")
.HasColumnType("int");
b.Property<string>("Priority")
.HasColumnType("nvarchar(max)");
b.Property<string>("Problem")
.HasColumnType("nvarchar(max)");
b.Property<int>("RescheduleCount")
.HasColumnType("int");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<bool?>("ScheduleWeekOnly")
.HasColumnType("bit");
b.Property<DateTime?>("ScheduledDate")
.HasColumnType("datetime2");
b.Property<DateTime?>("ScheduledEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("ScheduledStart")
.HasColumnType("datetime2");
b.Property<string>("Severity")
.HasColumnType("nvarchar(max)");
b.Property<string>("Service")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SignOffAttachment")
.HasColumnType("nvarchar(max)");
@ -2033,12 +2003,18 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("TT")
.HasColumnType("nvarchar(max)");
b.Property<DateOnly?>("TargetWeek")
.HasColumnType("date");
b.Property<string>("Trade")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("VendorNTE")
.HasColumnType("decimal(18,2)");
b.Property<int?>("WorkOrderType")
.HasColumnType("int");
b.Property<string>("WorkerOrderNumber")
.HasColumnType("nvarchar(max)");
@ -2057,6 +2033,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasIndex("LocationId");
b.HasIndex("PrimaryDispatchId");
b.ToTable("workOrders");
});
@ -2113,9 +2091,21 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Action")
.HasColumnType("nvarchar(max)");
b.Property<string>("ActorType")
.HasColumnType("nvarchar(max)");
b.Property<string>("CorrelationId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<int?>("DispatchId")
.HasColumnType("int");
b.Property<string>("EventType")
.HasColumnType("nvarchar(max)");
b.Property<string>("FieldName")
.HasColumnType("nvarchar(max)");
@ -2213,6 +2203,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int?>("WorkorderId")
.HasColumnType("int");
@ -2228,6 +2221,35 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("WorkOrderContacts");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("LockedAt")
.HasColumnType("datetime2");
b.Property<string>("LockedByUserId")
.HasColumnType("nvarchar(max)");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "FieldName")
.IsUnique();
b.ToTable("WorkOrderFieldLocks");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
@ -2457,8 +2479,7 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany("Dispatches")
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict);
.HasForeignKey("WorkOrderId");
b.Navigation("Vendor");
@ -2612,16 +2633,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
{
b.HasOne("Data.SeaHavenIndustries.Assets", "Asset")
@ -2731,9 +2742,16 @@ namespace Data.SeaHavenIndustries.Migrations
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch")
.WithMany()
.HasForeignKey("PrimaryDispatchId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("AssignToUser");
b.Navigation("Locations");
b.Navigation("PrimaryDispatch");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b =>
@ -2798,6 +2816,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b =>
{
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany("FieldLocks")
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -2943,6 +2972,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Dispatches");
b.Navigation("FieldLocks");
b.Navigation("Quotes");
b.Navigation("WorkOrderCategories");

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
@ -26,6 +27,10 @@ namespace Data.SeaHavenIndustries
public string? AcceptToken { get; set; }
public string? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }
public ICollection<Comments>? Comments { get; set; }
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }

View file

@ -15,6 +15,10 @@ namespace Data.SeaHavenIndustries
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string? Action { get; set; }
public string? EventType { get; set; }
public string? ActorType { get; set; }
public int? DispatchId { get; set; }
public string? CorrelationId { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View file

@ -12,6 +12,6 @@ namespace Data.SeaHavenIndustries
public int? WorkorderId { get; set; }
[ForeignKey(nameof(WorkorderId))]
public virtual WorkOrder? WorkOrder { get; set; }
public string? Notes { get; set; }
}
}

View file

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// ManualEditFlag — blocks Sync overwrite after first SHOC edit per field.
/// </summary>
public class WorkOrderFieldLock
{
public int Id { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public virtual WorkOrder? WorkOrder { get; set; }
public string FieldName { get; set; } = "";
public DateTime LockedAt { get; set; }
public string? LockedByUserId { get; set; }
}
}

View file

@ -1,11 +1,12 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using static System.Runtime.InteropServices.JavaScript.JSType;
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class WorkOrder : FullAuditEntity
{
// --- Core slice ---
public string? InternalWONumber { get; set; }
public string? ExternalWorkOrderId { get; set; }
public string? WorkerOrderNumber { get; set; }
@ -15,34 +16,59 @@ namespace Data.SeaHavenIndustries
public string? SiteCode { get; set; }
public string? Building { get; set; }
public string? Severity { get; set; }
public DateTime? DateReported { get; set; }
public DateTime? DueDate { get; set; }
public string? AssignTo { get; set; }
[ForeignKey(nameof(AssignTo))]
public virtual ApplicationUser? AssignToUser { get; set; }
public int? LocationId { get; set; }
[ForeignKey(nameof(LocationId))]
public virtual Locations? Locations { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public string? LegacyStatus { get; set; }
public WorkOrderType? WorkOrderType { get; set; }
/// <summary>SHOC board "Service" column (PM type label). Not Trade/Problem.</summary>
public string? Service { get; set; }
public int? PrimaryDispatchId { get; set; }
[ForeignKey(nameof(PrimaryDispatchId))]
public virtual Dispatch? PrimaryDispatch { get; set; }
// --- Scheduling slice ---
public DateTime? ScheduledDate { get; set; }
public DateTime? ScheduledStart { get; set; }
public DateTime? ScheduledEnd { get; set; }
public DateOnly? TargetWeek { get; set; }
public bool? ScheduleWeekOnly { get; set; }
// --- Tracking slice (set-once) ---
public DateOnly? OriginalWeek { get; set; }
public DateOnly? OriginalDate { get; set; }
// --- Analytics slice ---
public int RescheduleCount { get; set; }
public int CarriedOver { get; set; }
public OperationalFlags OperationalFlags { get; set; }
// --- Completion slice ---
public DocStatus? DocStatus { get; set; }
// --- Legacy SQL columns (drift reconciliation) ---
public string? AvettaTask { get; set; }
public DateOnly? AssignDate { get; set; }
// --- Legacy fields (retained for Blazor / coexistence) ---
public DateTime? DateReported { get; set; }
public string? SourceEmailS3Key { get; set; }
public string? Problem { get; set; }
public string? Trade { get; set; }
public string? SubTrade { get; set; }
public decimal? VendorNTE { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? CompletedDate { get; set; }
public string? Source { get; set; }
public bool? istemplate { get; set; }
public string? PO { get; set; }
public string? TT { get; set; }
//public int? ContactId { get; set; }
//[ForeignKey(nameof(ContactId))]
//public virtual Contacts? POC { get; set; }
public string? AssignTo { get; set; }
[ForeignKey(nameof(AssignTo))]
public virtual ApplicationUser? AssignToUser { get; set; }
public DateTime? DueDate { get; set; }
public string? Priority { get; set; }
public string? Status { get; set; }
public int? LocationId { get; set; }
[ForeignKey(nameof(LocationId))]
public virtual Locations? Locations { get; set; }
//public int? CategoryId { get; set; }
//[ForeignKey(nameof(CategoryId))]
//public virtual Category? Category { get; set; }
public string? Attachments { get; set; }
public string? BeforPhotoIssue { get; set; }
public string? BeforPhotoAttachment { get; set; }
@ -51,11 +77,16 @@ namespace Data.SeaHavenIndustries
public string? SignOffName { get; set; }
public string? SignOffAttachment { get; set; }
public string? SignOffSignature { get; set; }
[Timestamp]
public byte[]? RowVersion { get; set; }
public ICollection<Comments>? Comments { get; set; }
public ICollection<Quotes>? Quotes { get; set; }
public ICollection<WorkOrderContacts>? WorkOrderContacts { get; set; }
public ICollection<WorkOrderAttachments>? workOrderAttachments { get; set; }
public ICollection<WorkOrderCategories>? WorkOrderCategories { get; set; }
public ICollection<Dispatch>? Dispatches { get; set; }
public ICollection<WorkOrderFieldLock>? FieldLocks { get; set; }
}
}

View file

@ -0,0 +1,26 @@
namespace SeaHaven.Services.Constants
{
public static class WorkOrderFieldNames
{
public const string LifecycleStatus = nameof(LifecycleStatus);
public const string AssignTo = nameof(AssignTo);
public const string ScheduledDate = nameof(ScheduledDate);
public const string ScheduledEnd = nameof(ScheduledEnd);
public const string TargetWeek = nameof(TargetWeek);
public const string ScheduleWeekOnly = nameof(ScheduleWeekOnly);
public const string DueDate = nameof(DueDate);
public const string Description = nameof(Description);
public const string WorkerOrderTitle = nameof(WorkerOrderTitle);
public const string SiteCode = nameof(SiteCode);
public const string Building = nameof(Building);
public const string LocationId = nameof(LocationId);
public const string WorkOrderType = nameof(WorkOrderType);
public const string Service = nameof(Service);
public const string DocStatus = nameof(DocStatus);
public const string Trade = nameof(Trade);
public const string Problem = nameof(Problem);
public const string Priority = nameof(Priority);
public const string InternalWONumber = nameof(InternalWONumber);
public const string Status = nameof(Status);
}
}

View file

@ -0,0 +1,86 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class LifecycleStatusMapper
{
public static LifecycleStatus? FromLegacyStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return null;
return status.Trim().ToLowerInvariant() switch
{
"open" or "incomplete" => LifecycleStatus.Incomplete,
"pending" or "onhold" or "on hold" => LifecycleStatus.Pending,
"scheduled" => LifecycleStatus.Scheduled,
"en route" or "enroute" => LifecycleStatus.EnRoute,
"on site" or "onsite" => LifecycleStatus.OnSite,
"inprogress" or "in progress" => LifecycleStatus.InProgress,
"completed" or "complete" or "done" or "closed" => LifecycleStatus.Completed,
"rescheduled" => LifecycleStatus.Rescheduled,
"cancelled" or "canceled" => LifecycleStatus.Canceled,
"pending quote" or "pendingquote" or "pendingapproval" or "pending approval" => LifecycleStatus.PendingQuote,
_ => null
};
}
public static LifecycleStatus? ParseLifecycleStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (TryParseFeLabel(value, out var fromLabel))
return fromLabel;
if (Enum.TryParse<LifecycleStatus>(value, true, out var parsed))
return parsed;
return FromLegacyStatus(value);
}
public static bool TryParseFeLabel(string? label, out LifecycleStatus status)
{
status = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"incomplete" => Set(LifecycleStatus.Incomplete, out status),
"pending" => Set(LifecycleStatus.Pending, out status),
"scheduled" => Set(LifecycleStatus.Scheduled, out status),
"en route" => Set(LifecycleStatus.EnRoute, out status),
"on site" => Set(LifecycleStatus.OnSite, out status),
"in progress" => Set(LifecycleStatus.InProgress, out status),
"completed" => Set(LifecycleStatus.Completed, out status),
"rescheduled" => Set(LifecycleStatus.Rescheduled, out status),
"canceled" => Set(LifecycleStatus.Canceled, out status),
"pending quote" => Set(LifecycleStatus.PendingQuote, out status),
_ => false
};
}
public static string ToFeLabel(LifecycleStatus status) => status switch
{
LifecycleStatus.Incomplete => "Incomplete",
LifecycleStatus.Pending => "Pending",
LifecycleStatus.Scheduled => "Scheduled",
LifecycleStatus.EnRoute => "En Route",
LifecycleStatus.OnSite => "On Site",
LifecycleStatus.InProgress => "In Progress",
LifecycleStatus.Completed => "Completed",
LifecycleStatus.Rescheduled => "Rescheduled",
LifecycleStatus.Canceled => "Canceled",
LifecycleStatus.PendingQuote => "Pending Quote",
_ => status.ToString()
};
public static bool IsTerminal(LifecycleStatus? status)
=> status is LifecycleStatus.Completed or LifecycleStatus.Canceled;
private static bool Set(LifecycleStatus value, out LifecycleStatus status)
{
status = value;
return true;
}
}
}

View file

@ -0,0 +1,55 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderDerivedFields
{
/// <summary>
/// Past due overlay — never a WorkOrderType. Matches SHOC board design.
/// </summary>
public static bool IsPastDue(WorkOrder workOrder, DateOnly? asOf = null)
{
var today = asOf ?? DateOnly.FromDateTime(DateTime.UtcNow);
if (workOrder.ScheduledDate == null)
return false;
if (LifecycleStatusMapper.IsTerminal(workOrder.LifecycleStatus))
return false;
return DateOnly.FromDateTime(workOrder.ScheduledDate.Value.Date) < today;
}
/// <summary>
/// Sets initial lifecycle on create. New WOs start Incomplete unless auto-scheduled.
/// </summary>
public static void SetInitialLifecycleStatus(WorkOrder workOrder)
{
workOrder.LifecycleStatus ??= LifecycleStatus.Incomplete;
ApplyAutoScheduleIfEligible(workOrder);
}
/// <summary>
/// SHOC rule: specific scheduled date + assignee + Incomplete → Scheduled.
/// Week-only targets do not auto-schedule.
/// </summary>
public static bool ApplyAutoScheduleIfEligible(WorkOrder workOrder)
{
if (workOrder.LifecycleStatus != LifecycleStatus.Incomplete)
return false;
if (workOrder.ScheduleWeekOnly == true)
return false;
if (workOrder.ScheduledDate == null)
return false;
if (string.IsNullOrWhiteSpace(workOrder.AssignTo))
return false;
workOrder.LifecycleStatus = LifecycleStatus.Scheduled;
return true;
}
}
}

View file

@ -0,0 +1,37 @@
using System.Text.RegularExpressions;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderNumberNormalizer
{
private const int RequiredLength = 11;
public static bool TryNormalize(string? input, out string normalized, out string? error)
{
normalized = "";
error = null;
if (string.IsNullOrWhiteSpace(input))
{
error = "WO number is required.";
return false;
}
var digits = Regex.Replace(input.Trim(), @"\D", "");
if (digits.Length == 0)
{
error = "WO number must contain digits.";
return false;
}
if (digits.Length > RequiredLength)
{
error = $"WO number cannot exceed {RequiredLength} digits.";
return false;
}
normalized = digits.PadLeft(RequiredLength, '0');
return true;
}
}
}

View file

@ -0,0 +1,48 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Helpers
{
public static class WorkOrderTypeMapper
{
/// <summary>Board FE types. Overdue is not a type — use <see cref="WorkOrderDerivedFields.IsPastDue"/>.</summary>
public static bool TryParseFeLabel(string? label, out WorkOrderType type)
{
type = default;
if (string.IsNullOrWhiteSpace(label)) return false;
return label.Trim().ToLowerInvariant() switch
{
"pm" => Set(WorkOrderType.PM, out type),
"reactive" => Set(WorkOrderType.Reactive, out type),
"emergency" => Set(WorkOrderType.Emergency, out type),
"add-on" or "addon" => Set(WorkOrderType.AddOn, out type),
"po" => Set(WorkOrderType.PO, out type),
"project" => Set(WorkOrderType.Project, out type),
"inspection" => Set(WorkOrderType.Inspection, out type),
"other" => Set(WorkOrderType.Other, out type),
"overdue" => false,
_ => Enum.TryParse(label, true, out type)
};
}
public static string ToFeLabel(WorkOrderType type) => type switch
{
WorkOrderType.PM => "PM",
WorkOrderType.Reactive => "Reactive",
WorkOrderType.Emergency => "Emergency",
WorkOrderType.AddOn => "Add-On",
WorkOrderType.PO => "PO",
WorkOrderType.Project => "Project",
WorkOrderType.Inspection => "Inspection",
WorkOrderType.Other => "Other",
_ => type.ToString()
};
private static bool Set(WorkOrderType value, out WorkOrderType type)
{
type = value;
return true;
}
}
}

View file

@ -0,0 +1,219 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class SyncFieldMergePolicy : ISyncFieldMergePolicy
{
private static readonly HashSet<string> ShocOwnedFieldSet = new(StringComparer.OrdinalIgnoreCase)
{
WorkOrderFieldNames.LifecycleStatus,
WorkOrderFieldNames.AssignTo,
WorkOrderFieldNames.ScheduledDate,
WorkOrderFieldNames.ScheduledEnd,
WorkOrderFieldNames.TargetWeek,
WorkOrderFieldNames.ScheduleWeekOnly,
WorkOrderFieldNames.DueDate,
WorkOrderFieldNames.Description,
WorkOrderFieldNames.WorkerOrderTitle,
WorkOrderFieldNames.SiteCode,
WorkOrderFieldNames.Building,
WorkOrderFieldNames.LocationId,
WorkOrderFieldNames.WorkOrderType,
WorkOrderFieldNames.Service,
WorkOrderFieldNames.DocStatus,
WorkOrderFieldNames.Trade,
WorkOrderFieldNames.Problem,
WorkOrderFieldNames.Priority,
WorkOrderFieldNames.InternalWONumber,
WorkOrderFieldNames.Status,
};
private readonly IWorkOrderFieldLockService _fieldLocks;
public SyncFieldMergePolicy(IWorkOrderFieldLockService fieldLocks)
{
_fieldLocks = fieldLocks;
}
public IReadOnlySet<string> ShocOwnedFields => ShocOwnedFieldSet;
public async Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName)
=> !await _fieldLocks.IsLockedAsync(workOrderId, fieldName);
public async Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue)
{
if (incomingValue == null) return false;
if (await context.FieldLocks.IsLockedAsync(context.WorkOrder.Id, fieldName))
{
var current = GetFieldValue(context.WorkOrder, fieldName);
await context.Audit.LogSyncRejectedAsync(
context.WorkOrder.Id,
fieldName,
current,
incomingValue.ToString());
return false;
}
return TryApplyField(context.WorkOrder, fieldName, incomingValue);
}
private static string? GetFieldValue(WorkOrder wo, string fieldName) => fieldName switch
{
WorkOrderFieldNames.Description => wo.Description,
WorkOrderFieldNames.WorkerOrderTitle => wo.WorkerOrderTitle,
WorkOrderFieldNames.Status => wo.Status,
WorkOrderFieldNames.DueDate => wo.DueDate?.ToString("O"),
WorkOrderFieldNames.ScheduledDate => wo.ScheduledDate?.ToString("O"),
WorkOrderFieldNames.ScheduledEnd => wo.ScheduledEnd?.ToString("O"),
WorkOrderFieldNames.TargetWeek => wo.TargetWeek?.ToString("O"),
WorkOrderFieldNames.ScheduleWeekOnly => wo.ScheduleWeekOnly?.ToString(),
WorkOrderFieldNames.SiteCode => wo.SiteCode,
WorkOrderFieldNames.Building => wo.Building,
WorkOrderFieldNames.Priority => wo.Priority,
WorkOrderFieldNames.AssignTo => wo.AssignTo,
WorkOrderFieldNames.LocationId => wo.LocationId?.ToString(),
WorkOrderFieldNames.LifecycleStatus => wo.LifecycleStatus?.ToString(),
WorkOrderFieldNames.WorkOrderType => wo.WorkOrderType?.ToString(),
WorkOrderFieldNames.Service => wo.Service,
WorkOrderFieldNames.DocStatus => wo.DocStatus?.ToString(),
WorkOrderFieldNames.Trade => wo.Trade,
WorkOrderFieldNames.Problem => wo.Problem,
WorkOrderFieldNames.InternalWONumber => wo.InternalWONumber,
_ => null
};
private static bool TryApplyField(WorkOrder wo, string fieldName, object incomingValue)
{
switch (fieldName)
{
case WorkOrderFieldNames.Description:
wo.Description = incomingValue.ToString();
return true;
case WorkOrderFieldNames.WorkerOrderTitle:
wo.WorkerOrderTitle = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Status:
wo.Status = incomingValue.ToString();
return true;
case WorkOrderFieldNames.DueDate when TryParseDateTime(incomingValue, out var dueDate):
wo.DueDate = dueDate;
return true;
case WorkOrderFieldNames.ScheduledDate when TryParseDateTime(incomingValue, out var scheduledDate):
wo.ScheduledDate = scheduledDate;
return true;
case WorkOrderFieldNames.ScheduledEnd when TryParseDateTime(incomingValue, out var scheduledEnd):
wo.ScheduledEnd = scheduledEnd;
return true;
case WorkOrderFieldNames.TargetWeek when TryParseDateOnly(incomingValue, out var targetWeek):
wo.TargetWeek = targetWeek;
return true;
case WorkOrderFieldNames.ScheduleWeekOnly when TryParseBool(incomingValue, out var scheduleWeekOnly):
wo.ScheduleWeekOnly = scheduleWeekOnly;
return true;
case WorkOrderFieldNames.SiteCode:
wo.SiteCode = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Building:
wo.Building = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Priority:
wo.Priority = incomingValue.ToString();
return true;
case WorkOrderFieldNames.AssignTo:
wo.AssignTo = incomingValue.ToString();
return true;
case WorkOrderFieldNames.LocationId when TryParseInt(incomingValue, out var locationId):
wo.LocationId = locationId;
return true;
case WorkOrderFieldNames.LifecycleStatus when TryParseEnum(incomingValue, out LifecycleStatus lifecycleStatus):
wo.LifecycleStatus = lifecycleStatus;
return true;
case WorkOrderFieldNames.WorkOrderType when TryParseEnum(incomingValue, out WorkOrderType workOrderType):
wo.WorkOrderType = workOrderType;
return true;
case WorkOrderFieldNames.Service:
wo.Service = incomingValue.ToString();
return true;
case WorkOrderFieldNames.DocStatus when TryParseEnum(incomingValue, out DocStatus docStatus):
wo.DocStatus = docStatus;
return true;
case WorkOrderFieldNames.Trade:
wo.Trade = incomingValue.ToString();
return true;
case WorkOrderFieldNames.Problem:
wo.Problem = incomingValue.ToString();
return true;
case WorkOrderFieldNames.InternalWONumber:
wo.InternalWONumber = incomingValue.ToString();
return true;
default:
return false;
}
}
private static bool TryParseDateTime(object value, out DateTime result)
{
if (value is DateTime dt)
{
result = dt;
return true;
}
return DateTime.TryParse(value.ToString(), out result);
}
private static bool TryParseDateOnly(object value, out DateOnly result)
{
if (value is DateOnly dateOnly)
{
result = dateOnly;
return true;
}
if (value is DateTime dt)
{
result = DateOnly.FromDateTime(dt);
return true;
}
return DateOnly.TryParse(value.ToString(), out result);
}
private static bool TryParseBool(object value, out bool result)
{
if (value is bool b)
{
result = b;
return true;
}
return bool.TryParse(value.ToString(), out result);
}
private static bool TryParseInt(object value, out int result)
{
if (value is int i)
{
result = i;
return true;
}
return int.TryParse(value.ToString(), out result);
}
private static bool TryParseEnum<TEnum>(object value, out TEnum result) where TEnum : struct, Enum
{
if (value is TEnum enumValue)
{
result = enumValue;
return true;
}
return Enum.TryParse(value.ToString(), true, out result);
}
}
}

View file

@ -0,0 +1,92 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderAuditService : IWorkOrderAuditService
{
private readonly ApplicationDbContext _context;
private readonly IWorkOrderFieldLockService _fieldLocks;
public WorkOrderAuditService(ApplicationDbContext context, IWorkOrderFieldLockService fieldLocks)
{
_context = context;
_fieldLocks = fieldLocks;
}
public Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> LogAsync(workOrderId, AuditActionType.StatusChanged, AuditEventType.Manual,
AuditActorType.Dispatcher, WorkOrderFieldNames.LifecycleStatus, oldStatus, newStatus, actorId);
public Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> LogAsync(workOrderId, AuditActionType.AssignmentChanged, AuditEventType.Manual,
AuditActorType.Dispatcher, WorkOrderFieldNames.AssignTo, oldValue, newValue, actorId);
public Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue)
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
AuditActorType.Sync, fieldName, oldValue, newValue);
public async Task LogAsync(
int workOrderId,
AuditActionType action,
AuditEventType eventType,
AuditActorType actorType,
string fieldName,
string? oldValue,
string? newValue,
string? actorId = null,
int? dispatchId = null,
string? correlationId = null)
{
_context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = actorId,
FieldName = fieldName,
OldValue = oldValue ?? "",
NewValue = newValue ?? "",
Action = action.ToString(),
EventType = eventType.ToString(),
ActorType = actorType.ToString(),
DispatchId = dispatchId,
CorrelationId = correlationId,
CreatedAt = DateTime.UtcNow
});
if (eventType == AuditEventType.Manual &&
(action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged))
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
await SaveChangesIdempotentAsync();
}
private async Task SaveChangesIdempotentAsync()
{
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock))
entry.State = EntityState.Detached;
await _context.SaveChangesAsync();
}
}
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
var message = ex.InnerException?.Message ?? ex.Message;
return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
|| message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase);
}
}
}

View file

@ -0,0 +1,47 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class WorkOrderFieldLockService : IWorkOrderFieldLockService
{
private readonly ApplicationDbContext _context;
public WorkOrderFieldLockService(ApplicationDbContext context)
{
_context = context;
}
public async Task<bool> IsLockedAsync(int workOrderId, string fieldName)
{
if (IsTracked(workOrderId, fieldName))
return true;
return await _context.WorkOrderFieldLocks
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
}
public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId)
{
if (IsTracked(workOrderId, fieldName))
return;
if (await IsLockedAsync(workOrderId, fieldName))
return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = workOrderId,
FieldName = fieldName,
LockedAt = DateTime.UtcNow,
LockedByUserId = userId
});
}
private bool IsTracked(int workOrderId, string fieldName)
=> _context.WorkOrderFieldLocks.Local.Any(l =>
l.WorkOrderId == workOrderId &&
string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase));
}
}

View file

@ -0,0 +1,18 @@
namespace SeaHaven.Services.Interfaces
{
public interface ISyncFieldMergePolicy
{
IReadOnlySet<string> ShocOwnedFields { get; }
Task<bool> CanSyncOverwriteAsync(int workOrderId, string fieldName);
Task<bool> TryApplyAsync(WorkOrderSyncContext context, string fieldName, object? incomingValue);
}
public class WorkOrderSyncContext
{
public required Data.SeaHavenIndustries.WorkOrder WorkOrder { get; init; }
public required SeaHaven.Services.Interfaces.IWorkOrderFieldLockService FieldLocks { get; init; }
public required SeaHaven.Services.Interfaces.IWorkOrderAuditService Audit { get; init; }
}
}

View file

@ -0,0 +1,23 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderAuditService
{
Task LogAsync(
int workOrderId,
AuditActionType action,
AuditEventType eventType,
AuditActorType actorType,
string fieldName,
string? oldValue,
string? newValue,
string? actorId = null,
int? dispatchId = null,
string? correlationId = null);
Task LogStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
}
}

View file

@ -0,0 +1,8 @@
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderFieldLockService
{
Task LockFieldAsync(int workOrderId, string fieldName, string? userId);
Task<bool> IsLockedAsync(int workOrderId, string fieldName);
}
}

View file

@ -0,0 +1,50 @@
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
namespace SeaHavenIndustries.Tests;
public class ConcurrencyExceptionFilterTests
{
[Fact]
public void OnException_DbUpdateConcurrencyException_Returns409Conflict()
{
var filter = new ConcurrencyExceptionFilter();
var context = CreateExceptionContext(new DbUpdateConcurrencyException());
filter.OnException(context);
Assert.True(context.ExceptionHandled);
var result = Assert.IsType<ConflictObjectResult>(context.Result);
Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode);
}
[Fact]
public void OnException_OtherException_DoesNotHandle()
{
var filter = new ConcurrencyExceptionFilter();
var context = CreateExceptionContext(new InvalidOperationException("other"));
filter.OnException(context);
Assert.False(context.ExceptionHandled);
Assert.Null(context.Result);
}
private static ExceptionContext CreateExceptionContext(Exception exception)
{
var actionContext = new ActionContext(
new DefaultHttpContext(),
new RouteData(),
new ActionDescriptor());
return new ExceptionContext(actionContext, [])
{
Exception = exception
};
}
}

View file

@ -0,0 +1,40 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
public class LifecycleStatusMapperTests
{
[Theory]
[InlineData("Open", LifecycleStatus.Incomplete)]
[InlineData("Incomplete", LifecycleStatus.Incomplete)]
[InlineData("InProgress", LifecycleStatus.InProgress)]
[InlineData("In Progress", LifecycleStatus.InProgress)]
[InlineData("Completed", LifecycleStatus.Completed)]
[InlineData("Cancelled", LifecycleStatus.Canceled)]
[InlineData("On Hold", LifecycleStatus.Pending)]
[InlineData("Scheduled", LifecycleStatus.Scheduled)]
[InlineData("En Route", LifecycleStatus.EnRoute)]
[InlineData("Pending Quote", LifecycleStatus.PendingQuote)]
public void FromLegacyStatus_MapsKnownValues(string input, LifecycleStatus expected)
{
var result = LifecycleStatusMapper.FromLegacyStatus(input);
Assert.Equal(expected, result);
}
[Fact]
public void FromLegacyStatus_Unknown_ReturnsNull()
{
var result = LifecycleStatusMapper.FromLegacyStatus("MysteryStatus");
Assert.Null(result);
}
[Theory]
[InlineData(LifecycleStatus.EnRoute, "En Route")]
[InlineData(LifecycleStatus.PendingQuote, "Pending Quote")]
public void ToFeLabel_ReturnsBoardLabel(LifecycleStatus status, string expected)
{
Assert.Equal(expected, LifecycleStatusMapper.ToFeLabel(status));
}
}

View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Api.SeaHavenIndustries\Api.SeaHavenIndustries.csproj" />
<ProjectReference Include="..\SeaHaven.Services\SeaHaven.Services.csproj" />
<ProjectReference Include="..\Data.SeaHavenIndustries\Data.SeaHavenIndustries.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,151 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class SyncFieldMergePolicyTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task TryApply_WhenFieldLocked_RejectsAndAudits()
{
await using var context = CreateContext();
var wo = new WorkOrder { Id = 10, Description = "SHOC edited", Status = "Open" };
context.workOrders.Add(wo);
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 10,
FieldName = WorkOrderFieldNames.Description,
LockedAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = locks,
Audit = audit
};
var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "Sync value");
Assert.False(applied);
Assert.Equal("SHOC edited", wo.Description);
var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Single(logs);
Assert.Equal("SyncRejected", logs[0].Action);
Assert.Equal("Sync", logs[0].EventType);
}
[Fact]
public async Task TryApply_WhenFieldNotLocked_AppliesValue()
{
await using var context = CreateContext();
var wo = new WorkOrder { Id = 11, Description = "Original" };
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = locks,
Audit = audit
};
var applied = await policy.TryApplyAsync(syncContext, WorkOrderFieldNames.Description, "From sync");
Assert.True(applied);
Assert.Equal("From sync", wo.Description);
}
[Fact]
public async Task TryApply_UnsupportedField_ReturnsFalse()
{
await using var context = CreateContext();
var wo = new WorkOrder { Id = 12 };
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = locks,
Audit = audit
};
var applied = await policy.TryApplyAsync(syncContext, "NotARealField", "value");
Assert.False(applied);
}
[Theory]
[InlineData(WorkOrderFieldNames.LifecycleStatus, LifecycleStatus.Scheduled)]
[InlineData(WorkOrderFieldNames.Service, "PM")]
[InlineData(WorkOrderFieldNames.DueDate, "2026-07-01T10:00:00Z")]
public async Task TryApply_ShocOwnedFields_AppliesValue(string fieldName, object incomingValue)
{
await using var context = CreateContext();
var wo = new WorkOrder { Id = 13 };
context.workOrders.Add(wo);
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
var policy = new SyncFieldMergePolicy(locks);
var syncContext = new WorkOrderSyncContext
{
WorkOrder = wo,
FieldLocks = locks,
Audit = audit
};
var applied = await policy.TryApplyAsync(syncContext, fieldName, incomingValue);
Assert.True(applied);
}
[Fact]
public async Task CanSyncOverwriteAsync_WhenLocked_ReturnsFalse()
{
await using var context = CreateContext();
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 20,
FieldName = WorkOrderFieldNames.AssignTo,
LockedAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var policy = new SyncFieldMergePolicy(locks);
var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo);
Assert.False(canOverwrite);
}
}

View file

@ -0,0 +1,56 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public class WorkOrderAuditServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task LogStatusChanged_CreatesSingleAuditWithManualEventType()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder { Id = 1, Status = "Open" });
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
await audit.LogStatusChangedAsync(1, "Open", "In Progress", "user-1");
var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Single(logs);
Assert.Equal("StatusChanged", logs[0].Action);
Assert.Equal("Manual", logs[0].EventType);
Assert.Equal("Dispatcher", logs[0].ActorType);
Assert.Equal(WorkOrderFieldNames.LifecycleStatus, logs[0].FieldName);
}
[Fact]
public async Task LogStatusChanged_CreatesFieldLock()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder { Id = 2, Status = "Open" });
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, locks);
await audit.LogStatusChangedAsync(2, "Open", "Scheduled", "user-1");
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks);
Assert.Equal(WorkOrderFieldNames.LifecycleStatus, fieldLocks[0].FieldName);
}
}

View file

@ -0,0 +1,75 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
public class WorkOrderDerivedFieldsTests
{
[Fact]
public void IsPastDue_WhenScheduledBeforeTodayAndNotTerminal_ReturnsTrue()
{
var wo = new WorkOrder
{
ScheduledDate = DateTime.UtcNow.AddDays(-2),
LifecycleStatus = LifecycleStatus.Scheduled
};
Assert.True(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
}
[Fact]
public void IsPastDue_WhenCompleted_ReturnsFalse()
{
var wo = new WorkOrder
{
ScheduledDate = DateTime.UtcNow.AddDays(-2),
LifecycleStatus = LifecycleStatus.Completed
};
Assert.False(WorkOrderDerivedFields.IsPastDue(wo, DateOnly.FromDateTime(DateTime.UtcNow)));
}
[Fact]
public void ApplyAutoSchedule_WhenDateAndAssignee_PromotesToScheduled()
{
var wo = new WorkOrder
{
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = new DateTime(2026, 6, 25),
AssignTo = "dispatcher-1"
};
Assert.True(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
}
[Fact]
public void ApplyAutoSchedule_WeekOnly_DoesNotPromote()
{
var wo = new WorkOrder
{
LifecycleStatus = LifecycleStatus.Incomplete,
ScheduledDate = new DateTime(2026, 6, 25),
AssignTo = "dispatcher-1",
ScheduleWeekOnly = true,
TargetWeek = new DateOnly(2026, 6, 22)
};
Assert.False(WorkOrderDerivedFields.ApplyAutoScheduleIfEligible(wo));
Assert.Equal(LifecycleStatus.Incomplete, wo.LifecycleStatus);
}
[Fact]
public void SetInitialLifecycleStatus_NewWo_StartsIncompleteThenAutoSchedules()
{
var wo = new WorkOrder
{
ScheduledDate = new DateTime(2026, 6, 25),
AssignTo = "dispatcher-1"
};
WorkOrderDerivedFields.SetInitialLifecycleStatus(wo);
Assert.Equal(LifecycleStatus.Scheduled, wo.LifecycleStatus);
}
}

View file

@ -0,0 +1,53 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Constants;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderFieldLockServiceTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock()
{
await using var context = CreateContext();
var locks = new WorkOrderFieldLockService(context);
await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1");
await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2");
await context.SaveChangesAsync();
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks);
Assert.Equal(WorkOrderFieldNames.Description, fieldLocks[0].FieldName);
}
[Fact]
public async Task LockFieldAsync_WhenAlreadyPersisted_IsIdempotent()
{
await using var context = CreateContext();
context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = 2,
FieldName = WorkOrderFieldNames.AssignTo,
LockedAt = DateTime.UtcNow,
LockedByUserId = "user-1"
});
await context.SaveChangesAsync();
var locks = new WorkOrderFieldLockService(context);
await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2");
await context.SaveChangesAsync();
var fieldLocks = await context.WorkOrderFieldLocks.ToListAsync();
Assert.Single(fieldLocks);
}
}

View file

@ -0,0 +1,43 @@
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
public class WorkOrderNumberNormalizerTests
{
[Fact]
public void TryNormalize_PadsToElevenDigits()
{
var ok = WorkOrderNumberNormalizer.TryNormalize("12345", out var normalized, out var error);
Assert.True(ok);
Assert.Null(error);
Assert.Equal("00000012345", normalized);
}
[Fact]
public void TryNormalize_AcceptsElevenDigits()
{
var ok = WorkOrderNumberNormalizer.TryNormalize("10000000001", out var normalized, out _);
Assert.True(ok);
Assert.Equal("10000000001", normalized);
}
[Fact]
public void TryNormalize_RejectsEmpty()
{
var ok = WorkOrderNumberNormalizer.TryNormalize("", out _, out var error);
Assert.False(ok);
Assert.NotNull(error);
}
[Fact]
public void TryNormalize_RejectsTooLong()
{
var ok = WorkOrderNumberNormalizer.TryNormalize("123456789012", out _, out var error);
Assert.False(ok);
Assert.Contains("11", error);
}
}

View file

@ -0,0 +1,32 @@
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.Helpers;
namespace SeaHavenIndustries.Tests;
public class WorkOrderTypeMapperTests
{
[Theory]
[InlineData("PM", WorkOrderType.PM)]
[InlineData("Reactive", WorkOrderType.Reactive)]
[InlineData("Emergency", WorkOrderType.Emergency)]
[InlineData("Add-On", WorkOrderType.AddOn)]
public void TryParseFeLabel_BoardTypes(string label, WorkOrderType expected)
{
Assert.True(WorkOrderTypeMapper.TryParseFeLabel(label, out var type));
Assert.Equal(expected, type);
}
[Fact]
public void TryParseFeLabel_Overdue_ReturnsFalse()
{
Assert.False(WorkOrderTypeMapper.TryParseFeLabel("Overdue", out _));
}
[Theory]
[InlineData(WorkOrderType.AddOn, "Add-On")]
[InlineData(WorkOrderType.Reactive, "Reactive")]
public void ToFeLabel_ReturnsBoardLabel(WorkOrderType type, string expected)
{
Assert.Equal(expected, WorkOrderTypeMapper.ToFeLabel(type));
}
}

View file

@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.SeaHavenIndustries.Test
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHaven.Services.Tests", "SeaHaven.Services.Tests\SeaHaven.Services.Tests.csproj", "{97455198-C479-47F2-8BC8-601B335DFFAB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeaHavenIndustries.Tests", "SeaHavenIndustries.Tests\SeaHavenIndustries.Tests.csproj", "{0093B3F5-0180-4C02-A088-6A564E06CEBF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -51,6 +53,10 @@ Global
{97455198-C479-47F2-8BC8-601B335DFFAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{97455198-C479-47F2-8BC8-601B335DFFAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97455198-C479-47F2-8BC8-601B335DFFAB}.Release|Any CPU.Build.0 = Release|Any CPU
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0093B3F5-0180-4C02-A088-6A564E06CEBF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE