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
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
}
}
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
}
}
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
.OrderBy(s => s.CompanyName).ToListAsync();
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name).ToListAsync();
/// <summary>
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length);
}
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await FetchShopWorkersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var w = data[i];
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
return sb.ToString();
}
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await FetchShopWorkersAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary>
/// All users (active and inactive) are exported for completeness and compliance — mirrors
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
/// <summary>
/// Returns the subset of selected sheet names reordered into the canonical export sequence
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
/// Sheet names not in the canonical list are silently dropped.
/// </summary>
private static string[] OrderSheets(string[] sheets)
{
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
return order.Where(sheets.Contains).ToArray();
}
@@ -756,7 +756,6 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
var sb = new System.Text.StringBuilder();
@@ -783,8 +782,7 @@ public class CompanySettingsController : Controller
ShopCapabilityTier.Large => "high-volume",
_ => "small"
};
sb.AppendLine($"We are a {tierLabel} operation" +
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
sb.AppendLine($"We are a {tierLabel} operation.");
}
// Ovens
@@ -827,32 +825,6 @@ public class CompanySettingsController : Controller
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
}
// Worker roles
if (workers.Any())
{
var roles = workers
.Select(w => w.Role)
.Distinct()
.Select(r => r switch
{
ShopWorkerRole.Sandblaster => "sandblasting",
ShopWorkerRole.Coater => "powder coating",
ShopWorkerRole.Masker => "masking",
ShopWorkerRole.QualityControl => "quality control",
ShopWorkerRole.OvenOperator => "oven operation",
ShopWorkerRole.Supervisor => "supervision",
ShopWorkerRole.Maintenance => "equipment maintenance",
_ => "general labor"
})
.Distinct()
.ToList();
if (roles.Count > 1)
{
sb.AppendLine();
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
}
}
// Rates hint
if (costs != null && costs.StandardLaborRate > 0)
{
@@ -2719,79 +2691,6 @@ public class CompanySettingsController : Controller
// ── Role-Based Labor Rates ────────────────────────────────────────────────
/// <summary>
/// Returns the per-role hourly labor rates configured for the current company, keyed by
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
/// without special-casing an empty state. The global multi-tenant filter on
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetRoleCosts()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new List<object>());
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
return Json(result);
}
/// <summary>
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
/// <c>CompanyOperatingCosts</c>.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "No company found." });
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
foreach (var dto in rates)
{
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
if (dto.HourlyRate <= 0)
{
// Remove rate if cleared
if (record != null)
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
}
else if (record == null)
{
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
{
CompanyId = companyId.Value,
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
HourlyRate = dto.HourlyRate,
CreatedAt = DateTime.UtcNow
});
}
else
{
record.HourlyRate = dto.HourlyRate;
record.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
}
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving role costs");
return Json(new { success = false, message = "An error occurred saving role rates." });
}
}
// ─── Stripe Connect ───────────────────────────────────────────────────────
/// <summary>
@@ -3055,7 +2954,6 @@ public class CompanySettingsController : Controller
}
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
public record SaveRoleCostDto(int Role, decimal HourlyRate);
public record SaveOnlinePaymentSettingsDto(
OnlinePaymentSurchargeType SurchargeType,
decimal SurchargeValue,
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
/// checks that still reference the role system.
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
/// to satisfy policy checks that still reference the role system.
/// </summary>
// POST: CompanyUsers/Create
[HttpPost]
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
await _userManager.AddToRoleAsync(user, legacyRole);
// If Worker role, automatically create a ShopWorker record
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
{
var shopWorker = new ShopWorker
{
Name = user.FullName,
Email = user.Email,
Phone = user.PhoneNumber,
IsActive = true,
Notes = $"Auto-created from user account: {user.Email}",
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
CompanyId = companyId!.Value
};
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
_logger.LogInformation("User {Email} created successfully by {Admin}",
_logger.LogInformation("User {Email} created successfully by {Admin}",
user.Email, User.Identity?.Name);
TempData["Success"] = $"User '{user.FullName}' created successfully.";
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
Department = user.Department,
Position = user.Position,
LaborCostPerHour = user.LaborCostPerHour,
Phone = user.PhoneNumber,
IsActive = user.IsActive,
HireDate = user.HireDate,
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
/// Saves changes to an existing company user. Validates company isolation and role whitelist
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
/// for a company (which would lock out the tenant). When the role changes to Worker and no
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
/// already exists, its name, email, and active status are kept in sync. Email changes are
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
/// Identity's own normalisation logic runs correctly.
/// for a company (which would lock out the tenant). Email changes are applied via
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
/// normalisation logic runs correctly.
/// </summary>
// POST: CompanyUsers/Edit/id
[HttpPost]
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
user.CompanyRole = model.CompanyRole;
user.Department = model.Department;
user.Position = model.Position;
user.LaborCostPerHour = model.LaborCostPerHour;
user.PhoneNumber = model.Phone;
user.IsActive = model.IsActive;
user.HireDate = model.HireDate;
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
user.Id, oldEmail, model.Email, User.Identity?.Name);
}
// If role changed to Worker, ensure ShopWorker record exists
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
{
// Search by oldEmail so we find the record even when the email just changed
var lookupEmail = emailChanged ? oldEmail : user.Email;
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
if (!existingShopWorker.Any())
{
var shopWorker = new ShopWorker
{
Name = user.FullName,
Email = user.Email,
Phone = user.PhoneNumber,
IsActive = user.IsActive,
Notes = $"Auto-created from user account: {user.Email}",
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
CompanyId = user.CompanyId
};
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
else
{
// Update existing ShopWorker to ensure it's active
var shopWorker = existingShopWorker.First();
var shopWorkerDirty = false;
if (!shopWorker.IsActive && user.IsActive)
{
shopWorker.IsActive = true;
shopWorkerDirty = true;
_logger.LogInformation("ShopWorker record reactivated for user {Email}", user.Email);
}
if (emailChanged && shopWorker.Email == oldEmail)
{
shopWorker.Email = user.Email;
shopWorkerDirty = true;
}
shopWorker.Name = user.FullName;
shopWorker.Phone = user.PhoneNumber;
if (shopWorkerDirty)
await _unitOfWork.CompleteAsync();
}
}
_logger.LogInformation("User {Email} updated successfully by {Admin}",
_logger.LogInformation("User {Email} updated successfully by {Admin}",
user.Email, User.Identity?.Name);
TempData["Success"] = "User updated successfully.";
@@ -122,7 +122,6 @@ public class DataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
}
}
@@ -172,7 +171,6 @@ public class DataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
}
}
@@ -441,38 +439,6 @@ public class DataExportController : Controller
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
/// raw enum name is used here so the export value is round-trip parseable.
/// </summary>
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var w = data[i];
ws.Cells[r, 1].Value = w.Id;
ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString();
ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email;
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
@@ -687,21 +653,6 @@ public class DataExportController : Controller
return sb.ToString();
}
/// <summary>
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary>
/// Builds the users CSV string for the specified company, ordered by last name.
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
@@ -769,7 +720,7 @@ public class DataExportController : Controller
/// <param name="sheets">Raw sheet names from the form POST.</param>
private static string[] OrderSheets(string[] sheets)
{
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
return order.Where(sheets.Contains).ToArray();
}
@@ -175,7 +175,6 @@ public class DataPurgeController : Controller
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
return stats;
}
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
"Equipment" => await QueryCount(_db.Equipment, cutoff),
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
"Vendors" => await QueryCount(_db.Vendors, cutoff),
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
_ => (0, null)
};
}
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "ShopWorkers":
count = await _db.ShopWorkers.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
default:
return 0;
}
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
return View();
}
/// <summary>
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
/// </summary>
public IActionResult ShopWorkers()
{
return View();
}
/// <summary>
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
/// </summary>
@@ -3368,8 +3368,7 @@ public class JobsController : Controller
public async Task<IActionResult> GetTimeEntries(int jobId)
{
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
e => e.JobId == jobId, false,
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
e => e.JobId == jobId, false);
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
return Json(dtos);
}
@@ -3823,15 +3822,24 @@ public class JobsController : Controller
// Operating costs for fallback labor rate and oven rate
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
// Role cost rates map: role → hourly rate
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate);
// Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m);
var companyUsers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.LaborCostPerHour != null)
.Select(u => new { u.Id, u.LaborCostPerHour })
.ToListAsync();
var userLaborCostMap = companyUsers.ToDictionary(u => u.Id, u => u.LaborCostPerHour!.Value);
// 1. Powder / Material cost
// Priority: PowderUsageLog actuals (sum per coat) > coat.ActualPowderUsedLbs > coat.PowderToOrder (estimated)
var usageLogs = await _unitOfWork.PowderUsageLogs.FindAsync(u => u.JobId == jobId);
var actualByCoat = usageLogs
.GroupBy(u => u.JobItemCoatId)
.ToDictionary(g => g.Key, g => g.Sum(u => u.ActualLbsUsed));
decimal powderCost = 0m;
var powderLines = new List<object>();
bool hasCoatsWithRateButNoQty = false;
@@ -3839,7 +3847,19 @@ public class JobsController : Controller
{
foreach (var coat in item.Coats)
{
var lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
bool isActual;
decimal lbs;
if (actualByCoat.TryGetValue(coat.Id, out var loggedLbs) && loggedLbs > 0)
{
lbs = loggedLbs;
isActual = true;
}
else
{
lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
isActual = coat.ActualPowderUsedLbs.HasValue;
}
var costPerLb = coat.PowderCostPerLb ?? 0m;
var lineCost = lbs * costPerLb;
powderCost += lineCost;
@@ -3850,7 +3870,7 @@ public class JobsController : Controller
lbs = Math.Round(lbs, 3),
costPerLb = Math.Round(costPerLb, 4),
total = Math.Round(lineCost, 2),
isActual = coat.ActualPowderUsedLbs.HasValue
isActual
});
}
else if (costPerLb > 0 && lbs == 0)
@@ -3862,20 +3882,23 @@ public class JobsController : Controller
}
// 2. Labor cost
// Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
decimal laborCost = 0m;
var laborLines = new List<object>();
foreach (var entry in job.TimeEntries)
{
var rate = entry.Worker != null && roleCostMap.TryGetValue(entry.Worker.Role, out var r) ? r : fallbackLaborRate;
bool usingPerUser = entry.UserId != null && userLaborCostMap.TryGetValue(entry.UserId, out _);
var rate = usingPerUser
? userLaborCostMap[entry.UserId!]
: companyLaborCostRate;
var lineCost = entry.HoursWorked * rate;
laborCost += lineCost;
laborLines.Add(new {
worker = entry.Worker?.Name ?? "Unknown",
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
worker = entry.UserDisplayName ?? "Unknown",
hours = entry.HoursWorked,
rate = Math.Round(rate, 2),
total = Math.Round(lineCost, 2),
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role),
usingFallback = !usingPerUser,
stage = entry.Stage,
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
});
@@ -3949,7 +3972,7 @@ public class JobsController : Controller
grossMargin,
quotedMargin,
quotedPrice = Math.Round(job.QuotedPrice, 2),
fallbackLaborRate,
companyLaborCostRate,
powderLines,
laborLines,
hasPowderData = powderLines.Count > 0,
@@ -911,7 +911,7 @@ public class ToolsController : Controller
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
/// normally enforce for other entity types.
/// </summary>
// GET: Tools/GetShopWorkers - For randomizer wheel
// GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
[HttpGet]
public async Task<IActionResult> GetShopWorkers()
{