7735fe3cce
Seed data fixes: - Fix EF interceptor: no longer overwrites explicitly-set CreatedAt on Added entities — root cause of all "same month" chart issues - Customer seeder: generates 15 customers/month from Jan → current month; keeps 10 commercial anchors in deterministic order for job seeder index map - Invoice seeder: historical range bumped from 2→8 paid invoices/month so P&L shows consistent profit (~$5,200 collected vs ~$4,200 monthly expenses) - Month -1 bumped to 7 paid invoices to stay above expenses - Jobs: set UpdatedAt to historical event date so analytics don't need null fallback - Analytics (ReportsController): use CompletedDate ?? UpdatedAt ?? CreatedAt for revenue chart grouping; fixes empty Revenue Trend charts on Overview/Revenue tabs - SeedDataService: inject IAccountBalanceService; auto-recalculate account balances after seeding; patch checking/savings opening balances unconditionally on reset - Customer list: sort by CompanyName ?? ContactLastName so individuals and commercial accounts interleave instead of appearing as two blocks Invoice resend: - ResendInvoice action now accepts sendEmail + sendSms parameters; SMS-only resend no longer requires an email address on file - Ensures PublicViewToken exists before SMS so the view link is always valid - canResend in Details view now allows Paid invoices (removed != Paid guard) - Resend button shows channel-choice modal when customer has both email + SMS, direct SMS button when SMS only, or email button when email only - New #resendChannelModal mirrors the Send channel modal but posts to ResendInvoice - resendInvoice() JS updated to pass sendEmail/sendSms query params Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
10 KiB
C#
228 lines
10 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;
|
||
|
||
// Two-query approach: resolve status IDs first to avoid Include() navigation
|
||
// returning null when global query filters interact with IgnoreQueryFilters().
|
||
var completedStatusIds = await _context.Set<JobStatusLookup>()
|
||
.IgnoreQueryFilters()
|
||
.Where(s => s.CompanyId == company.Id
|
||
&& new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }.Contains(s.StatusCode))
|
||
.Select(s => s.Id)
|
||
.ToListAsync();
|
||
|
||
var completedJobs = completedStatusIds.Count == 0
|
||
? new List<Job>()
|
||
: await _context.Set<Job>()
|
||
.IgnoreQueryFilters()
|
||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||
&& completedStatusIds.Contains(j.JobStatusId))
|
||
.Include(j => j.JobItems)
|
||
.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 — 12 months of monthly restocks ────────────
|
||
var powderItems = items.Take(8).ToList();
|
||
// Quantities vary slightly month to month to give the inventory chart a natural shape
|
||
var purchaseOffsets = new (int daysAgo, decimal mult)[]
|
||
{
|
||
(365, 0.80m), (335, 0.85m), (305, 0.90m), (275, 0.95m),
|
||
(245, 1.00m), (215, 1.05m), (185, 1.10m), (155, 1.10m),
|
||
(125, 1.00m), ( 95, 1.10m), ( 65, 1.00m), ( 35, 0.95m)
|
||
};
|
||
foreach (var (offset, qtyMult) in purchaseOffsets)
|
||
{
|
||
foreach (var item in powderItems.Take(4))
|
||
{
|
||
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)))
|
||
{
|
||
// Use the job's actual completion date so powder usage history spans the same
|
||
// 12-month window as jobs, giving the Powder Usage report non-trivial data in
|
||
// every month rather than clustering everything in the last 60 days.
|
||
var usageDate = (job.CompletedDate ?? job.ScheduledDate ?? now.AddDays(-30)).Date;
|
||
|
||
// 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;
|
||
}
|
||
}
|