using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; namespace PowderCoating.Infrastructure.Services; public partial class SeedDataService { /// /// 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. /// /// /// /// Idempotency: returns 0 immediately if any non-deleted inventory transactions already /// exist for this company, preventing duplicate ledger entries on re-seed. /// /// /// All transactions are accumulated in a single in-memory list and persisted in one /// AddRangeAsync / SaveChangesAsync call for efficiency. The trade-off is that /// BalanceAfter values on individual transactions are approximations based on /// QuantityOnHand 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. /// /// /// JobUsage quantities are stored as negative numbers (e.g. −5.5 lbs) following /// the production convention where negative quantity = stock consumed. /// /// /// The color-matching helper PickItem() 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. /// /// /// The tenant company to seed inventory transactions for. /// Total number of transaction records inserted, or 0 if already seeded or no inventory items exist. private async Task SeedInventoryTransactionsAsync(Company company) { var existingCount = await _context.Set() .IgnoreQueryFilters() .CountAsync(t => t.CompanyId == company.Id && !t.IsDeleted); if (existingCount > 0) return 0; // ── Load inventory items (powder coats) ─────────────────────────────── var items = await _context.Set() .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() .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(); // ── 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(); 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: 3–15 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().AddRangeAsync(txns); await _context.SaveChangesAsync(); seeded = txns.Count; return seeded; } }