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:
@@ -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–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–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–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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user