Fix material usage logging: remaining weight mode, edit modal, and consolidate duplicate logic
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage (scan page) and LogMaterial (jobs modal, moved from JobsController) call it — no more duplicate save/GL logic across two controllers - Log Material modal: replace radio buttons with prominent toggle buttons so the active mode (Amount Used vs Amount Remaining) is always visually obvious; add always-visible preview line showing exactly what will be logged before saving - Edit Usage modal: add quantity field (pre-populated from existing transaction) with delta adjustment to InventoryItem.QuantityOnHand on save; include completed/terminal jobs in the dropdown so entries can be corrected after a job is marked done - Scan page job picker: include jobs completed within the last 7 days (marked with '(completed)') so usage can be logged after a job is finished Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1642,8 +1642,10 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var userId = _userManager.GetUserId(User);
|
var userId = _userManager.GetUserId(User);
|
||||||
|
|
||||||
|
var recentCutoff = DateTime.UtcNow.AddDays(-7);
|
||||||
|
|
||||||
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
|
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus))
|
j => j.JobStatus))
|
||||||
@@ -1651,7 +1653,7 @@ public class InventoryController : Controller
|
|||||||
.Select(j => new ScanJobOption
|
.Select(j => new ScanJobOption
|
||||||
{
|
{
|
||||||
Id = j.Id,
|
Id = j.Id,
|
||||||
JobNumber = j.JobNumber,
|
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||||
CustomerName = j.Customer != null
|
CustomerName = j.Customer != null
|
||||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||||
: "No Customer"
|
: "No Customer"
|
||||||
@@ -1660,7 +1662,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||||||
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
|
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && !myJobIds.Contains(j.Id),
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus))
|
j => j.JobStatus))
|
||||||
@@ -1669,7 +1671,7 @@ public class InventoryController : Controller
|
|||||||
.Select(j => new ScanJobOption
|
.Select(j => new ScanJobOption
|
||||||
{
|
{
|
||||||
Id = j.Id,
|
Id = j.Id,
|
||||||
JobNumber = j.JobNumber,
|
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||||
CustomerName = j.Customer != null
|
CustomerName = j.Customer != null
|
||||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||||
: "No Customer"
|
: "No Customer"
|
||||||
@@ -1686,9 +1688,64 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records powder usage logged via the mobile scan page. Creates a JobUsage
|
/// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
|
||||||
/// InventoryTransaction (and PowderUsageLog) when a job is selected, or an
|
/// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
|
||||||
/// Adjustment transaction when logging without a job. Updates QuantityOnHand.
|
/// </summary>
|
||||||
|
private async Task<InventoryUsageResult> RecordInventoryUsageAsync(
|
||||||
|
int inventoryItemId, int? jobId, decimal quantityUsed,
|
||||||
|
InventoryTransactionType transactionType, string? notes)
|
||||||
|
{
|
||||||
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
||||||
|
if (item == null)
|
||||||
|
return new InventoryUsageResult(false, "Inventory item not found.", 0, "", "");
|
||||||
|
|
||||||
|
string? reference = null;
|
||||||
|
if (jobId.HasValue)
|
||||||
|
{
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
|
||||||
|
reference = job != null ? $"Job {job.JobNumber}" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.QuantityOnHand -= quantityUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var txn = new InventoryTransaction
|
||||||
|
{
|
||||||
|
InventoryItemId = item.Id,
|
||||||
|
TransactionType = transactionType,
|
||||||
|
Quantity = -quantityUsed,
|
||||||
|
UnitCost = item.UnitCost,
|
||||||
|
TotalCost = quantityUsed * item.UnitCost,
|
||||||
|
TransactionDate = DateTime.UtcNow,
|
||||||
|
BalanceAfter = item.QuantityOnHand,
|
||||||
|
JobId = jobId,
|
||||||
|
Reference = reference,
|
||||||
|
Notes = notes?.Trim(),
|
||||||
|
CompanyId = item.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||||
|
{
|
||||||
|
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||||
|
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||||
|
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InventoryUsageResult(
|
||||||
|
true,
|
||||||
|
$"Logged {quantityUsed:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.",
|
||||||
|
item.QuantityOnHand,
|
||||||
|
item.UnitOfMeasure,
|
||||||
|
item.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records powder usage from the mobile scan page. Resolves the used quantity
|
||||||
|
/// (caller already converts "remaining weight" to delta before posting) and redirects to ScanSuccess.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
@@ -1697,55 +1754,26 @@ public class InventoryController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
|
||||||
if (item == null) return NotFound();
|
|
||||||
|
|
||||||
if (quantity <= 0)
|
if (quantity <= 0)
|
||||||
{
|
{
|
||||||
TempData["ScanError"] = "Quantity must be greater than zero.";
|
TempData["ScanError"] = "Quantity must be greater than zero.";
|
||||||
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = _userManager.GetUserId(User) ?? string.Empty;
|
var result = await RecordInventoryUsageAsync(
|
||||||
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only
|
inventoryItemId, jobId, quantity,
|
||||||
var txnType = InventoryTransactionType.JobUsage;
|
InventoryTransactionType.JobUsage, notes);
|
||||||
|
|
||||||
item.QuantityOnHand -= quantity;
|
if (!result.Success)
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
|
||||||
|
|
||||||
var txn = new InventoryTransaction
|
|
||||||
{
|
{
|
||||||
InventoryItemId = item.Id,
|
TempData["ScanError"] = result.Message;
|
||||||
TransactionType = txnType,
|
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||||
Quantity = -quantity,
|
|
||||||
UnitCost = item.UnitCost,
|
|
||||||
TotalCost = quantity * item.UnitCost,
|
|
||||||
TransactionDate = DateTime.UtcNow,
|
|
||||||
BalanceAfter = item.QuantityOnHand,
|
|
||||||
JobId = jobId,
|
|
||||||
Reference = jobId.HasValue ? $"Job #{jobId}" : null,
|
|
||||||
Notes = notes?.Trim()
|
|
||||||
};
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
|
||||||
await _unitOfWork.SaveChangesAsync();
|
|
||||||
|
|
||||||
// GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item
|
|
||||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
|
||||||
{
|
|
||||||
var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
|
||||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
|
||||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging
|
TempData["ScanSuccess"] = result.Message;
|
||||||
// doesn't have that context, so we rely on the InventoryTransaction alone
|
|
||||||
// for the audit trail. Coat-level PowderUsageLogs are created by the job workflow.
|
|
||||||
|
|
||||||
TempData["ScanSuccess"] = $"Logged {quantity:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.";
|
|
||||||
TempData["ScanItemId"] = inventoryItemId.ToString();
|
TempData["ScanItemId"] = inventoryItemId.ToString();
|
||||||
TempData["ScanJobId"] = jobId?.ToString();
|
TempData["ScanJobId"] = jobId?.ToString();
|
||||||
TempData["ScanItemName"] = item.Name;
|
TempData["ScanItemName"] = result.ItemName;
|
||||||
return RedirectToAction(nameof(ScanSuccess));
|
return RedirectToAction(nameof(ScanSuccess));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1756,6 +1784,43 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records manual material usage from the job details modal. Accepts JSON, resolves
|
||||||
|
/// the amount used (caller sends the already-computed used quantity), and returns JSON
|
||||||
|
/// so the modal can close and refresh inline.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (req.QuantityUsed <= 0)
|
||||||
|
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
||||||
|
|
||||||
|
var txnType = req.TransactionType == "Waste"
|
||||||
|
? InventoryTransactionType.Waste
|
||||||
|
: InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
var result = await RecordInventoryUsageAsync(
|
||||||
|
req.InventoryItemId, req.JobId, req.QuantityUsed, txnType, req.Notes);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
message = result.Message,
|
||||||
|
newBalance = result.NewBalance,
|
||||||
|
unitOfMeasure = result.UnitOfMeasure,
|
||||||
|
itemName = result.ItemName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
|
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
|
||||||
/// This Job" and "Done" options.
|
/// This Job" and "Done" options.
|
||||||
@@ -2003,7 +2068,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
|
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
|
||||||
/// jobs so the edit modal can be pre-populated without a full page reload.
|
/// jobs (plus the currently assigned job even if terminal) for the edit modal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetUsageForEdit(int id)
|
public async Task<IActionResult> GetUsageForEdit(int id)
|
||||||
@@ -2034,10 +2099,27 @@ public class InventoryController : Controller
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// If the assigned job has terminal status it won't appear in the active list; insert it at the top
|
||||||
|
// so the dropdown pre-selects correctly and the user can see the existing job assignment.
|
||||||
|
if (txn.JobId.HasValue && jobs.All(j => j.Id != txn.JobId.Value))
|
||||||
|
{
|
||||||
|
var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(txn.JobId.Value, false, j => j.Customer);
|
||||||
|
if (assignedJob != null)
|
||||||
|
jobs.Insert(0, new ScanJobOption
|
||||||
|
{
|
||||||
|
Id = assignedJob.Id,
|
||||||
|
JobNumber = assignedJob.JobNumber,
|
||||||
|
CustomerName = assignedJob.Customer != null
|
||||||
|
? (assignedJob.Customer.CompanyName ?? $"{assignedJob.Customer.ContactFirstName} {assignedJob.Customer.ContactLastName}".Trim())
|
||||||
|
: "No Customer"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
transactionId = txn.Id,
|
transactionId = txn.Id,
|
||||||
jobId = txn.JobId,
|
jobId = txn.JobId,
|
||||||
|
quantity = Math.Abs(txn.Quantity),
|
||||||
notes = txn.Notes,
|
notes = txn.Notes,
|
||||||
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
||||||
itemName = txn.InventoryItem?.Name,
|
itemName = txn.InventoryItem?.Name,
|
||||||
@@ -2046,14 +2128,15 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
|
/// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
|
||||||
/// Quantity and balance are not changed.
|
/// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
|
||||||
|
/// ledger balance remains consistent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
|
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate, decimal? quantity)
|
||||||
{
|
{
|
||||||
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
|
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.InventoryItem);
|
||||||
if (txn == null) return NotFound();
|
if (txn == null) return NotFound();
|
||||||
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
||||||
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
||||||
@@ -2075,6 +2158,28 @@ public class InventoryController : Controller
|
|||||||
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
||||||
txn.TransactionType = InventoryTransactionType.JobUsage;
|
txn.TransactionType = InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
// Adjust inventory when the logged quantity is changed.
|
||||||
|
// txn.Quantity is stored as a negative number for usage (e.g. -3.5 for 3.5 lbs used).
|
||||||
|
if (quantity.HasValue && quantity.Value > 0)
|
||||||
|
{
|
||||||
|
var oldUsed = Math.Abs(txn.Quantity);
|
||||||
|
var newUsed = quantity.Value;
|
||||||
|
if (oldUsed != newUsed)
|
||||||
|
{
|
||||||
|
var item = txn.InventoryItem ?? await _unitOfWork.InventoryItems.GetByIdAsync(txn.InventoryItemId);
|
||||||
|
if (item != null)
|
||||||
|
{
|
||||||
|
// Positive delta means less was actually used → restore the difference to inventory.
|
||||||
|
item.QuantityOnHand += oldUsed - newUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
txn.BalanceAfter = item.QuantityOnHand;
|
||||||
|
}
|
||||||
|
txn.Quantity = -newUsed;
|
||||||
|
txn.TotalCost = newUsed * txn.UnitCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
txn.Notes = notes?.Trim();
|
txn.Notes = notes?.Trim();
|
||||||
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
||||||
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
||||||
@@ -2094,3 +2199,21 @@ public class ScanJobOption
|
|||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Result returned by RecordInventoryUsageAsync.</summary>
|
||||||
|
public record InventoryUsageResult(
|
||||||
|
bool Success,
|
||||||
|
string Message,
|
||||||
|
decimal NewBalance,
|
||||||
|
string UnitOfMeasure,
|
||||||
|
string ItemName);
|
||||||
|
|
||||||
|
/// <summary>JSON body for the LogMaterial endpoint (job details modal).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4399,75 +4399,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// LogMaterial has been consolidated into InventoryController.LogMaterial.
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
[ValidateAntiForgeryToken]
|
|
||||||
public async Task<IActionResult> 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." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inline-edits description, quantity, and unit price on a single job line item.
|
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||||
@@ -4554,14 +4486,6 @@ public class PatchJobItemRequest
|
|||||||
public decimal Quantity { get; set; }
|
public decimal Quantity { get; set; }
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { 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 class CreateReworkJobRequest
|
||||||
{
|
{
|
||||||
public int ReworkRecordId { get; set; }
|
public int ReworkRecordId { get; set; }
|
||||||
|
|||||||
@@ -353,6 +353,11 @@
|
|||||||
<label class="form-label fw-semibold">Powder Item</label>
|
<label class="form-label fw-semibold">Powder Item</label>
|
||||||
<p id="euItemName" class="form-control-plaintext text-muted"></p>
|
<p id="euItemName" class="form-control-plaintext text-muted"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="euQuantity" class="form-label fw-semibold">Amount Used <small class="text-muted fw-normal" id="euQuantityUom"></small></label>
|
||||||
|
<input type="number" id="euQuantity" name="quantity" class="form-control" min="0.001" step="any" required />
|
||||||
|
<div class="form-text">Adjusts the inventory balance by the difference from the original entry.</div>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
|
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
|
||||||
<select id="euJobId" name="jobId" class="form-select">
|
<select id="euJobId" name="jobId" class="form-select">
|
||||||
|
|||||||
@@ -1158,21 +1158,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Entry Method</label>
|
<label class="form-label fw-semibold">Entry Method</label>
|
||||||
<div class="d-flex gap-3">
|
<div class="btn-group w-100" role="group">
|
||||||
<div class="form-check">
|
<button type="button" id="lmBtnUsed" class="btn btn-primary"
|
||||||
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
|
onclick="lmSetMethod('used')">
|
||||||
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
|
<i class="bi bi-droplet me-1"></i>Amount Used
|
||||||
</div>
|
</button>
|
||||||
<div class="form-check">
|
<button type="button" id="lmBtnRemaining" class="btn btn-outline-primary"
|
||||||
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
|
onclick="lmSetMethod('remaining')">
|
||||||
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
|
<i class="bi bi-droplet-half me-1"></i>Amount Remaining
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
<span id="lmMethodHint">Enter how much powder you took out of the bag.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
|
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
|
||||||
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
|
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
|
||||||
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
|
<div id="lmComputedUsed" class="form-text fw-semibold d-none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Reason</label>
|
<label class="form-label fw-semibold">Reason</label>
|
||||||
@@ -3311,7 +3314,7 @@
|
|||||||
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
|
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
|
||||||
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
|
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
|
||||||
const jobId = @Model.Id;
|
const jobId = @Model.Id;
|
||||||
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
|
const logUrl = '@Url.Action("LogMaterial", "Inventory")';
|
||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
|
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ async function openUsageEdit(transactionId) {
|
|||||||
|
|
||||||
document.getElementById('euTxnId').value = data.transactionId;
|
document.getElementById('euTxnId').value = data.transactionId;
|
||||||
document.getElementById('euItemName').textContent = data.itemName || '—';
|
document.getElementById('euItemName').textContent = data.itemName || '—';
|
||||||
|
document.getElementById('euQuantity').value = data.quantity != null ? parseFloat(data.quantity).toFixed(4) : '';
|
||||||
document.getElementById('euDate').value = data.transactionDate;
|
document.getElementById('euDate').value = data.transactionDate;
|
||||||
document.getElementById('euNotes').value = data.notes || '';
|
document.getElementById('euNotes').value = data.notes || '';
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ document.getElementById('euSaveBtn').addEventListener('click', async () => {
|
|||||||
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id: document.getElementById('euTxnId').value,
|
id: document.getElementById('euTxnId').value,
|
||||||
|
quantity: document.getElementById('euQuantity').value,
|
||||||
jobId: document.getElementById('euJobId').value,
|
jobId: document.getElementById('euJobId').value,
|
||||||
notes: document.getElementById('euNotes').value,
|
notes: document.getElementById('euNotes').value,
|
||||||
transactionDate: document.getElementById('euDate').value,
|
transactionDate: document.getElementById('euDate').value,
|
||||||
|
|||||||
@@ -6,9 +6,64 @@
|
|||||||
let _items = [];
|
let _items = [];
|
||||||
let _jobPowderIds = new Set();
|
let _jobPowderIds = new Set();
|
||||||
let _modal = null;
|
let _modal = null;
|
||||||
|
let _selectedItemId = 0;
|
||||||
|
let _entryMethod = 'used'; // 'used' | 'remaining'
|
||||||
|
|
||||||
|
// ── Mode toggle ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.lmSetMethod = function (method) {
|
||||||
|
_entryMethod = method;
|
||||||
|
const btnUsed = document.getElementById('lmBtnUsed');
|
||||||
|
const btnRemaining = document.getElementById('lmBtnRemaining');
|
||||||
|
const hintEl = document.getElementById('lmMethodHint');
|
||||||
|
const qtyLabel = document.getElementById('lmQtyLabel');
|
||||||
|
|
||||||
|
if (method === 'remaining') {
|
||||||
|
btnUsed.className = 'btn btn-outline-primary';
|
||||||
|
btnRemaining.className = 'btn btn-primary';
|
||||||
|
hintEl.textContent = 'Enter how much is LEFT in the bag — the system calculates what was used.';
|
||||||
|
qtyLabel.innerHTML = 'Weight Remaining in Bag <span class="text-danger">*</span>';
|
||||||
|
} else {
|
||||||
|
btnUsed.className = 'btn btn-primary';
|
||||||
|
btnRemaining.className = 'btn btn-outline-primary';
|
||||||
|
hintEl.textContent = 'Enter how much powder you took out of the bag.';
|
||||||
|
qtyLabel.innerHTML = 'Quantity Used <span class="text-danger">*</span>';
|
||||||
|
}
|
||||||
|
lmUpdatePreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Live preview (always visible once qty + item are set) ─────────────────
|
||||||
|
|
||||||
|
function lmUpdatePreview() {
|
||||||
|
const computedDiv = document.getElementById('lmComputedUsed');
|
||||||
|
if (!_selectedItemId || !computedDiv) { computedDiv?.classList.add('d-none'); return; }
|
||||||
|
const item = _items.find(it => it.id === _selectedItemId);
|
||||||
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||||
|
const qty = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
|
if (qty <= 0) { computedDiv.classList.add('d-none'); return; }
|
||||||
|
|
||||||
|
const uom = item?.unitOfMeasure || '';
|
||||||
|
if (_entryMethod === 'remaining') {
|
||||||
|
const used = onHand - qty;
|
||||||
|
if (used <= 0) {
|
||||||
|
computedDiv.className = 'form-text fw-semibold text-danger';
|
||||||
|
computedDiv.textContent = 'Remaining cannot be ≥ current stock (' + onHand.toFixed(2) + ' ' + uom + ').';
|
||||||
|
} else {
|
||||||
|
computedDiv.className = 'form-text fw-semibold text-success';
|
||||||
|
computedDiv.textContent =
|
||||||
|
'Will log ' + used.toFixed(2) + ' ' + uom + ' used — new balance: ' + qty.toFixed(2) + ' ' + uom;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newBal = onHand - qty;
|
||||||
|
const col = newBal < 0 ? 'text-danger' : 'text-success';
|
||||||
|
computedDiv.className = 'form-text fw-semibold ' + col;
|
||||||
|
computedDiv.textContent =
|
||||||
|
'Will log ' + qty.toFixed(2) + ' ' + uom + ' used — new balance: ' + newBal.toFixed(2) + ' ' + uom;
|
||||||
|
}
|
||||||
|
computedDiv.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Combobox state ────────────────────────────────────────────────────────
|
// ── Combobox state ────────────────────────────────────────────────────────
|
||||||
let _selectedItemId = 0;
|
|
||||||
|
|
||||||
function lmComboInput() {
|
function lmComboInput() {
|
||||||
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
||||||
@@ -16,7 +71,7 @@
|
|||||||
lmComboShow();
|
lmComboShow();
|
||||||
_selectedItemId = 0;
|
_selectedItemId = 0;
|
||||||
document.getElementById('lmItemBalance').classList.add('d-none');
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||||
lmOnQtyInput();
|
lmUpdatePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
function lmComboOpen() {
|
function lmComboOpen() {
|
||||||
@@ -111,7 +166,7 @@
|
|||||||
const balDiv = document.getElementById('lmItemBalance');
|
const balDiv = document.getElementById('lmItemBalance');
|
||||||
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
|
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
|
||||||
balDiv.classList.remove('d-none');
|
balDiv.classList.remove('d-none');
|
||||||
lmOnQtyInput();
|
lmUpdatePreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.lmComboInput = lmComboInput;
|
window.lmComboInput = lmComboInput;
|
||||||
@@ -152,39 +207,14 @@
|
|||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quantity / label logic ────────────────────────────────────────────────
|
// ── Kept for backward-compat with any inline onchange handlers that may exist ─
|
||||||
|
window.lmUpdateQuantityLabel = function () { lmUpdatePreview(); };
|
||||||
function lmOnQtyInput() {
|
|
||||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
|
||||||
if (method !== 'remaining') {
|
|
||||||
document.getElementById('lmComputedUsed').classList.add('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_selectedItemId) {
|
|
||||||
document.getElementById('lmComputedUsed').classList.add('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const item = _items.find(it => it.id === _selectedItemId);
|
|
||||||
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
|
||||||
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
|
||||||
const used = onHand - remaining;
|
|
||||||
const computedDiv = document.getElementById('lmComputedUsed');
|
|
||||||
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' − ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
|
|
||||||
computedDiv.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.lmUpdateQuantityLabel = function () {
|
|
||||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
|
||||||
document.getElementById('lmQtyLabel').innerHTML =
|
|
||||||
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
|
|
||||||
' <span class="text-danger">*</span>';
|
|
||||||
lmOnQtyInput();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Modal open / save ─────────────────────────────────────────────────────
|
// ── Modal open / save ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
window.openLogMaterialModal = function () {
|
window.openLogMaterialModal = function () {
|
||||||
_selectedItemId = 0;
|
_selectedItemId = 0;
|
||||||
|
_entryMethod = 'used';
|
||||||
document.getElementById('lmItemSearch').value = '';
|
document.getElementById('lmItemSearch').value = '';
|
||||||
document.getElementById('lmItemBalance').classList.add('d-none');
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
||||||
document.getElementById('lmQuantity').value = '';
|
document.getElementById('lmQuantity').value = '';
|
||||||
@@ -193,8 +223,7 @@
|
|||||||
document.getElementById('lmNotes').value = '';
|
document.getElementById('lmNotes').value = '';
|
||||||
document.getElementById('lmAlert').classList.add('d-none');
|
document.getElementById('lmAlert').classList.add('d-none');
|
||||||
document.getElementById('lmSaveBtn').disabled = false;
|
document.getElementById('lmSaveBtn').disabled = false;
|
||||||
document.getElementById('lmMethodUsed').checked = true;
|
lmSetMethod('used');
|
||||||
window.lmUpdateQuantityLabel();
|
|
||||||
lmComboClose();
|
lmComboClose();
|
||||||
if (_modal) _modal.show();
|
if (_modal) _modal.show();
|
||||||
};
|
};
|
||||||
@@ -214,14 +243,14 @@
|
|||||||
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
||||||
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
|
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
|
||||||
|
|
||||||
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
|
const item = _items.find(it => it.id === _selectedItemId);
|
||||||
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
||||||
|
|
||||||
let quantityUsed = qtyInput;
|
let quantityUsed = qtyInput;
|
||||||
if (method === 'remaining') {
|
if (_entryMethod === 'remaining') {
|
||||||
const item = _items.find(it => it.id === _selectedItemId);
|
|
||||||
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
|
||||||
quantityUsed = onHand - qtyInput;
|
quantityUsed = onHand - qtyInput;
|
||||||
if (quantityUsed <= 0) {
|
if (quantityUsed <= 0) {
|
||||||
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
|
showError('Remaining cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,9 +298,8 @@
|
|||||||
_jobPowderIds = new Set(cfg.jobPowderIds || []);
|
_jobPowderIds = new Set(cfg.jobPowderIds || []);
|
||||||
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
|
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
|
||||||
|
|
||||||
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
|
document.getElementById('lmQuantity').addEventListener('input', lmUpdatePreview);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (!e.target.closest('#lmItemSearch') &&
|
if (!e.target.closest('#lmItemSearch') &&
|
||||||
!e.target.closest('#lmItemDropdown') &&
|
!e.target.closest('#lmItemDropdown') &&
|
||||||
|
|||||||
Reference in New Issue
Block a user