Fix time entry workers, powder usage logging, inventory edit, and mojibake
- JobTimeEntry: migrate to UserId/UserDisplayName; make ShopWorkerId nullable (migration MigrateTimeEntriesToUserId) - Log Time modal: populate worker dropdown from Identity users instead of ShopWorkers; fix ShopMobile view same issue - Inventory Ledger: scan-based JobUsage transactions now appear in Powder Usage By Job tab (synthesized from InventoryTransaction) - Inventory Ledger: add Edit button for JobUsage transactions; new GetUsageForEdit + EditUsageTransaction endpoints; inventory-ledger.js - InventoryTransactionRepository: include Job.Customer for ledger queries - InventoryAiLookupService: handle JSON-LD @graph wrapper (Columbia Coatings / WooCommerce+Yoast); add HTML price snippet fallback - Fix mojibake in 9 views: â†' → →, âœ" → ✓, âš → ⚠ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1610,6 +1610,62 @@ public class InventoryController : Controller
|
||||
if (inventoryItemId.HasValue)
|
||||
selectedItem = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId.Value);
|
||||
|
||||
// Synthesize powder-usage rows for scan-based JobUsage transactions not already linked to a PowderUsageLog
|
||||
var linkedTxIds = usageLogs
|
||||
.Where(u => u.InventoryTransactionId.HasValue)
|
||||
.Select(u => u.InventoryTransactionId!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
var powderUsageDtos = usageLogs.Select(u => new PowderUsageLogDto
|
||||
{
|
||||
Id = u.Id,
|
||||
JobId = u.JobId,
|
||||
JobNumber = u.Job?.JobNumber ?? string.Empty,
|
||||
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
|
||||
InventoryItemId = u.InventoryItemId,
|
||||
ItemName = u.InventoryItem?.Name,
|
||||
SKU = u.InventoryItem?.SKU,
|
||||
CoatColor = u.JobItemCoat?.ColorName,
|
||||
ActualLbsUsed = u.ActualLbsUsed,
|
||||
EstimatedLbs = u.EstimatedLbs,
|
||||
VarianceLbs = u.VarianceLbs,
|
||||
RecordedAt = u.RecordedAt,
|
||||
Notes = u.Notes
|
||||
}).ToList();
|
||||
|
||||
// Scan-based JobUsage entries have a JobId on the transaction but no PowderUsageLog record;
|
||||
// surface them in the "Powder Usage By Job" tab so they aren't invisible.
|
||||
powderUsageDtos.AddRange(transactions
|
||||
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage
|
||||
&& !linkedTxIds.Contains(t.Id)
|
||||
&& (t.JobId.HasValue || (t.Reference != null && jobRefLookup.ContainsKey(t.Reference))))
|
||||
.Select(t =>
|
||||
{
|
||||
var jobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var r) ? r.Id : 0);
|
||||
var jobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : string.Empty);
|
||||
var cust = t.Job?.Customer;
|
||||
var custName = cust?.CompanyName ?? $"{cust?.ContactFirstName} {cust?.ContactLastName}".Trim();
|
||||
return new PowderUsageLogDto
|
||||
{
|
||||
Id = 0,
|
||||
SourceTransactionId = t.Id,
|
||||
JobId = jobId,
|
||||
JobNumber = jobNumber,
|
||||
CustomerName = string.IsNullOrWhiteSpace(custName) ? null : custName,
|
||||
InventoryItemId = t.InventoryItemId,
|
||||
ItemName = t.InventoryItem?.Name,
|
||||
SKU = t.InventoryItem?.SKU,
|
||||
CoatColor = null,
|
||||
ActualLbsUsed = Math.Abs(t.Quantity),
|
||||
EstimatedLbs = 0,
|
||||
VarianceLbs = 0,
|
||||
RecordedAt = t.TransactionDate,
|
||||
Notes = t.Notes
|
||||
};
|
||||
}));
|
||||
|
||||
powderUsageDtos = [.. powderUsageDtos.OrderByDescending(u => u.RecordedAt)];
|
||||
|
||||
var vm = new InventoryLedgerViewModel
|
||||
{
|
||||
InventoryItemId = inventoryItemId,
|
||||
@@ -1638,22 +1694,7 @@ public class InventoryController : Controller
|
||||
JobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var resolved) ? resolved.Id : null),
|
||||
JobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : null)
|
||||
}).ToList(),
|
||||
PowderUsageLogs = usageLogs.Select(u => new PowderUsageLogDto
|
||||
{
|
||||
Id = u.Id,
|
||||
JobId = u.JobId,
|
||||
JobNumber = u.Job?.JobNumber ?? string.Empty,
|
||||
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
|
||||
InventoryItemId = u.InventoryItemId,
|
||||
ItemName = u.InventoryItem?.Name,
|
||||
SKU = u.InventoryItem?.SKU,
|
||||
CoatColor = u.JobItemCoat?.ColorName,
|
||||
ActualLbsUsed = u.ActualLbsUsed,
|
||||
EstimatedLbs = u.EstimatedLbs,
|
||||
VarianceLbs = u.VarianceLbs,
|
||||
RecordedAt = u.RecordedAt,
|
||||
Notes = u.Notes
|
||||
}).ToList(),
|
||||
PowderUsageLogs = powderUsageDtos,
|
||||
TotalPurchased = transactions
|
||||
.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial)
|
||||
.Sum(t => t.Quantity),
|
||||
@@ -1667,6 +1708,85 @@ public class InventoryController : Controller
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetUsageForEdit(int id)
|
||||
{
|
||||
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false,
|
||||
t => t.Job, t => t.InventoryItem);
|
||||
if (txn == null) return NotFound();
|
||||
if (txn.TransactionType != InventoryTransactionType.JobUsage)
|
||||
return BadRequest("Only JobUsage transactions can be edited here.");
|
||||
|
||||
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => !j.JobStatus.IsTerminalStatus,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus);
|
||||
|
||||
var jobs = allJobs
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.Take(200)
|
||||
.Select(j => new ScanJobOption
|
||||
{
|
||||
Id = j.Id,
|
||||
JobNumber = j.JobNumber,
|
||||
CustomerName = j.Customer != null
|
||||
? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim())
|
||||
: "No Customer"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Json(new
|
||||
{
|
||||
transactionId = txn.Id,
|
||||
jobId = txn.JobId,
|
||||
notes = txn.Notes,
|
||||
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
||||
itemName = txn.InventoryItem?.Name,
|
||||
jobs
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
|
||||
/// Quantity and balance are not changed.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
|
||||
{
|
||||
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
|
||||
if (txn == null) return NotFound();
|
||||
if (txn.TransactionType != InventoryTransactionType.JobUsage)
|
||||
return BadRequest();
|
||||
|
||||
if (jobId.HasValue)
|
||||
{
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
|
||||
txn.JobId = jobId.Value;
|
||||
txn.Reference = job?.JobNumber;
|
||||
}
|
||||
else
|
||||
{
|
||||
txn.JobId = null;
|
||||
txn.Reference = null;
|
||||
}
|
||||
|
||||
txn.Notes = notes?.Trim();
|
||||
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
||||
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
||||
txn.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.InventoryTransactions.UpdateAsync(txn);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Helper projection used by the Scan action for job picker data.</summary>
|
||||
|
||||
Reference in New Issue
Block a user