Remove ShopWorker entity and migrate worker identity to ApplicationUser

Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 20:32:32 -04:00
parent 3b5511a703
commit 1a44133a63
43 changed files with 10989 additions and 1055 deletions
@@ -205,11 +205,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
public DbSet<Vendor> Vendors { get; set; }
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
public DbSet<ShopWorker> ShopWorkers { get; set; }
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
public DbSet<ReworkRecord> ReworkRecords { get; set; }
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
public DbSet<Refund> Refunds { get; set; }
@@ -530,11 +526,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -1314,12 +1306,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.HasForeignKey(m => m.PerformedById)
.OnDelete(DeleteBehavior.SetNull);
// ShopWorker relationships
modelBuilder.Entity<ShopWorker>()
.HasOne<Company>()
.WithMany(c => c.ShopWorkers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Job>()
.HasOne(j => j.AssignedUser)
@@ -1393,10 +1380,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<PricingTier>()
.HasIndex(p => p.CompanyId);
modelBuilder.Entity<ShopWorker>()
.HasIndex(w => w.CompanyId);
modelBuilder.Entity<CatalogCategory>()
modelBuilder.Entity<CatalogCategory>()
.HasIndex(c => c.CompanyId);
modelBuilder.Entity<CatalogCategory>()
@@ -1431,12 +1415,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.IsUnique()
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
modelBuilder.Entity<ShopWorkerRoleCost>()
.HasIndex(r => new { r.CompanyId, r.Role })
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
modelBuilder.Entity<Job>()
modelBuilder.Entity<Job>()
.Property(j => j.ShopAccessCode)
.HasDefaultValueSql("NEWID()");
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
{
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
nameof(MaintenanceRecord), nameof(Vendor),
nameof(InventoryItem), nameof(Company),
// Financial entities
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddLaborCostPerHour : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "LaborCostPerHour",
table: "CompanyOperatingCosts",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "LaborCostPerHour",
table: "AspNetUsers",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LaborCostPerHour",
table: "CompanyOperatingCosts");
migrationBuilder.DropColumn(
name: "LaborCostPerHour",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
}
}
}
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsBanned")
.HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("LastLoginDate")
.HasColumnType("datetime2");
@@ -2075,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<int>("MonthlyBillableHours")
.HasColumnType("int");
@@ -6714,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6725,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6736,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
.ThenInclude(t => t.Worker)
.AsNoTracking()
.FirstOrDefaultAsync();
}
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
private IRepository<PrepService>? _prepServices;
private IRepository<ShopWorker>? _shopWorkers;
// Appointments
private IRepository<Appointment>? _appointments;
@@ -350,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
public IRepository<PrepService> PrepServices =>
_prepServices ??= new Repository<PrepService>(_context);
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary>
public IRepository<ShopWorker> ShopWorkers =>
_shopWorkers ??= new Repository<ShopWorker>(_context);
/// <summary>Repository for <see cref="ShopWorkerRoleCost"/> per-role labour cost rates; unique on (CompanyId, Role).</summary>
private IRepository<ShopWorkerRoleCost>? _shopWorkerRoleCosts;
public IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts =>
_shopWorkerRoleCosts ??= new Repository<ShopWorkerRoleCost>(_context);
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
private IRepository<ReworkRecord>? _reworkRecords;
public IRepository<ReworkRecord> ReworkRecords =>
_reworkRecords ??= new Repository<ReworkRecord>(_context);
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// ── Tier 4: Company configs and lookup tables ─────────────────────────
@@ -137,7 +136,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
@@ -160,7 +158,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
using CsvHelper;
using CsvHelper.Configuration;
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
}
#endregion
#region Shop Worker Import
/// <summary>
/// Generates a downloadable CSV template with two example shop worker rows covering different roles.
/// Two rows help users see how Role values (Coater, Sandblaster, etc.) are expressed and remind
/// them that Role is optional — the importer will default to GeneralLabor when it is omitted.
/// </summary>
public byte[] GenerateShopWorkerTemplate()
{
using var memoryStream = new MemoryStream();
using var writer = new StreamWriter(memoryStream);
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
csv.WriteHeader<ShopWorkerImportDto>();
csv.NextRecord();
csv.WriteRecord(new ShopWorkerImportDto
{
Name = "John Doe",
Role = "Coater",
Phone = "555-1234",
Email = "johndoe@example.com",
IsActive = true,
Notes = "Experienced powder coater"
});
csv.NextRecord();
csv.WriteRecord(new ShopWorkerImportDto
{
Name = "Jane Smith",
Role = "Sandblaster",
Phone = "555-5678",
Email = "janesmith@example.com",
IsActive = true,
Notes = ""
});
csv.NextRecord();
writer.Flush();
return memoryStream.ToArray();
}
/// <summary>
/// Imports shop workers from a CSV stream using an upsert strategy keyed on worker Name.
/// Like vendor import, this is intentionally an upsert rather than insert-only so that a
/// company can re-import their HR list to update phone/email/role details without worrying
/// about creating duplicates. Role is parsed case-insensitively with spaces stripped so that
/// "General Labor" and "GeneralLabor" are both accepted; an unrecognised role falls back to
/// GeneralLabor with a warning rather than failing the row.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own newly inserted worker records.</param>
public async Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<ShopWorkerImportDto>().ToList();
result.TotalRows = records.Count;
_logger.LogInformation("Starting import of {Count} shop workers for company {CompanyId}", records.Count, companyId);
// Load existing workers for upsert matching by name
var existingWorkers = await _unitOfWork.ShopWorkers.GetAllAsync();
var workerDict = existingWorkers
.Where(w => !string.IsNullOrEmpty(w.Name))
.GroupBy(w => w.Name.Trim().ToUpperInvariant())
.ToDictionary(g => g.Key, g => g.First());
foreach (var record in records)
{
rowNumber++;
try
{
if (string.IsNullOrWhiteSpace(record.Name))
{
result.Errors.Add($"Row {rowNumber}: Name is required.");
result.ErrorCount++;
continue;
}
// Parse role
ShopWorkerRole role = ShopWorkerRole.GeneralLabor;
if (!string.IsNullOrEmpty(record.Role))
{
if (!Enum.TryParse<ShopWorkerRole>(record.Role.Replace(" ", ""), true, out role))
{
result.Warnings.Add($"Row {rowNumber}: Role '{record.Role}' not recognized. Valid values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance. Using 'GeneralLabor'.");
role = ShopWorkerRole.GeneralLabor;
}
}
var key = record.Name.Trim().ToUpperInvariant();
var now = DateTime.UtcNow;
if (workerDict.TryGetValue(key, out var existing))
{
// Update
existing.Role = role;
existing.Phone = record.Phone ?? existing.Phone;
existing.Email = record.Email ?? existing.Email;
if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
existing.Notes = record.Notes ?? existing.Notes;
existing.UpdatedAt = now;
await _unitOfWork.CompleteAsync();
result.Warnings.Add($"Row {rowNumber}: Updated existing shop worker '{record.Name}'.");
result.SuccessCount++;
}
else
{
var worker = new Core.Entities.ShopWorker
{
CompanyId = companyId,
Name = record.Name.Trim(),
Role = role,
Phone = record.Phone,
Email = record.Email,
IsActive = record.IsActive ?? true,
Notes = record.Notes,
CreatedAt = now,
UpdatedAt = now
};
await _unitOfWork.ShopWorkers.AddAsync(worker);
await _unitOfWork.CompleteAsync();
result.SuccessCount++;
}
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
result.ErrorCount++;
_logger.LogError(ex, "Error saving shop worker at row {RowNumber}", rowNumber);
}
}
_logger.LogInformation("Shop worker import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
result.Success = result.SuccessCount > 0;
}
catch (Exception ex)
{
result.Errors.Add($"Fatal error: {ex.Message}");
result.Success = false;
_logger.LogError(ex, "Fatal error importing shop workers");
}
return result;
}
#endregion
#region Prep Service Import
/// <summary>