Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SeedDataService.InventoryTransactions.cs
T
spouliot 7735fe3cce Demo data realism + invoice resend via SMS on any status
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>
2026-06-11 13:20:04 -04:00

228 lines
10 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;
// 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: 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;
}
}