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 <noreply@anthropic.com>
This commit is contained in:
@@ -389,7 +389,7 @@ public class CompleteJobDto
|
|||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public decimal? ActualTimeSpentHours { get; set; }
|
public decimal? ActualTimeSpentHours { get; set; }
|
||||||
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
|
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
|
||||||
public bool SendEmailToCustomer { get; set; } = false;
|
public bool SendEmailToCustomer { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +400,10 @@ public class SendJobSmsRequest
|
|||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO for tracking actual powder usage per coat
|
// DTO for tracking actual powder usage per inventory item (color) for the whole job
|
||||||
public class JobItemCoatUsageDto
|
public class JobPowderUsageDto
|
||||||
{
|
{
|
||||||
public int JobItemCoatId { get; set; }
|
public int InventoryItemId { get; set; }
|
||||||
public decimal? ActualPowderUsedLbs { get; set; }
|
public decimal? ActualPowderUsedLbs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2655,41 +2655,54 @@ public class JobsController : Controller
|
|||||||
.GroupBy(t => t.InventoryItemId)
|
.GroupBy(t => t.InventoryItemId)
|
||||||
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
||||||
|
|
||||||
// Update actual powder usage for each coat
|
// Process powder usage submitted per inventory item (color) for the whole job.
|
||||||
foreach (var coatUsage in dto.CoatUsages)
|
// 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(
|
// Load all coats for the job with their inventory items
|
||||||
coatUsage.JobItemCoatId,
|
var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
|
||||||
false,
|
jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
|
||||||
jic => jic.InventoryItem);
|
false, jic => jic.InventoryItem, jic => jic.JobItem))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (jobItemCoat != null)
|
foreach (var powderUsage in dto.PowderUsages)
|
||||||
{
|
{
|
||||||
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs;
|
if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
|
||||||
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat);
|
continue;
|
||||||
|
|
||||||
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used",
|
var invItemId = powderUsage.InventoryItemId;
|
||||||
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs);
|
var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
|
||||||
|
|
||||||
// Deduct powder from inventory if using stock powder
|
// Distribute across coats using this powder proportionally by estimated lbs
|
||||||
if (jobItemCoat.InventoryItemId.HasValue &&
|
var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
|
||||||
coatUsage.ActualPowderUsedLbs.HasValue &&
|
if (coatsForPowder.Any())
|
||||||
coatUsage.ActualPowderUsedLbs.Value > 0)
|
|
||||||
{
|
{
|
||||||
var invItemId = jobItemCoat.InventoryItemId.Value;
|
var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
|
||||||
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
|
foreach (var coat in coatsForPowder)
|
||||||
|
{
|
||||||
|
var share = totalEstimated > 0
|
||||||
|
? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
|
||||||
|
: totalActualLbs / coatsForPowder.Count;
|
||||||
|
coat.ActualPowderUsedLbs = Math.Round(share, 4);
|
||||||
|
await _unitOfWork.JobItemCoats.UpdateAsync(coat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply available pre-logged credit so we don't double-deduct
|
// Single inventory deduction for the whole powder, net of pre-logged credit
|
||||||
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
|
||||||
var deductNow = Math.Max(0m, actualLbs - credit);
|
var deductNow = Math.Max(0m, totalActualLbs - credit);
|
||||||
// Consume credit (other coats sharing the same powder get whatever remains)
|
preLoggedCredit[invItemId] = 0m;
|
||||||
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
|
|
||||||
|
|
||||||
if (deductNow > 0)
|
if (deductNow > 0)
|
||||||
{
|
{
|
||||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
||||||
if (inventoryItem != null)
|
if (inventoryItem != null)
|
||||||
{
|
{
|
||||||
|
inventoryItem.QuantityOnHand -= deductNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||||
|
|
||||||
var transaction = new InventoryTransaction
|
var transaction = new InventoryTransaction
|
||||||
{
|
{
|
||||||
InventoryItemId = inventoryItem.Id,
|
InventoryItemId = inventoryItem.Id,
|
||||||
@@ -2700,16 +2713,12 @@ public class JobsController : Controller
|
|||||||
TransactionDate = DateTime.UtcNow,
|
TransactionDate = DateTime.UtcNow,
|
||||||
JobId = job.Id,
|
JobId = job.Id,
|
||||||
Reference = job.JobNumber,
|
Reference = job.JobNumber,
|
||||||
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
|
Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}",
|
||||||
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
|
BalanceAfter = inventoryItem.QuantityOnHand,
|
||||||
CompanyId = job.CompanyId
|
CompanyId = job.CompanyId
|
||||||
};
|
};
|
||||||
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
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)
|
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
||||||
{
|
{
|
||||||
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
||||||
@@ -2722,13 +2731,6 @@ public class JobsController : Controller
|
|||||||
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
|
|
||||||
coatUsage.JobItemCoatId, actualLbs, invItemId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,21 @@
|
|||||||
@{
|
@{
|
||||||
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
|
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
|
||||||
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
|
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
|
||||||
// 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<PowderCoating.Application.DTOs.Job.JobItemDto>())
|
||||||
|
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
|
||||||
|
.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();
|
||||||
}
|
}
|
||||||
<div class="modal fade" id="completeJobModal" tabindex="-1">
|
<div class="modal fade" id="completeJobModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
@@ -27,102 +40,59 @@
|
|||||||
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
|
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (Model.Items != null && Model.Items.Any())
|
@if (powderGroups.Any())
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h6 class="fw-semibold mb-3">
|
<h6 class="fw-semibold mb-1">
|
||||||
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
|
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
|
||||||
</h6>
|
</h6>
|
||||||
|
<p class="text-muted small mb-3">Enter total lbs used per powder color for the entire job.</p>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover">
|
<table class="table table-sm table-hover">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Item</th>
|
<th>Color / Powder</th>
|
||||||
<th>Coat</th>
|
|
||||||
<th>Color</th>
|
|
||||||
<th class="text-end">Estimated (lbs)</th>
|
<th class="text-end">Estimated (lbs)</th>
|
||||||
<th>Actual (lbs)</th>
|
<th style="width:150px">Actual Used (lbs)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@{
|
@for (int i = 0; i < powderGroups.Count; i++)
|
||||||
var coatIndex = 0;
|
|
||||||
}
|
|
||||||
@foreach (var item in Model.Items)
|
|
||||||
{
|
|
||||||
if (item.Coats != null && item.Coats.Any())
|
|
||||||
{
|
|
||||||
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
|
||||||
{
|
{
|
||||||
|
var pg = powderGroups[i];
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<small>@item.Description</small>
|
<span class="fw-semibold">@pg.ColorName</span>
|
||||||
@if (item.Quantity > 1)
|
@if (!string.IsNullOrEmpty(pg.ColorCode))
|
||||||
{
|
{
|
||||||
<span class="badge bg-secondary ms-1">×@item.Quantity</span>
|
<small class="text-muted ms-1">(@pg.ColorCode)</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge bg-secondary">@coat.CoatName</span></td>
|
<td class="text-end text-muted small align-middle">
|
||||||
<td>
|
@pg.TotalEstimatedLbs.ToString("0.##")
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorName))
|
|
||||||
{
|
|
||||||
<small>
|
|
||||||
@coat.ColorName
|
|
||||||
@if (!string.IsNullOrEmpty(coat.ColorCode))
|
|
||||||
{
|
|
||||||
<span class="text-muted">(@coat.ColorCode)</span>
|
|
||||||
}
|
|
||||||
</small>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@{
|
<input type="hidden" name="PowderUsages[@i].InventoryItemId" value="@pg.InventoryItemId" />
|
||||||
decimal preFilledLbs = 0m;
|
|
||||||
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
|
|
||||||
{
|
|
||||||
preFilledLbs = availCredit;
|
|
||||||
remainingCredit[coat.InventoryItemId.Value] = 0m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
|
|
||||||
<input type="number"
|
<input type="number"
|
||||||
class="form-control form-control-sm"
|
class="form-control form-control-sm"
|
||||||
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
|
name="PowderUsages[@i].ActualPowderUsedLbs"
|
||||||
step="0.01" min="0" placeholder="0.00"
|
step="0.01" min="0" placeholder="0.00"
|
||||||
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
|
value="@(pg.PreLogged > 0 ? pg.PreLogged.ToString("0.##") : "")">
|
||||||
style="max-width: 120px;">
|
@if (pg.PreLogged > 0)
|
||||||
@if (preFilledLbs > 0)
|
|
||||||
{
|
{
|
||||||
<small class="text-success d-block mt-1">
|
<small class="text-success d-block mt-1">
|
||||||
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
|
<i class="bi bi-check-circle me-1"></i>@pg.PreLogged.ToString("0.##") lbs already logged
|
||||||
</small>
|
</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
coatIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<tr class="table-secondary">
|
|
||||||
<td colspan="5">
|
|
||||||
<small class="text-muted fst-italic">
|
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
@item.Description — No coat information available (legacy job item)
|
|
||||||
</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-info alert-permanent mb-0">
|
<div class="alert alert-info alert-permanent mb-0">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
|
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user