215 lines
9.8 KiB
C#
215 lines
9.8 KiB
C#
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: 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<InventoryTransaction>().AddRangeAsync(txns);
|
||
await _context.SaveChangesAsync();
|
||
seeded = txns.Count;
|
||
|
||
return seeded;
|
||
}
|
||
}
|