Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,214 @@
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;
}
}