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/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 43c38ea..c44f985 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -340,13 +340,14 @@ public class InvoicesController : Controller var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted); + var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30"; var dto = new CreateInvoiceDto { PreparedById = currentUser.Id, InvoiceDate = DateTime.Today, - DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30), + DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today), TaxPercent = costs?.TaxPercent ?? 0, - Terms = prefs?.DefaultPaymentTerms ?? "Net 30" + Terms = defaultTerms }; if (jobId.HasValue) @@ -378,6 +379,13 @@ public class InvoicesController : Controller var defaultRevenueAccount = await _unitOfWork.Accounts .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); + // Deserialize the job's pricing snapshot up front — it is authoritative for discount, + // tax, and fees for both quote-based and direct jobs, because it is recalculated on + // every save and reflects any edits made after quote conversion. + QuotePricingBreakdownDto? jobBreakdown = null; + if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) + jobBreakdown = JsonSerializer.Deserialize(job.PricingBreakdownJson); + // If the job came from a quote, load it so we can use the agreed pricing. // The quote stores the approved total including oven batch cost and shop supplies — // these are quote-level charges that are NOT stored on individual job items. @@ -461,17 +469,15 @@ public class InvoicesController : Controller }); } - // Use the quote's agreed tax rate and discount — not current company defaults - dto.TaxPercent = sourceQuote.TaxPercent; + // Use the quote's agreed tax rate and discount — these represent the customer-approved + // price and must not be recomputed from the job's current state. + dto.TaxPercent = sourceQuote.TaxPercent; dto.DiscountAmount = sourceQuote.DiscountAmount; } else if (hadJobItems) { // Direct job — no source quote. Read all charges from the pricing snapshot so the // invoice always matches the total shown on the job's Pricing Summary card. - QuotePricingBreakdownDto? jobBreakdown = null; - if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) - jobBreakdown = JsonSerializer.Deserialize(job.PricingBreakdownJson); if (job.OvenBatchCost > 0.01m) { @@ -529,6 +535,22 @@ public class InvoicesController : Controller RevenueAccountId = defaultRevenueAccount?.Id }); } + + dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0; + } + + // Inherit payment terms from the source quote or the customer — more specific than + // the company-wide default set in the outer DTO. Quote terms take priority because + // they represent the agreed price; customer terms are next best for direct jobs. + var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms; + if (!string.IsNullOrWhiteSpace(inheritedTerms)) + { + dto.Terms = inheritedTerms; + dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today) + ?? dto.DueDate; + var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms); + dto.EarlyPaymentDiscountPercent = discPct; + dto.EarlyPaymentDiscountDays = discDays; } // Override tax to 0 for tax-exempt customers, regardless of company default or quote rate diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 3f20fc3..eaa39e4 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -498,6 +498,13 @@ public class JobsController : Controller .OrderByDescending(t => t.TransactionDate).ToList(); ViewBag.MaterialsUsed = allJobTransactions; + // Inventory items for the manual log-material modal + var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync()) + .OrderBy(i => i.Name) + .Select(i => new { i.Id, i.Name, i.UnitOfMeasure, i.QuantityOnHand }) + .ToList(); + ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal); + // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill) ViewBag.PreLoggedPowder = allJobTransactions .GroupBy(t => t.InventoryItemId) @@ -2648,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); } } } @@ -4080,9 +4089,87 @@ public class JobsController : Controller _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); } + + /// + /// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage + /// flow in InventoryController but returns JSON so the modal can close and refresh inline. + /// Quantity is always the amount USED (caller converts from remaining if needed). + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task LogMaterial([FromBody] LogMaterialRequest req) + { + try + { + if (req.QuantityUsed <= 0) + return Json(new { success = false, message = "Quantity used must be greater than zero." }); + + var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId); + if (item == null) return Json(new { success = false, message = "Inventory item not found." }); + + var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId); + if (job == null) return Json(new { success = false, message = "Job not found." }); + + var txnType = req.TransactionType == "Waste" + ? InventoryTransactionType.Waste + : InventoryTransactionType.JobUsage; + + item.QuantityOnHand -= req.QuantityUsed; + item.UpdatedAt = DateTime.UtcNow; + await _unitOfWork.InventoryItems.UpdateAsync(item); + + var txn = new PowderCoating.Core.Entities.InventoryTransaction + { + InventoryItemId = item.Id, + TransactionType = txnType, + Quantity = -req.QuantityUsed, + UnitCost = item.UnitCost, + TotalCost = req.QuantityUsed * item.UnitCost, + TransactionDate = DateTime.UtcNow, + BalanceAfter = item.QuantityOnHand, + JobId = req.JobId, + Reference = $"Job {job.JobNumber}", + Notes = req.Notes?.Trim(), + CompanyId = item.CompanyId, + CreatedAt = DateTime.UtcNow + }; + await _unitOfWork.InventoryTransactions.AddAsync(txn); + await _unitOfWork.CompleteAsync(); + + // GL: DR COGS, CR Inventory Asset + if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue) + { + var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost); + await _accountBalanceService.DebitAsync(item.CogsAccountId, cost); + await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost); + } + + return Json(new + { + success = true, + message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.", + newBalance = item.QuantityOnHand, + unitOfMeasure = item.UnitOfMeasure, + itemName = item.Name + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error logging material for job {JobId}", req.JobId); + return Json(new { success = false, message = "An error occurred. Please try again." }); + } + } } public class DeleteTimeEntryRequest { public int Id { get; set; } } +public class LogMaterialRequest +{ + public int JobId { get; set; } + public int InventoryItemId { get; set; } + public decimal QuantityUsed { get; set; } + public string TransactionType { get; set; } = "JobUsage"; + public string? Notes { get; set; } +} public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } } public class UpdateWorkerAssignmentRequest diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index eec0999..409baec 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -302,7 +302,7 @@ public static class HelpKnowledgeBase **Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system. - **Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." + **Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save. **Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in. @@ -314,6 +314,8 @@ public static class HelpKnowledgeBase All QR codes require login — workers must have an active account. Logging in once on their phone is sufficient for the session. + **Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand. + **Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record. - Access: Jobs list page → printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank. - The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line. diff --git a/src/PowderCoating.Web/Views/Help/Invoices.cshtml b/src/PowderCoating.Web/Views/Help/Invoices.cshtml index f61bb32..f45672d 100644 --- a/src/PowderCoating.Web/Views/Help/Invoices.cshtml +++ b/src/PowderCoating.Web/Views/Help/Invoices.cshtml @@ -48,8 +48,9 @@
  1. Open the job from Operations › Jobs and go to its Details page.
  2. Scroll to the Invoice section near the bottom of the page.
  3. -
  4. Click Create Invoice. The system generates an invoice pre-filled with all the job's line items and the final pricing.
  5. -
  6. Review the invoice — check line items, totals, and the due date — then click Save Invoice.
  7. +
  8. Click Create Invoice. The system pre-fills all line items, the discount, tax rate, payment terms, and due date from the job and customer automatically.
  9. +
  10. Review the Totals panel on the right — if a discount was applied to the job it shows as a red Discount Applied line below the subtotal. Negative line items are allowed if you need to apply a manual credit or price adjustment.
  11. +
  12. Adjust anything you need, then click Save Invoice.

From the Invoices list (manual)

@@ -139,7 +140,7 @@
  • Open the invoice from Operations › Invoices or from the job's Details page.
  • Click Send Invoice. The status changes from Draft to Sent.
  • If email notifications are configured, the customer receives an email with the invoice details and total due.
  • -
  • A due date is set automatically based on the customer's payment terms (e.g., Net 30 means the due date is 30 days from today).
  • +
  • The due date and payment terms are pre-filled from the source quote (if the job came from a quote) or the customer’s payment terms — you can always override them before saving.
  • You can also click Download PDF on any invoice to generate a print-ready PDF diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index e604772..3dbe603 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -607,13 +607,28 @@ no anonymous bumps.

    -

    Bottom QR — Log Powder Usage

    +

    Bottom QR — Log Powder Usage

    One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled with that powder and the job number, so you can record actual lbs used in seconds without navigating through the app.

    +

    Logging Material Usage from a PC

    +

    + You don’t need a phone or QR code to log material usage. On the Job Details page, expand the + Materials Used section and click Log Material. A modal opens where you can: +

    +
      +
    • Select any inventory item from a searchable dropdown — the item’s current stock level is shown when you pick it.
    • +
    • Choose Amount Used (enter how much was consumed) or Amount Remaining (enter what’s left in the bag — the system calculates the usage automatically).
    • +
    • Pick a reason: Job Usage or Waste / Spillage.
    • +
    • Add optional notes.
    • +
    +

    + Saving immediately reduces the item’s stock on hand and creates an entry in the Inventory Activity ledger, exactly like a QR scan would. The QR scan icon is still available next to the button for mobile workers. +

    + + + + @{ var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0; @@ -3082,6 +3144,18 @@ } } + + + +