6eb7be0193
- 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>
412 lines
22 KiB
C#
412 lines
22 KiB
C#
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;
|
|
}
|
|
}
|