Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.InventoryTransactions.cs
T
2026-04-23 21:38:24 -04:00

215 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds inventory transaction history for a company, comprising four categories of
/// transactions: opening balance (Initial), three months of powder restocks (Purchase),
/// per-job powder consumption for every completed/ready/delivered job (JobUsage),
/// and one waste plus one manual adjustment record.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted inventory transactions already
/// exist for this company, preventing duplicate ledger entries on re-seed.
/// </para>
/// <para>
/// All transactions are accumulated in a single in-memory list and persisted in one
/// <c>AddRangeAsync / SaveChangesAsync</c> call for efficiency. The trade-off is that
/// <c>BalanceAfter</c> values on individual transactions are approximations based on
/// <c>QuantityOnHand</c> at seed time rather than a running sum — they are good enough
/// for demo display purposes but should not be trusted as exact ledger balances.
/// </para>
/// <para>
/// JobUsage quantities are stored as <em>negative</em> numbers (e.g. 5.5 lbs) following
/// the production convention where negative quantity = stock consumed.
/// </para>
/// <para>
/// The color-matching helper <c>PickItem()</c> attempts to link each completed job to an
/// inventory item whose color name starts with the same word as the job's first item's color
/// (e.g. "Matte Black" → key "matte"). If no match is found it falls back to round-robin
/// rotation so every completed job still gets a usage transaction.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed inventory transactions for.</param>
/// <returns>Total number of transaction records inserted, or 0 if already seeded or no inventory items exist.</returns>
private async Task<int> SeedInventoryTransactionsAsync(Company company)
{
var existingCount = await _context.Set<InventoryTransaction>()
.IgnoreQueryFilters()
.CountAsync(t => t.CompanyId == company.Id && !t.IsDeleted);
if (existingCount > 0)
return 0;
// ── Load inventory items (powder coats) ───────────────────────────────
var items = await _context.Set<InventoryItem>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == company.Id && !i.IsDeleted && i.IsActive)
.ToListAsync();
if (items.Count == 0) return 0;
// Load completed/delivered jobs to generate usage transactions against
var completedJobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& (j.JobStatus.StatusCode == "COMPLETED"
|| j.JobStatus.StatusCode == "READY_FOR_PICKUP"
|| j.JobStatus.StatusCode == "DELIVERED"))
.Include(j => j.JobStatus)
.OrderBy(j => j.Id)
.ToListAsync();
var now = DateTime.UtcNow;
var seeded = 0;
var txns = new List<InventoryTransaction>();
// ── Initial stock transactions (set opening balances) ─────────────────
// These reflect inventory on hand from day-1; dated 90 days ago
foreach (var item in items)
{
var openingQty = item.QuantityOnHand > 0 ? item.QuantityOnHand : 25m;
txns.Add(new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = InventoryTransactionType.Initial,
Quantity = openingQty,
UnitCost = item.UnitCost,
TotalCost = Math.Round(openingQty * item.UnitCost, 2),
TransactionDate = now.AddDays(-90),
Reference = "Opening balance",
Notes = "Initial stock entry",
BalanceAfter = openingQty,
CompanyId = company.Id,
CreatedAt = now.AddDays(-90)
});
}
// ── Purchase transactions — 3 months of restocks ──────────────────────
// Simulate monthly powder purchases for top items
var powderItems = items.Take(8).ToList(); // focus on powder coat items
foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] {
(85, 1.2m), (55, 1.0m), (25, 0.9m) })
{
foreach (var item in powderItems.Take(4)) // 4 items per purchase cycle
{
var qty = Math.Round(25m * qtyMult, 0);
txns.Add(new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = qty,
UnitCost = item.UnitCost,
TotalCost = Math.Round(qty * item.UnitCost, 2),
TransactionDate = now.AddDays(-offset),
Reference = $"PO-{now.AddDays(-offset):yyMM}-{item.Id:D3}",
Notes = "Scheduled restock",
BalanceAfter = qty + (item.QuantityOnHand > 0 ? item.QuantityOnHand : 25m),
CompanyId = company.Id,
CreatedAt = now.AddDays(-offset)
});
}
}
// ── JobUsage transactions — powder consumed for completed jobs ─────────
// Each completed job consumes powder from one or two inventory items.
// Spread consumption across the past 3 months.
var colorMap = new Dictionary<string, InventoryItem?>();
foreach (var item in items)
{
var key = (item.ColorName ?? item.Name).ToLowerInvariant().Split(' ')[0];
colorMap.TryAdd(key, item);
}
// Fallback: rotate through items if no color match.
// PickItem is a local function rather than a LINQ expression because it needs to
// mutate the outer itemIdx counter across multiple calls.
int itemIdx = 0;
InventoryItem PickItem(string? colorName)
{
if (!string.IsNullOrEmpty(colorName))
{
var key = colorName.ToLowerInvariant().Split(' ')[0];
if (colorMap.TryGetValue(key, out var matched) && matched != null)
return matched;
}
return items[itemIdx++ % items.Count];
}
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
{
// Completed date spread: most within the last 60 days
var daysAgo = 10 + (idx % 55);
var usageDate = now.AddDays(-daysAgo);
// Pick a color-matched powder item (or rotate)
var firstItem = job.JobItems?.FirstOrDefault();
var inv = PickItem(firstItem?.ColorName);
// Powder used: 315 lbs depending on job size
var lbsUsed = Math.Round(3m + (idx % 5) * 2.5m, 1);
if (lbsUsed < 0.5m) lbsUsed = 0.5m;
txns.Add(new InventoryTransaction
{
InventoryItemId = inv.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -lbsUsed, // negative = consumed
UnitCost = inv.UnitCost,
TotalCost = Math.Round(lbsUsed * inv.UnitCost, 2),
TransactionDate = usageDate,
Reference = job.JobNumber,
Notes = $"Powder used — {job.JobNumber}",
BalanceAfter = Math.Max(0, inv.QuantityOnHand - lbsUsed),
CompanyId = company.Id,
CreatedAt = usageDate
});
}
// ── Waste/adjustment transactions ─────────────────────────────────────
if (items.Count >= 2)
{
txns.Add(new InventoryTransaction
{
InventoryItemId = items[0].Id,
TransactionType = InventoryTransactionType.Waste,
Quantity = -1.5m,
UnitCost = items[0].UnitCost,
TotalCost = Math.Round(1.5m * items[0].UnitCost, 2),
TransactionDate = now.AddDays(-45),
Reference = "Waste",
Notes = "Contaminated powder — disposed",
BalanceAfter = Math.Max(0, items[0].QuantityOnHand - 1.5m),
CompanyId = company.Id,
CreatedAt = now.AddDays(-45)
});
txns.Add(new InventoryTransaction
{
InventoryItemId = items[1].Id,
TransactionType = InventoryTransactionType.Adjustment,
Quantity = 2.0m,
UnitCost = items[1].UnitCost,
TotalCost = Math.Round(2.0m * items[1].UnitCost, 2),
TransactionDate = now.AddDays(-30),
Reference = "Adjustment",
Notes = "Physical count correction",
BalanceAfter = items[1].QuantityOnHand + 2.0m,
CompanyId = company.Id,
CreatedAt = now.AddDays(-30)
});
}
await _context.Set<InventoryTransaction>().AddRangeAsync(txns);
await _context.SaveChangesAsync();
seeded = txns.Count;
return seeded;
}
}