Add pricing breakdown and powder pre-fill to Job Details; surface voided invoice history
- Job Details: collapsible internal pricing breakdown card mirrors quote details breakdown (items subtotal, shop supplies, discount, rush fee, tax, total) - Job Details: voided invoice history section shows previous invoices instead of hiding them - Complete Job modal: pre-fills powder usage from QR-scanned / manually logged entries so staff don't double-log; consumes pre-logged credit per InventoryItemId before deducting net delta - JobProfile: map ShopSuppliesAmount, ShopSuppliesPercent, IsRushJob, DiscountType/Value/Reason so the pricing breakdown has the data it needs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -395,11 +395,17 @@ public class JobsController : Controller
|
||||
ViewBag.UseMetric = useMetric;
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
// Check if an invoice exists for this job
|
||||
var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value);
|
||||
// Separate active invoice from voided history for this job
|
||||
var allJobInvoices = await _unitOfWork.Invoices.FindAsync(i => i.JobId == id.Value);
|
||||
var jobInvoice = allJobInvoices.FirstOrDefault(i => i.Status != Core.Enums.InvoiceStatus.Voided);
|
||||
var voidedInvoices = allJobInvoices
|
||||
.Where(i => i.Status == Core.Enums.InvoiceStatus.Voided)
|
||||
.Select(i => new { i.Id, i.InvoiceNumber })
|
||||
.ToList<dynamic>();
|
||||
ViewBag.JobInvoiceId = jobInvoice?.Id;
|
||||
ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber;
|
||||
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
|
||||
ViewBag.JobVoidedInvoices = voidedInvoices;
|
||||
|
||||
// Workers dropdown for inline assignment
|
||||
await PopulateWorkersDropdown();
|
||||
@@ -410,11 +416,79 @@ public class JobsController : Controller
|
||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||
.ToListAsync();
|
||||
ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
|
||||
ViewBag.CurrentUserId = _userManager.GetUserId(User);
|
||||
|
||||
// Populate Edit Items wizard data (inline modal on Details page)
|
||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
||||
|
||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
||||
var breakdownItems = job.JobItems
|
||||
.Where(ji => !ji.IsDeleted)
|
||||
.Select(ji => new CreateQuoteItemDto
|
||||
{
|
||||
Description = ji.Description,
|
||||
Quantity = ji.Quantity,
|
||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
||||
EstimatedMinutes = ji.EstimatedMinutes,
|
||||
CatalogItemId = ji.CatalogItemId,
|
||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||
IsLaborItem = ji.IsLaborItem,
|
||||
IsSalesItem = ji.IsSalesItem,
|
||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||
PowderCostOverride = ji.PowderCostOverride,
|
||||
IncludePrepCost = ji.IncludePrepCost,
|
||||
Complexity = ji.Complexity,
|
||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||
{
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder
|
||||
}).ToList(),
|
||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
if (breakdownItems.Any())
|
||||
{
|
||||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
breakdownItems, job.CompanyId, job.CustomerId,
|
||||
wizardCosts?.TaxPercent ?? 0m,
|
||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||
job.OvenCostId, 1, null);
|
||||
|
||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = pr.MaterialCosts,
|
||||
LaborCosts = pr.LaborCosts,
|
||||
EquipmentCosts = pr.EquipmentCosts,
|
||||
ItemsSubtotal = pr.ItemsSubtotal,
|
||||
OvenBatchCost = pr.OvenBatchCost,
|
||||
OvenBatches = pr.OvenBatches,
|
||||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||
OverheadCosts = pr.OverheadCosts,
|
||||
OverheadPercent = pr.OverheadPercent,
|
||||
ProfitMargin = pr.ProfitMargin,
|
||||
ProfitPercent = pr.ProfitPercent,
|
||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||
DiscountAmount = pr.DiscountAmount,
|
||||
DiscountPercent = pr.DiscountPercent,
|
||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||
RushFee = pr.RushFee,
|
||||
TaxAmount = pr.TaxAmount,
|
||||
TaxPercent = pr.TaxPercent,
|
||||
Total = pr.Total
|
||||
};
|
||||
}
|
||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||
ViewBag.ComplexityModeratePercent = wizardCosts?.ComplexityModeratePercent ?? 5m;
|
||||
ViewBag.ComplexityComplexPercent = wizardCosts?.ComplexityComplexPercent ?? 15m;
|
||||
@@ -465,9 +539,15 @@ public class JobsController : Controller
|
||||
ViewBag.Deposits = jobDeposits;
|
||||
|
||||
// Materials used on this job via QR scan or manual log
|
||||
ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync(
|
||||
var allJobTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
|
||||
t => t.JobId == id.Value, false, t => t.InventoryItem))
|
||||
.OrderByDescending(t => t.TransactionDate).ToList();
|
||||
ViewBag.MaterialsUsed = allJobTransactions;
|
||||
|
||||
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
||||
ViewBag.PreLoggedPowder = allJobTransactions
|
||||
.GroupBy(t => t.InventoryItemId)
|
||||
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
|
||||
|
||||
// Job photo subscription limits — used to disable the upload button in the view
|
||||
var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
@@ -1153,7 +1233,9 @@ public class JobsController : Controller
|
||||
createCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
@@ -1667,7 +1749,9 @@ public class JobsController : Controller
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
editCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
}
|
||||
|
||||
// Save change history records
|
||||
@@ -2702,6 +2786,15 @@ public class JobsController : Controller
|
||||
job.JobStatusId = completedStatus.Id;
|
||||
}
|
||||
|
||||
// Build a mutable credit map: lbs already deducted from inventory for this job
|
||||
// (via QR scan / LogUsage before completion). We consume this credit per InventoryItemId
|
||||
// so we only deduct the net delta and never double-subtract.
|
||||
var preLoggedTransactions = await _unitOfWork.InventoryTransactions.FindAsync(
|
||||
t => t.JobId == dto.JobId);
|
||||
var preLoggedCredit = preLoggedTransactions
|
||||
.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)
|
||||
{
|
||||
@@ -2723,37 +2816,49 @@ public class JobsController : Controller
|
||||
coatUsage.ActualPowderUsedLbs.HasValue &&
|
||||
coatUsage.ActualPowderUsedLbs.Value > 0)
|
||||
{
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(jobItemCoat.InventoryItemId.Value);
|
||||
if (inventoryItem != null)
|
||||
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)
|
||||
{
|
||||
// Create inventory transaction to track the usage
|
||||
var transaction = new InventoryTransaction
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
|
||||
if (inventoryItem != null)
|
||||
{
|
||||
InventoryItemId = inventoryItem.Id,
|
||||
TransactionType = InventoryTransactionType.JobUsage,
|
||||
Quantity = -coatUsage.ActualPowderUsedLbs.Value, // Negative for deduction
|
||||
UnitCost = inventoryItem.UnitCost,
|
||||
TotalCost = inventoryItem.UnitCost * coatUsage.ActualPowderUsedLbs.Value,
|
||||
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 - coatUsage.ActualPowderUsedLbs.Value,
|
||||
CompanyId = job.CompanyId
|
||||
};
|
||||
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);
|
||||
|
||||
// Update inventory item quantity
|
||||
inventoryItem.QuantityOnHand -= coatUsage.ActualPowderUsedLbs.Value;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
inventoryItem.QuantityOnHand -= deductNow;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deducted {Lbs} lbs of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
|
||||
coatUsage.ActualPowderUsedLbs.Value,
|
||||
inventoryItem.Name,
|
||||
job.JobNumber,
|
||||
inventoryItem.QuantityOnHand);
|
||||
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
|
||||
coatUsage.JobItemCoatId, actualLbs, invItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3113,7 +3218,9 @@ public class JobsController : Controller
|
||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||
model.TaxPercent, "None", 0, false, null, 1, null);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
job.UpdatedBy = currentUser.UserName;
|
||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||
@@ -3184,11 +3291,15 @@ public class JobsController : Controller
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.FinalPrice = totals.Total;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
}
|
||||
else
|
||||
{
|
||||
job.FinalPrice = 0;
|
||||
job.FinalPrice = 0;
|
||||
job.ShopSuppliesAmount = 0;
|
||||
job.ShopSuppliesPercent = 0;
|
||||
}
|
||||
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -3211,18 +3322,21 @@ public class JobsController : Controller
|
||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
|
||||
ViewBag.InventoryCoatings = inventory
|
||||
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
||||
.OrderBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||
.Select(i => new
|
||||
{
|
||||
value = i.Id.ToString(),
|
||||
text = $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
|
||||
text = i.IsIncoming
|
||||
? $"[INCOMING] {i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)"
|
||||
: $"{i.InventoryCategory!.DisplayName} - {i.Manufacturer ?? "Generic"} - {i.ColorName ?? i.Name} - {i.ColorCode ?? "N/A"} ({i.UnitCost:C4}/unit)",
|
||||
coverage = i.CoverageSqFtPerLb ?? 30m,
|
||||
efficiency = i.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||
categoryName = i.InventoryCategory.DisplayName,
|
||||
categoryName = i.InventoryCategory!.DisplayName,
|
||||
costPerLb = i.UnitCost,
|
||||
colorName = i.ColorName ?? i.Name,
|
||||
colorCode = i.ColorCode ?? ""
|
||||
colorCode = i.ColorCode ?? "",
|
||||
isIncoming = i.IsIncoming
|
||||
}).ToList();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
|
||||
@@ -3896,9 +4010,11 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
// Update pricing from quote and advance the snapshot so banner clears
|
||||
job.QuotedPrice = quote.Total;
|
||||
job.FinalPrice = quote.Total;
|
||||
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
|
||||
job.QuotedPrice = quote.Total;
|
||||
job.FinalPrice = quote.Total;
|
||||
job.ShopSuppliesAmount = quote.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = quote.ShopSuppliesPercent;
|
||||
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
@@ -3933,7 +4049,8 @@ 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 defaultOvenCycleHours = (opCosts?.DefaultOvenCycleMinutes ?? 45) / 60.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);
|
||||
@@ -3942,6 +4059,7 @@ public class JobsController : Controller
|
||||
// 1. Powder / Material cost
|
||||
decimal powderCost = 0m;
|
||||
var powderLines = new List<object>();
|
||||
bool hasCoatsWithRateButNoQty = false;
|
||||
foreach (var item in job.JobItems)
|
||||
{
|
||||
foreach (var coat in item.Coats)
|
||||
@@ -3960,6 +4078,11 @@ public class JobsController : Controller
|
||||
isActual = coat.ActualPowderUsedLbs.HasValue
|
||||
});
|
||||
}
|
||||
else if (costPerLb > 0 && lbs == 0)
|
||||
{
|
||||
// Coat has a price/lb but no quantity — surface area missing on the item
|
||||
hasCoatsWithRateButNoQty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4040,7 +4163,7 @@ public class JobsController : Controller
|
||||
laborCost = Math.Round(laborCost, 2),
|
||||
ovenCost = Math.Round(ovenCost, 2),
|
||||
ovenLabel,
|
||||
ovenCycleMinutes = opCosts?.DefaultOvenCycleMinutes ?? 45,
|
||||
ovenCycleMinutes = effectiveOvenMinutes,
|
||||
reworkCostTotal = Math.Round(reworkCostTotal, 2),
|
||||
reworkBilledToCustomer = Math.Round(reworkBilledToCustomer, 2),
|
||||
netReworkCost = Math.Round(netReworkCost, 2),
|
||||
@@ -4055,6 +4178,7 @@ public class JobsController : Controller
|
||||
powderLines,
|
||||
laborLines,
|
||||
hasPowderData = powderLines.Count > 0,
|
||||
hasPowderRateButNoQty = hasCoatsWithRateButNoQty && powderLines.Count == 0,
|
||||
hasLaborData = laborLines.Count > 0
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user