Demo reset + dev banner suppression for DEMO company

- DemoController: company-code-gated reset action (DEMO only, CSRF protected)
- SeedDataService.Remove: FK-safe topological pre-sweep, all deletes scoped to companyId
- SeedDataService: clock entries, extra seed data, updated customer/worker/job-status seeders
- CompanySettingsController + Index.cshtml: Reset Demo Data button for DEMO company users
- ReportsController + FinancialReportService: supporting report fixes
- _Layout.cshtml: suppress env banner when current company is DEMO (all auth paths)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:26:40 -04:00
parent 7735fe3cce
commit 6eb7be0193
13 changed files with 963 additions and 221 deletions
@@ -0,0 +1,411 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
// ═══════════════════════════════════════════════════════════════════════════
// Job Notes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds realistic shop-floor notes on a subset of jobs so that the job
/// detail view looks lived-in. Roughly 70% of jobs receive 1&ndash;2 notes;
/// the selection and text are deterministic so every reset produces the same set.
/// </summary>
private async Task<int> SeedJobNotesAsync(Company company)
{
var existingCount = await _context.Set<JobNote>()
.IgnoreQueryFilters()
.CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
if (existingCount > 0) return 0;
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
if (jobs.Count == 0) return 0;
string[] internalNotes =
[
"Customer confirmed pickup Friday PM — call ahead.",
"Requires extra masking around mounting holes — 6 total.",
"Rush job — prioritize over batch queue.",
"Custom color mix: 70% Gloss Black + 30% Charcoal Grey.",
"Check for pitting before coating — surface prep critical.",
"Customer wants to inspect before final cure.",
"Sandblast to bare metal — all previous coating must come off.",
"Note: customer bringing additional parts next week, hold space.",
"Frame sections must be coated before assembly — check sequence.",
"QC check required — previous run had adhesion issue with this powder.",
"Customer paid deposit via check #4412.",
"Temperature-sensitive substrate — confirm oven profile before load.",
"Left voicemail re: delivery date — awaiting callback.",
"Batch with Triangle Offroad order if timing works out.",
"Hanging rod #3 reserved for this run.",
];
string[] externalNotes =
[
"Customer requested matte finish — confirmed in writing.",
"Delivery arranged for Thursday 9am&ndash;12pm.",
"Customer approved color sample.",
"Special instructions: no masking on inner bore.",
"Customer will drop off Wednesday morning.",
];
var notes = new List<JobNote>();
for (int i = 0; i < jobs.Count; i++)
{
int hash = (i * 31 + 7) % 10;
if (hash >= 7) continue; // ~30% of jobs get no notes
int noteCount = hash < 3 ? 1 : 2;
var job = jobs[i];
for (int n = 0; n < noteCount; n++)
{
bool isInternal = (i + n) % 4 != 0;
var pool = isInternal ? internalNotes : externalNotes;
notes.Add(new JobNote
{
JobId = job.Id,
Note = pool[(i * 3 + n * 7) % pool.Length],
IsImportant = n == 0 && i % 5 == 0,
IsInternal = isInternal,
CompanyId = company.Id,
CreatedAt = job.CreatedAt.AddDays(1 + n),
});
}
}
if (notes.Count == 0) return 0;
await _context.Set<JobNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Customer Notes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds account-management notes on the 10 commercial anchor customers and a
/// handful of individual customers so that customer detail pages look like a
/// working CRM rather than a blank slate.
/// </summary>
private async Task<int> SeedCustomerNotesAsync(Company company)
{
var existingCount = await _context.Set<CustomerNote>()
.IgnoreQueryFilters()
.CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
if (existingCount > 0) return 0;
// Targeted notes for named commercial anchor accounts
var anchorData = new (string email, string note, bool important)[]
{
("matt@carolinafab.com", "Net 30 terms per contract; invoices go to accounting@carolinafab.com.", true),
("matt@carolinafab.com", "Prefers Gloss Black and Hammertone for structural steel — confirm any color changes.", false),
("ctanner@apexmotorsports.com", "Racing season turnaround required within 5 business days.", true),
("ctanner@apexmotorsports.com", "Color approval required before coating — never assume from previous run.", false),
("jpruitt@triangleoffroad.com", "Skid plates must be coated inside and out — customer checks coverage.", true),
("bsmith@smithwelding.com", "Monthly standing PO — call Bill to confirm quantities before start.", false),
("kmorales@raleigharchitectural.com", "Architectural work: surface finish is critical, QC photos required.", true),
("kmorales@raleigharchitectural.com", "Preferred color: RAL 9005 Jet Black. Has approved alternate: Gloss Black.", false),
("tgreco@eastcoastpw.com", "Net 15 — has paid late twice; flag for follow-up at 10 days.", true),
("dshaw@piedmontmetalworks.com", "Heavy gauge steel — plan for extended sandblast and pre-heat time.", false),
("lpatel@caryindustrial.com", "Parts arrive disassembled; coordinate reassembly quote if needed.", false),
("rblake@durhamtech.com", "University purchase order required on every invoice — ask for PO number.", true),
("mcoleman@wakecountyfleet.gov", "Government contract — tax exempt, Net 60. Do not charge sales tax.", true),
("mcoleman@wakecountyfleet.gov", "Fleet contact: Michelle Coleman. Secondary: Facilities Dept (919) 555-0100.", false),
};
// Generic notes for individual customers (applied deterministically to first N individuals)
string[] genericIndivNotes =
[
"Repeat customer — prefers same color family as previous job.",
"Customer prefers afternoon pickups — call before loading.",
"Cash payment preferred; has paid by check in the past.",
"Sensitive to turnaround time — communicate delays early.",
"Referred by Carolina Fabrication. Treat as preferred.",
];
var notes = new List<CustomerNote>();
var now = DateTime.UtcNow;
// Anchor commercial notes
foreach (var (email, note, important) in anchorData)
{
var customer = await _context.Set<Customer>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Email == email && c.CompanyId == company.Id && !c.IsDeleted);
if (customer == null) continue;
notes.Add(new CustomerNote
{
CustomerId = customer.Id,
Note = note,
IsImportant = important,
CompanyId = company.Id,
CreatedAt = customer.CreatedAt.AddDays(2),
});
}
// Generic notes on the first 8 individual customers (non-commercial)
var individuals = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted && !c.IsCommercial)
.OrderBy(c => c.Id)
.Take(8)
.ToListAsync();
for (int i = 0; i < individuals.Count; i++)
{
notes.Add(new CustomerNote
{
CustomerId = individuals[i].Id,
Note = genericIndivNotes[i % genericIndivNotes.Length],
IsImportant = false,
CompanyId = company.Id,
CreatedAt = individuals[i].CreatedAt.AddDays(3),
});
}
if (notes.Count == 0) return 0;
await _context.Set<CustomerNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Rework Records
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds 7&ndash;8 rework records on completed jobs representing a realistic ~5%
/// rework rate. Mix of internal defects (shop fault) and customer-reported
/// warranty claims. About half are resolved; the rest remain open or in-progress.
/// </summary>
private async Task<int> SeedReworkRecordsAsync(Company company)
{
var existingCount = await _context.Set<ReworkRecord>()
.IgnoreQueryFilters()
.CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
if (existingCount > 0) return 0;
// Only completed/delivered jobs qualify for rework records
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "READY_FOR_PICKUP" };
var statusIds = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id && terminalCodes.Contains(s.StatusCode))
.Select(s => s.Id)
.ToListAsync();
var completedJobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted && statusIds.Contains(j.JobStatusId))
.OrderBy(j => j.Id)
.ToListAsync();
if (completedJobs.Count < 5) return 0;
// Deterministic selection: pick jobs at indices 1, 4, 7, 10, 13, 16, 19
var reworkData = new (int jobIdx, ReworkType type, ReworkReason reason,
string description, ReworkDiscoveredBy by, ReworkStatus status,
ReworkResolution? resolution, decimal estCost, decimal actCost,
bool billable, ReworkPricingType? pricing)[]
{
(1, ReworkType.InternalDefect, ReworkReason.AdhesionFailure, "Coating delaminating on three mounting points — surface prep insufficient.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 85m, 95m, false, ReworkPricingType.ShopFault),
(4, ReworkType.InternalDefect, ReworkReason.Contamination, "Fish-eye defects across main panel — contamination during coating.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 60m, 70m, false, ReworkPricingType.ShopFault),
(7, ReworkType.CustomerWarranty, ReworkReason.ColorMismatch, "Customer says delivered color does not match approved sample.", ReworkDiscoveredBy.Customer, ReworkStatus.Resolved, ReworkResolution.CustomerCredited, 120m, 0m, false, ReworkPricingType.ShopFault),
(10, ReworkType.InternalDefect, ReworkReason.RunsSags, "Runs visible on lower edge — caught during QC inspection.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 45m, 50m, false, ReworkPricingType.ShopFault),
(13, ReworkType.CustomerDamage, ReworkReason.HandlingDamage, "Customer damaged finish during installation — requesting touch-up.", ReworkDiscoveredBy.Customer, ReworkStatus.InProgress, null, 150m, 0m, true, ReworkPricingType.CustomerFull),
(16, ReworkType.InternalDefect, ReworkReason.InsufficientCoverage, "Thin spots on inside radius — insufficient powder in recessed areas.", ReworkDiscoveredBy.Internal, ReworkStatus.Open, null, 70m, 0m, false, ReworkPricingType.ShopFault),
(19, ReworkType.CustomerWarranty, ReworkReason.OvenIssue, "Early cure failure reported 3 months post-delivery — under investigation.", ReworkDiscoveredBy.Customer, ReworkStatus.Open, null, 200m, 0m, false, ReworkPricingType.ShopFault),
};
var records = new List<ReworkRecord>();
foreach (var (jobIdx, type, reason, desc, by, status, resolution, est, act, billable, pricing) in reworkData)
{
if (jobIdx >= completedJobs.Count) continue;
var job = completedJobs[jobIdx];
var discoveredDate = job.CompletedDate ?? job.UpdatedAt ?? job.CreatedAt.AddDays(7);
records.Add(new ReworkRecord
{
JobId = job.Id,
ReworkType = type,
Reason = reason,
DefectDescription = desc,
DiscoveredBy = by,
DiscoveredDate = discoveredDate.AddDays(1),
EstimatedReworkCost = est,
ActualReworkCost = act,
IsBillableToCustomer = billable,
ReworkPricingType = pricing,
Status = status,
Resolution = resolution,
ResolvedDate = status == ReworkStatus.Resolved ? discoveredDate.AddDays(5) : null,
ResolutionNotes = status == ReworkStatus.Resolved ? "Recoated and re-inspected. Customer notified." : null,
CompanyId = company.Id,
CreatedAt = discoveredDate.AddDays(1),
});
}
if (records.Count == 0) return 0;
await _context.Set<ReworkRecord>().AddRangeAsync(records);
await _context.SaveChangesAsync();
return records.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Deposits
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds ~18 deposits spread across commercial and individual jobs.
/// Deposits on jobs that have paid invoices are marked applied; deposits on
/// open or pending jobs remain unapplied, giving the Deposits module a realistic mix.
/// </summary>
private async Task<int> SeedDepositsAsync(Company company)
{
var existingCount = await _context.Set<Deposit>()
.IgnoreQueryFilters()
.CountAsync(d => d.CompanyId == company.Id && !d.IsDeleted);
if (existingCount > 0) return 0;
// Grab jobs with their invoices and customer info
var jobsWithInvoices = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
var invoicesByJob = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == company.Id && !i.IsDeleted && i.JobId.HasValue)
.ToDictionaryAsync(i => i.JobId!.Value, i => i);
// Checking account for deposit account ID
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountNumber == "1000");
if (jobsWithInvoices.Count == 0) return 0;
// Deposit amounts and methods for variety
decimal[] amounts = [150m, 200m, 250m, 300m, 400m, 500m, 750m, 100m, 350m];
var methods = new[] { PaymentMethod.Cash, PaymentMethod.Check, PaymentMethod.CreditDebitCard,
PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.Cash };
var deposits = new List<Deposit>();
int depSeq = 1;
// Seed a deposit for every 3rd job (deterministic ~33% coverage)
foreach (var (job, idx) in jobsWithInvoices.Select((j, i) => (j, i)))
{
if (idx % 3 != 0) continue;
if (deposits.Count >= 20) break;
var receivedDate = job.CreatedAt.AddDays(1);
decimal amount = amounts[idx % amounts.Length];
var method = methods[idx % methods.Length];
string yymm = receivedDate.ToString("yyMM");
string receipt = $"DEP-{yymm}-{depSeq:D4}";
invoicesByJob.TryGetValue(job.Id, out var invoice);
bool isApplied = invoice != null
&& invoice.Status is InvoiceStatus.Paid or InvoiceStatus.PartiallyPaid;
deposits.Add(new Deposit
{
ReceiptNumber = receipt,
CustomerId = job.CustomerId,
JobId = job.Id,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = method == PaymentMethod.Check ? $"CHK #{4000 + idx}" : null,
Notes = isApplied ? "Applied to invoice on creation." : null,
DepositAccountId = checkingAccount?.Id,
AppliedToInvoiceId = isApplied ? invoice!.Id : null,
AppliedDate = isApplied ? invoice!.InvoiceDate : null,
CompanyId = company.Id,
CreatedAt = receivedDate,
});
depSeq++;
}
if (deposits.Count == 0) return 0;
await _context.Set<Deposit>().AddRangeAsync(deposits);
await _context.SaveChangesAsync();
return deposits.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Bank Reconciliations
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds 3 completed monthly bank reconciliations on the primary checking account
/// covering the 3 full calendar months immediately before the current month.
/// Balances are approximated from the account's opening balance plus a realistic
/// monthly cash-flow trajectory, so they look plausible without needing to match
/// actual transaction totals (which vary per reset).
/// </summary>
private async Task<int> SeedBankReconciliationsAsync(Company company)
{
var existingCount = await _context.Set<BankReconciliation>()
.IgnoreQueryFilters()
.CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
if (existingCount > 0) return 0;
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountNumber == "1000");
if (checkingAccount == null) return 0;
var now = DateTime.UtcNow;
var reconciliations = new List<BankReconciliation>();
// Build 3 months of completed reconciliations ending the last day of each month.
// Balances step up ~$4-6k per month to reflect a moderately profitable shop.
decimal[] endingBalances = [62_400m, 67_800m, 73_250m];
for (int i = 3; i >= 1; i--)
{
var statementMonth = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
var statementDate = new DateTime(statementMonth.Year, statementMonth.Month,
DateTime.DaysInMonth(statementMonth.Year, statementMonth.Month),
23, 59, 59, DateTimeKind.Utc);
int balIdx = 3 - i; // 0,1,2
decimal ending = endingBalances[balIdx];
decimal beginning = balIdx == 0 ? ending - 5_200m : endingBalances[balIdx - 1];
reconciliations.Add(new BankReconciliation
{
AccountId = checkingAccount.Id,
StatementDate = statementDate,
BeginningBalance = beginning,
EndingBalance = ending,
Status = BankReconciliationStatus.Completed,
CompletedAt = statementDate.AddDays(3),
CompletedBy = "demo@powdercoatinglogix.com",
Notes = $"Monthly close — {statementMonth:MMMM yyyy}. All transactions cleared.",
CompanyId = company.Id,
CreatedAt = statementDate.AddDays(3),
});
}
await _context.Set<BankReconciliation>().AddRangeAsync(reconciliations);
await _context.SaveChangesAsync();
return reconciliations.Count;
}
}