From 0df2353d4f28917a3f8acae443f41f953554fea6 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 16 May 2026 12:30:30 -0400 Subject: [PATCH] Complete Job modal: ask powder usage once per color, not per item/coat The modal was showing one row per coat per item, so a job with 5 items each with 2 coats of the same powder produced 10 identical input rows. Now groups by unique InventoryItemId and shows one row per powder color for the whole job. The controller distributes the entered total across coats proportionally by their estimated PowderToOrder so per-coat reporting data is preserved. A single inventory transaction is created per powder (net of any pre-logged scan credit). Co-Authored-By: Claude Sonnet 4.6 --- .../DTOs/Job/JobDtos.cs | 8 +- .../Controllers/JobsController.cs | 126 +++++++++--------- .../Views/Jobs/_CompleteJobModal.cshtml | 126 +++++++----------- 3 files changed, 116 insertions(+), 144 deletions(-) diff --git a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs index 8ed6ac6..233649c 100644 --- a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs +++ b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs @@ -389,7 +389,7 @@ public class CompleteJobDto { public int JobId { get; set; } public decimal? ActualTimeSpentHours { get; set; } - public List CoatUsages { get; set; } = new(); + public List PowderUsages { get; set; } = new(); public bool SendEmailToCustomer { get; set; } = false; } @@ -400,10 +400,10 @@ public class SendJobSmsRequest public string Message { get; set; } = string.Empty; } -// DTO for tracking actual powder usage per coat -public class JobItemCoatUsageDto +// DTO for tracking actual powder usage per inventory item (color) for the whole job +public class JobPowderUsageDto { - public int JobItemCoatId { get; set; } + public int InventoryItemId { get; set; } public decimal? ActualPowderUsedLbs { get; set; } } diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index d1bcaf3..eaa39e4 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -2655,78 +2655,80 @@ public class JobsController : Controller .GroupBy(t => t.InventoryItemId) .ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity))); - // Update actual powder usage for each coat - foreach (var coatUsage in dto.CoatUsages) + // Process powder usage submitted per inventory item (color) for the whole job. + // Distribute entered lbs across coats sharing that InventoryItemId proportionally + // by estimated PowderToOrder so per-coat reporting stays meaningful. + // One inventory deduction per powder (net of pre-logged credit). + if (dto.PowderUsages.Any()) { - var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync( - coatUsage.JobItemCoatId, - false, - jic => jic.InventoryItem); + // Load all coats for the job with their inventory items + var allCoats = (await _unitOfWork.JobItemCoats.FindAsync( + jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId, + false, jic => jic.InventoryItem, jic => jic.JobItem)) + .ToList(); - if (jobItemCoat != null) + foreach (var powderUsage in dto.PowderUsages) { - jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs; - await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat); + if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0) + continue; - _logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used", - coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs); + var invItemId = powderUsage.InventoryItemId; + var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value; - // Deduct powder from inventory if using stock powder - if (jobItemCoat.InventoryItemId.HasValue && - coatUsage.ActualPowderUsedLbs.HasValue && - coatUsage.ActualPowderUsedLbs.Value > 0) + // Distribute across coats using this powder proportionally by estimated lbs + var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList(); + if (coatsForPowder.Any()) { - var invItemId = jobItemCoat.InventoryItemId.Value; - var actualLbs = coatUsage.ActualPowderUsedLbs.Value; - - // Apply available pre-logged credit so we don't double-deduct - var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m); - var deductNow = Math.Max(0m, actualLbs - credit); - // Consume credit (other coats sharing the same powder get whatever remains) - preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs); - - if (deductNow > 0) + var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m); + foreach (var coat in coatsForPowder) { - var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId); - if (inventoryItem != null) - { - var transaction = new InventoryTransaction - { - InventoryItemId = inventoryItem.Id, - TransactionType = InventoryTransactionType.JobUsage, - Quantity = -deductNow, - UnitCost = inventoryItem.UnitCost, - TotalCost = inventoryItem.UnitCost * deductNow, - TransactionDate = DateTime.UtcNow, - JobId = job.Id, - Reference = job.JobNumber, - Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}", - BalanceAfter = inventoryItem.QuantityOnHand - deductNow, - CompanyId = job.CompanyId - }; - - await _unitOfWork.InventoryTransactions.AddAsync(transaction); - inventoryItem.QuantityOnHand -= deductNow; - await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); - - // GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured - if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue) - { - var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost); - await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost); - await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost); - } - - _logger.LogInformation( - "Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}", - deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand); - } + var share = totalEstimated > 0 + ? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated) + : totalActualLbs / coatsForPowder.Count; + coat.ActualPowderUsedLbs = Math.Round(share, 4); + await _unitOfWork.JobItemCoats.UpdateAsync(coat); } - else + } + + // Single inventory deduction for the whole powder, net of pre-logged credit + var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m); + var deductNow = Math.Max(0m, totalActualLbs - credit); + preLoggedCredit[invItemId] = 0m; + + if (deductNow > 0) + { + var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId); + if (inventoryItem != null) { + inventoryItem.QuantityOnHand -= deductNow; + await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem); + + var transaction = new InventoryTransaction + { + InventoryItemId = inventoryItem.Id, + TransactionType = InventoryTransactionType.JobUsage, + Quantity = -deductNow, + UnitCost = inventoryItem.UnitCost, + TotalCost = inventoryItem.UnitCost * deductNow, + TransactionDate = DateTime.UtcNow, + JobId = job.Id, + Reference = job.JobNumber, + Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}", + BalanceAfter = inventoryItem.QuantityOnHand, + CompanyId = job.CompanyId + }; + await _unitOfWork.InventoryTransactions.AddAsync(transaction); + + if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue) + { + var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost); + await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost); + await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost); + } + _logger.LogInformation( - "Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}", - coatUsage.JobItemCoatId, actualLbs, invItemId); + "Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}", + deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand); } } } diff --git a/src/PowderCoating.Web/Views/Jobs/_CompleteJobModal.cshtml b/src/PowderCoating.Web/Views/Jobs/_CompleteJobModal.cshtml index 30b75af..9a41d9b 100644 --- a/src/PowderCoating.Web/Views/Jobs/_CompleteJobModal.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/_CompleteJobModal.cshtml @@ -2,8 +2,21 @@ @{ var emailDefault = ViewBag.EmailDefaultOnComplete == true; var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary ?? new Dictionary(); - // Track remaining credit per InventoryItemId as we allocate it across coat rows - var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value); + + // Group all coats by inventory item so we ask once per powder color, not once per item/coat + var powderGroups = (Model.Items ?? new List()) + .SelectMany(i => i.Coats ?? new List()) + .Where(c => c.InventoryItemId.HasValue) + .GroupBy(c => c.InventoryItemId!.Value) + .Select(g => new { + InventoryItemId = g.Key, + ColorName = g.First().ColorName, + ColorCode = g.First().ColorCode, + TotalEstimatedLbs = g.Sum(c => c.PowderToOrder ?? 0m), + PreLogged = preLoggedPowder.GetValueOrDefault(g.Key, 0m) + }) + .OrderBy(g => g.ColorName) + .ToList(); }