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:
@@ -755,17 +755,20 @@ public class FinancialReportService : IFinancialReportService
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var openBills = await _context.Bills
|
||||
// BalanceDue is a computed property — filter on persisted columns in SQL,
|
||||
// then apply BalanceDue > 0 client-side after materialisation.
|
||||
var openBills = (await _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Where(b => b.CompanyId == companyId
|
||||
&& b.Status != BillStatus.Draft
|
||||
&& b.Status != BillStatus.Voided
|
||||
&& b.Status != BillStatus.Paid
|
||||
&& b.BillDate <= asOfEnd
|
||||
&& b.BalanceDue > 0)
|
||||
&& b.BillDate <= asOfEnd)
|
||||
.OrderBy(b => b.Vendor!.CompanyName)
|
||||
.ThenBy(b => b.DueDate)
|
||||
.ToListAsync();
|
||||
.ToListAsync())
|
||||
.Where(b => b.BalanceDue > 0)
|
||||
.ToList();
|
||||
|
||||
static string AgingBucket(int d) => d switch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 90 days of facility clock-in/clock-out records for the 3 demo employees
|
||||
/// (weekdays only), with realistic variation: full days, half days, late arrivals,
|
||||
/// early dismissals, and random days off throughout the period.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All times are stored in UTC. The shop operates on EST (UTC-5), so a 7:30 AM
|
||||
/// clock-in becomes 12:30 UTC, and a 5:00 PM clock-out becomes 22:00 UTC.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pattern for each (employee, weekday) is deterministic — derived from a
|
||||
/// fixed hash of the employee index and day offset — so a reseed always produces
|
||||
/// the same schedule. This prevents reports from looking different on every reset.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Day patterns (approximate distribution):
|
||||
/// • Full day (7:30–5:00, ~8.5 h paid) — ~55% of weekdays
|
||||
/// • Late arrival (9:00–10:00 AM in, normal out) — ~15%
|
||||
/// • Early dismissal (normal in, 1:30–3:00 PM out) — ~12%
|
||||
/// • Half day (normal in, 12:00–12:30 PM out, ~4 h) — ~10%
|
||||
/// • Day off (no entry generated) — ~8%
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotency: returns 0 immediately if any clock entries already exist for
|
||||
/// this company.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> SeedEmployeeClockEntriesAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<EmployeeClockEntry>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(e => e.CompanyId == company.Id && !e.IsDeleted);
|
||||
|
||||
if (existingCount > 0) return 0;
|
||||
|
||||
var employees = await _userManager.Users
|
||||
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id)
|
||||
.OrderBy(u => u.Email)
|
||||
.ToListAsync();
|
||||
|
||||
if (employees.Count == 0) return 0;
|
||||
|
||||
var now = DateTime.UtcNow.Date;
|
||||
var entries = new List<EmployeeClockEntry>();
|
||||
|
||||
// Notes pools for each scenario, giving the time-clock view narrative variety.
|
||||
string[] lateNotes = ["Dropped kids at school", "Doctor appointment AM", "Car trouble — called ahead", "Traffic on I-40", "Dentist — morning slot"];
|
||||
string[] earlyNotes = ["Picking up parts from supplier", "Kid pickup from school", "Doctor appointment PM", "Personal errand — approved", "Left early — make-up hours tomorrow"];
|
||||
string[] halfDayNotes = ["Half day — personal time", "Dentist then half day", "Training seminar AM only", "Family obligation — half day PTO", "Half day approved by manager"];
|
||||
string[] dayOffNotes = []; // no entry = day off, no note needed
|
||||
|
||||
for (int empIdx = 0; empIdx < employees.Count; empIdx++)
|
||||
{
|
||||
var emp = employees[empIdx];
|
||||
|
||||
for (int daysBack = 90; daysBack >= 1; daysBack--)
|
||||
{
|
||||
var date = now.AddDays(-daysBack);
|
||||
|
||||
// Skip weekends
|
||||
if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
|
||||
continue;
|
||||
|
||||
// Deterministic pattern per (employee, day) — no Random state to maintain.
|
||||
int hash = (empIdx * 37 + daysBack * 13) % 100;
|
||||
|
||||
// ~8% day off
|
||||
if (hash < 8) continue;
|
||||
|
||||
// Base clock-in: 7:30 AM EST (12:30 UTC) ± small jitter
|
||||
// Base clock-out: 5:00 PM EST (22:00 UTC) ± small jitter
|
||||
int inMinuteUtc = 12 * 60 + 30 + (hash % 15); // 12:30–12:44 UTC
|
||||
int outMinuteUtc = 22 * 60 + (hash % 20); // 22:00–22:19 UTC
|
||||
|
||||
string? noteText = null;
|
||||
|
||||
if (hash < 8)
|
||||
{
|
||||
// day off — already handled above
|
||||
}
|
||||
else if (hash < 18)
|
||||
{
|
||||
// ~10% half day — leave around noon
|
||||
outMinuteUtc = 16 * 60 + 30 + (hash % 30); // 16:30–16:59 UTC = 11:30 AM–11:59 AM EST
|
||||
noteText = halfDayNotes[hash % halfDayNotes.Length];
|
||||
}
|
||||
else if (hash < 33)
|
||||
{
|
||||
// ~15% late arrival — arrive 9:00–10:30 AM EST (14:00–15:30 UTC)
|
||||
inMinuteUtc = 14 * 60 + (hash % 90); // 14:00–15:29 UTC
|
||||
noteText = lateNotes[hash % lateNotes.Length];
|
||||
}
|
||||
else if (hash < 45)
|
||||
{
|
||||
// ~12% early dismissal — leave 1:30–3:00 PM EST (18:30–20:00 UTC)
|
||||
outMinuteUtc = 18 * 60 + 30 + (hash % 90); // 18:30–19:59 UTC
|
||||
noteText = earlyNotes[hash % earlyNotes.Length];
|
||||
}
|
||||
// else: ~55% full day — use base in/out as-is
|
||||
|
||||
var clockIn = date.AddMinutes(inMinuteUtc);
|
||||
var clockOut = date.AddMinutes(outMinuteUtc);
|
||||
|
||||
// Safety: never let clockOut <= clockIn (can happen on very short half-days)
|
||||
if (clockOut <= clockIn) clockOut = clockIn.AddHours(4);
|
||||
|
||||
var hours = Math.Round((decimal)(clockOut - clockIn).TotalHours, 2);
|
||||
|
||||
entries.Add(new EmployeeClockEntry
|
||||
{
|
||||
UserId = emp.Id,
|
||||
ClockInTime = clockIn,
|
||||
ClockOutTime = clockOut,
|
||||
HoursWorked = hours,
|
||||
EntryType = ClockEntryType.Work,
|
||||
Notes = noteText,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = clockIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Count == 0) return 0;
|
||||
|
||||
await _context.Set<EmployeeClockEntry>().AddRangeAsync(entries);
|
||||
await _context.SaveChangesAsync();
|
||||
return entries.Count;
|
||||
}
|
||||
}
|
||||
@@ -6,27 +6,29 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds customers at a rate of 15 per calendar month from January of the current year
|
||||
/// through the current month, mimicking a real shop that acquires customers steadily
|
||||
/// as the year progresses. Seeding in January produces 15 customers; seeding in June
|
||||
/// produces 90 (15 × 6 months).
|
||||
/// Seeds customers at a random rate of 2-16 per calendar month across a date window that makes
|
||||
/// the shop look like an ongoing business regardless of when the reset happens:
|
||||
/// <list type="bullet">
|
||||
/// <item>April through December reset: window starts January 1 of the current year.</item>
|
||||
/// <item>January through March reset: window starts 6 months before the current month
|
||||
/// (reaching into the prior year) so the shop does not appear brand-new.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
|
||||
/// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/>
|
||||
/// maps customer indices 0–9 to specific commercial price profiles, so the commercial
|
||||
/// maps customer indices 0–9 to specific commercial price profiles, so the commercial
|
||||
/// anchors must always occupy the lowest database IDs for this company.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Remaining slots in each month (15 − anchors_that_month) are filled with procedurally
|
||||
/// generated individual customers drawn from NC-area name and city pools, so the
|
||||
/// New Customers per Month chart shows a consistent 15-bar pattern regardless of
|
||||
/// the reseed date.
|
||||
/// Anchors are spread evenly across the full window so that commercial accounts appear as
|
||||
/// established relationships. Procedural individual customers fill remaining slots to reach
|
||||
/// each month's random target.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotency: returns 0 immediately if any non-deleted customers already exist for
|
||||
/// this company (they are removed by the reset sweep before a full reseed).
|
||||
/// this company (removed by the reset sweep before a full reseed).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed customers for.</param>
|
||||
@@ -106,23 +108,53 @@ public partial class SeedDataService
|
||||
Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"),
|
||||
Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build"),
|
||||
Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC", "27540", "Outdoor furniture set, 6 chairs and table"),
|
||||
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"),
|
||||
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"),
|
||||
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"),
|
||||
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"),
|
||||
Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"),
|
||||
Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate"),
|
||||
Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly"),
|
||||
Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black"),
|
||||
Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black"),
|
||||
};
|
||||
|
||||
// Spread anchors evenly from Jan 1 of this year through the current month so the
|
||||
// New Customers chart shows them distributed rather than all in one bar.
|
||||
int currentMonth = now.Month;
|
||||
// ── Date window ───────────────────────────────────────────────────────
|
||||
// Post-Q1 (Apr–Dec): start from January 1 of the current year so the full
|
||||
// year-to-date story is visible in charts.
|
||||
// Q1 (Jan–Mar): look back 6 months into the prior year so the shop does not
|
||||
// appear to have opened the day of the reset.
|
||||
var windowStart = now.Month <= 3
|
||||
? new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-6)
|
||||
: new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
int windowMonths = (now.Year - windowStart.Year) * 12
|
||||
+ (now.Month - windowStart.Month) + 1;
|
||||
|
||||
var rng = new Random();
|
||||
|
||||
// Each month gets a random target between 2 and 16 so the customer-growth
|
||||
// graph looks like a real shop instead of a perfectly flat 15/month line.
|
||||
var monthlyTargets = Enumerable.Range(0, windowMonths)
|
||||
.Select(_ => rng.Next(2, 17))
|
||||
.ToArray();
|
||||
|
||||
// Spread anchors evenly across the full window so that commercial accounts
|
||||
// appear as established customers rather than all clustering in one month.
|
||||
for (int i = 0; i < anchors.Count; i++)
|
||||
{
|
||||
int month = 1 + (i * currentMonth / anchors.Count);
|
||||
month = Math.Clamp(month, 1, currentMonth);
|
||||
int day = 3 + (i % 20);
|
||||
anchors[i].CreatedAt = new DateTime(now.Year, month, day, 8, 0, 0, DateTimeKind.Utc);
|
||||
int slot = i * windowMonths / anchors.Count;
|
||||
slot = Math.Clamp(slot, 0, windowMonths - 1);
|
||||
var baseDate = windowStart.AddMonths(slot);
|
||||
int day = Math.Min(3 + (i % 20), DateTime.DaysInMonth(baseDate.Year, baseDate.Month));
|
||||
anchors[i].CreatedAt = new DateTime(baseDate.Year, baseDate.Month, day, 8, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
// Count anchors per month slot to know how many procedural customers each
|
||||
// month still needs to reach that slot's random target.
|
||||
var anchorsPerSlot = new int[windowMonths];
|
||||
foreach (var a in anchors)
|
||||
{
|
||||
int slot = (a.CreatedAt.Year - windowStart.Year) * 12
|
||||
+ (a.CreatedAt.Month - windowStart.Month);
|
||||
anchorsPerSlot[Math.Clamp(slot, 0, windowMonths - 1)]++;
|
||||
}
|
||||
|
||||
// Insert anchors in deterministic order (commercial first, then individual).
|
||||
@@ -170,23 +202,16 @@ public partial class SeedDataService
|
||||
"27591", "27520", "27597", "27312", "27546" };
|
||||
string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" };
|
||||
|
||||
// Count anchors already assigned to each calendar month
|
||||
var anchorsPerMonth = new int[currentMonth + 1];
|
||||
foreach (var a in anchors)
|
||||
{
|
||||
if (a.CreatedAt.Month >= 1 && a.CreatedAt.Month <= currentMonth)
|
||||
anchorsPerMonth[a.CreatedAt.Month]++;
|
||||
}
|
||||
|
||||
// Fill each month to exactly 15 customers using procedurally generated individuals.
|
||||
// These go into db after the 27 anchors, so job seeder indices 27+ get 0 jobs (default case).
|
||||
// Fill each month slot to the target using procedurally generated individual customers.
|
||||
int genIdx = 0;
|
||||
for (int month = 1; month <= currentMonth; month++)
|
||||
for (int slot = 0; slot < windowMonths; slot++)
|
||||
{
|
||||
int needed = Math.Max(0, 15 - anchorsPerMonth[month]);
|
||||
var monthStart = new DateTime(now.Year, month, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||
int needed = Math.Max(0, monthlyTargets[slot] - anchorsPerSlot[slot]);
|
||||
var monthDate = windowStart.AddMonths(slot);
|
||||
int daysInMo = DateTime.DaysInMonth(monthDate.Year, monthDate.Month);
|
||||
var monthBase = new DateTime(monthDate.Year, monthDate.Month, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
for (int slot = 0; slot < needed; slot++, genIdx++)
|
||||
for (int slotI = 0; slotI < needed; slotI++, genIdx++)
|
||||
{
|
||||
string fn = firstNames[genIdx % firstNames.Length];
|
||||
string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length];
|
||||
@@ -194,7 +219,9 @@ public partial class SeedDataService
|
||||
string zip = zips[genIdx % zips.Length];
|
||||
string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.Length]}";
|
||||
string phone = $"({600 + genIdx % 400}) {200 + genIdx % 800:D3}-{genIdx % 9000 + 1000:D4}";
|
||||
int day = needed > 1 ? 1 + (slot * 27 / needed) : 14;
|
||||
int day = needed > 1
|
||||
? Math.Min(1 + (slotI * (daysInMo - 2) / needed), daysInMo - 1)
|
||||
: 14;
|
||||
|
||||
var gen = new Customer
|
||||
{
|
||||
@@ -209,7 +236,7 @@ public partial class SeedDataService
|
||||
PaymentTerms = "Due on receipt",
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = monthStart.AddDays(day),
|
||||
CreatedAt = monthBase.AddDays(day),
|
||||
};
|
||||
|
||||
try
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,157 +6,122 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds a plausible status-transition history for every job belonging to the company,
|
||||
/// reconstructing the sequence of transitions a job must have passed through to reach
|
||||
/// its current status.
|
||||
/// Seeds <see cref="JobStatusHistory"/> transition records for every completed, delivered,
|
||||
/// or ready-for-pickup job so the Job Cycle Time report can calculate time-per-stage data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotency: returns 0 immediately if any non-deleted history rows already exist for
|
||||
/// this company.
|
||||
/// For each qualifying job the seeder builds a realistic stage sequence:
|
||||
/// PENDING → IN_PREPARATION → (SANDBLASTING if any item requires it)
|
||||
/// → (MASKING_TAPING if any item requires it) → CLEANING → IN_OVEN
|
||||
/// → COATING → CURING → QUALITY_CHECK → [terminal status].
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The method does not record arbitrary transitions — it follows the canonical 14-step
|
||||
/// pipeline array (<c>PENDING → QUOTED → APPROVED → … → DELIVERED</c>) and generates
|
||||
/// one <see cref="JobStatusHistory"/> row per transition step, from <c>PENDING</c> up to
|
||||
/// and including the job's current status.
|
||||
/// 85 % of the job's total cycle time (CreatedAt → CompletedDate) is
|
||||
/// distributed across work stages using fixed per-stage weights that reflect realistic
|
||||
/// relative durations (e.g. SANDBLASTING > CLEANING). The remaining 15 %
|
||||
/// is left as residual "terminal status" time, which surfaces correctly in the report's
|
||||
/// last-entry formula <c>(job.CompletedDate − last.ChangedDate)</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Terminal side-branch statuses are handled explicitly:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ON_HOLD</c> — assumed to have reached <c>QUALITY_CHECK</c> before pausing.</item>
|
||||
/// <item><c>CANCELLED</c> — assumed to have been cancelled from <c>IN_PREPARATION</c>.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Transition timestamps are spread ~6 hours apart starting from <c>job.CreatedAt</c>.
|
||||
/// This is an approximation chosen for demo realism; actual production transitions record
|
||||
/// the wall-clock time at which a user changes the status. A safety clamp prevents any
|
||||
/// generated timestamp from exceeding <c>DateTime.UtcNow</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All history rows are batched into a single <c>AddRangeAsync / SaveChangesAsync</c>
|
||||
/// call for performance, since the total count can be several hundred rows (50 jobs × up
|
||||
/// to 14 transitions each).
|
||||
/// Idempotency: returns 0 immediately if any history records already exist for
|
||||
/// this company, matching the pattern used by all other partial seeders.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed job status history for.</param>
|
||||
/// <returns>Total number of history rows inserted, or 0 if already seeded or no jobs exist.</returns>
|
||||
/// <param name="company">The tenant company to seed history for.</param>
|
||||
/// <returns>Number of history records inserted, or 0 if already seeded.</returns>
|
||||
private async Task<int> SeedJobStatusHistoryAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<JobStatusHistory>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted);
|
||||
|
||||
if (existingCount > 0)
|
||||
return 0;
|
||||
if (existingCount > 0) return 0;
|
||||
|
||||
// Load all job status lookups into a code → id map
|
||||
var statusMap = await _context.Set<JobStatusLookup>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == company.Id)
|
||||
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
|
||||
// Only completed-terminal jobs have a meaningful CompletedDate to calculate cycle time.
|
||||
// CANCELLED is excluded — the report cares only about successfully finished work.
|
||||
var terminalCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||
|
||||
// Load jobs with their current status
|
||||
var jobs = await _context.Set<Job>()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(j => j.JobItems)
|
||||
.Include(j => j.JobStatus)
|
||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
|
||||
.OrderBy(j => j.Id)
|
||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted && j.CompletedDate.HasValue)
|
||||
.ToListAsync();
|
||||
|
||||
if (jobs.Count == 0 || statusMap.Count == 0)
|
||||
return 0;
|
||||
jobs = jobs.Where(j => terminalCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||
if (jobs.Count == 0) return 0;
|
||||
|
||||
// Ordered pipeline — each status code in the order a job advances through it.
|
||||
// ON_HOLD and CANCELLED are terminal side-branches handled separately.
|
||||
var pipeline = new[]
|
||||
{
|
||||
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION",
|
||||
"SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN",
|
||||
"COATING", "CURING", "QUALITY_CHECK",
|
||||
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
|
||||
};
|
||||
var statuses = await _context.Set<JobStatusLookup>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == company.Id)
|
||||
.ToDictionaryAsync(s => s.StatusCode, s => s);
|
||||
|
||||
var pipelineIndex = pipeline
|
||||
.Select((code, idx) => (code, idx))
|
||||
.ToDictionary(t => t.code, t => t.idx);
|
||||
|
||||
var history = new List<JobStatusHistory>();
|
||||
var now = DateTime.UtcNow;
|
||||
var records = new List<JobStatusHistory>();
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var currentCode = job.JobStatus.StatusCode;
|
||||
var totalSeconds = (job.CompletedDate!.Value - job.CreatedAt).TotalSeconds;
|
||||
if (totalSeconds < 60) continue; // skip malformed dates
|
||||
|
||||
// Determine the sequence of transitions that happened to reach current state.
|
||||
// For ON_HOLD: assume it came from QUALITY_CHECK before going on hold.
|
||||
// For CANCELLED: assume cancelled from APPROVED or IN_PREPARATION.
|
||||
string[] codesTraversed;
|
||||
var needsSand = job.JobItems.Any(i => i.RequiresSandblasting);
|
||||
var needsMask = job.JobItems.Any(i => i.RequiresMasking);
|
||||
|
||||
if (currentCode == "ON_HOLD")
|
||||
// Build ordered stage list; the last element is the terminal "to" status only —
|
||||
// it never appears as a "from" and is not assigned a work weight.
|
||||
var stages = new List<string> { "PENDING", "IN_PREPARATION" };
|
||||
if (needsSand) stages.Add("SANDBLASTING");
|
||||
if (needsMask) stages.Add("MASKING_TAPING");
|
||||
stages.AddRange(new[] { "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" });
|
||||
stages.Add(job.JobStatus.StatusCode);
|
||||
|
||||
// Time weight for each "from" status — reflects typical relative hours in that stage.
|
||||
static double Weight(string code) => code switch
|
||||
{
|
||||
// Traversed up to QUALITY_CHECK then went ON_HOLD
|
||||
codesTraversed = [.. pipeline.Take(pipelineIndex["QUALITY_CHECK"] + 1), "ON_HOLD"];
|
||||
}
|
||||
else if (currentCode == "CANCELLED")
|
||||
"PENDING" => 2.0, // intake / scheduling buffer
|
||||
"IN_PREPARATION" => 1.5, // disassembly, hang, pre-inspect
|
||||
"SANDBLASTING" => 2.0, // media blast + blow-off
|
||||
"MASKING_TAPING" => 1.0, // tape & plug work
|
||||
"CLEANING" => 0.5, // chemical wash + dry
|
||||
"IN_OVEN" => 1.5, // pre-heat before coating
|
||||
"COATING" => 1.5, // powder application
|
||||
"CURING" => 1.0, // oven cure cycle
|
||||
"QUALITY_CHECK" => 0.5, // inspection & touch-up
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
// Work stages are all entries except the terminal "to" at the end.
|
||||
int n = stages.Count;
|
||||
var workWeights = stages.Take(n - 1).Select(Weight).ToList();
|
||||
double totalWeight = workWeights.Sum();
|
||||
// 85% of cycle time covers the work stages; 15% becomes terminal-status residual
|
||||
// so (job.CompletedDate − last.ChangedDate) produces a non-zero, plausible value.
|
||||
double workSeconds = totalSeconds * 0.85;
|
||||
|
||||
var currentDate = job.CreatedAt;
|
||||
for (int i = 0; i < n - 1; i++)
|
||||
{
|
||||
// Cancelled from IN_PREPARATION
|
||||
codesTraversed = ["PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "CANCELLED"];
|
||||
}
|
||||
else if (pipelineIndex.TryGetValue(currentCode, out int curIdx))
|
||||
{
|
||||
// Normal pipeline job — traversed from PENDING up to current status
|
||||
codesTraversed = pipeline.Take(curIdx + 1).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unknown status — just record a single PENDING → currentCode entry
|
||||
codesTraversed = ["PENDING", currentCode];
|
||||
}
|
||||
if (!statuses.TryGetValue(stages[i], out var fromLookup)) continue;
|
||||
if (!statuses.TryGetValue(stages[i + 1], out var toLookup)) continue;
|
||||
|
||||
// Spread transition dates backwards from job.CreatedAt.
|
||||
// Each step took roughly 4–8 hours, so transitions are spaced a few hours apart.
|
||||
// Jobs further along in the pipeline have older start dates.
|
||||
var stepCount = codesTraversed.Length - 1; // number of transitions
|
||||
if (stepCount <= 0) continue;
|
||||
currentDate = currentDate.AddSeconds(workSeconds * workWeights[i] / totalWeight);
|
||||
|
||||
// Job was created at job.CreatedAt; each transition is spaced ~6h apart
|
||||
// so the first transition (PENDING→QUOTED) happened ~6h after creation, etc.
|
||||
for (int t = 0; t < stepCount; t++)
|
||||
{
|
||||
var fromCode = codesTraversed[t];
|
||||
var toCode = codesTraversed[t + 1];
|
||||
|
||||
if (!statusMap.TryGetValue(fromCode, out int fromId)) continue;
|
||||
if (!statusMap.TryGetValue(toCode, out int toId)) continue;
|
||||
|
||||
// Spread: first transitions happened closer to job creation,
|
||||
// later ones closer to now. Add a few hours per step.
|
||||
var hoursOffset = (t + 1) * 6;
|
||||
var changedDate = job.CreatedAt.AddHours(hoursOffset);
|
||||
|
||||
// Don't let transitions exceed "now"
|
||||
if (changedDate > now) changedDate = now.AddMinutes(-(stepCount - t) * 10);
|
||||
|
||||
history.Add(new JobStatusHistory
|
||||
records.Add(new JobStatusHistory
|
||||
{
|
||||
JobId = job.Id,
|
||||
FromStatusId = fromId,
|
||||
ToStatusId = toId,
|
||||
ChangedDate = changedDate,
|
||||
Notes = null,
|
||||
FromStatusId = fromLookup.Id,
|
||||
ToStatusId = toLookup.Id,
|
||||
ChangedDate = currentDate,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = changedDate
|
||||
CreatedAt = currentDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (history.Count == 0) return 0;
|
||||
if (records.Count == 0) return 0;
|
||||
|
||||
await _context.Set<JobStatusHistory>().AddRangeAsync(history);
|
||||
await _context.Set<JobStatusHistory>().AddRangeAsync(records);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return history.Count;
|
||||
return records.Count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,12 @@ public partial class SeedDataService
|
||||
|
||||
// Tier 1 — pure leaf records (block nothing of their own)
|
||||
await Sweep<JobTimeEntry>(); // FK → Jobs (Cascade by convention — sweep before jobs)
|
||||
await Sweep<EmployeeClockEntry>(); // RESTRICT → ApplicationUser — must go before workers
|
||||
await Sweep<JobNote>(); // NO_ACTION → Jobs
|
||||
await Sweep<CustomerNote>(); // NO_ACTION → Customers
|
||||
await Sweep<ReworkRecord>(); // NO_ACTION → Jobs
|
||||
await Sweep<Deposit>(); // NO_ACTION → Customers, Jobs, Invoices, Quotes
|
||||
await Sweep<BankReconciliation>(); // FK → Accounts (accounts stay, but sweep for clean reset)
|
||||
await Sweep<OvenBatchItem>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
|
||||
await Sweep<PowderUsageLog>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
|
||||
await Sweep<InAppNotification>(); // NO_ACTION → Customers, Invoices, Quotes
|
||||
@@ -653,6 +659,18 @@ public partial class SeedDataService
|
||||
|
||||
if (workerUsers.Any())
|
||||
{
|
||||
// EmployeeClockEntry has DeleteBehavior.Restrict on UserId — delete
|
||||
// clock entries first so the user delete succeeds without a FK violation.
|
||||
var workerUserIds = workerUsers.Select(u => u.Id).ToList();
|
||||
var clockEntries = await _context.Set<EmployeeClockEntry>().IgnoreQueryFilters()
|
||||
.Where(e => workerUserIds.Contains(e.UserId)).ToListAsync();
|
||||
if (clockEntries.Any())
|
||||
{
|
||||
_context.Set<EmployeeClockEntry>().RemoveRange(clockEntries);
|
||||
await _context.SaveChangesAsync();
|
||||
details.Add($"✓ Removed {clockEntries.Count} clock entry/entries");
|
||||
}
|
||||
|
||||
foreach (var wu in workerUsers)
|
||||
await _userManager.DeleteAsync(wu);
|
||||
totalRemoved += workerUsers.Count;
|
||||
|
||||
@@ -7,32 +7,26 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical emails of the 5 demo shop workers. Used as fingerprints in RemoveSeedDataAsync
|
||||
/// to avoid needing a special "IsSeeded" flag on ApplicationUser.
|
||||
/// Canonical emails of the 3 demo employees (2 workers + 1 manager). Used as fingerprints
|
||||
/// in RemoveSeedDataAsync to avoid needing a special "IsSeeded" flag on ApplicationUser.
|
||||
/// </summary>
|
||||
internal static readonly string[] SeededWorkerEmails =
|
||||
[
|
||||
"mike.sanders@pcldemo.com",
|
||||
"jake.wilson@pcldemo.com",
|
||||
"sarah.brooks@pcldemo.com",
|
||||
"tyler.green@pcldemo.com",
|
||||
"chris.mason@pcldemo.com",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Seeds 5 named shop workers as ApplicationUser records for the demo company:
|
||||
/// Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector),
|
||||
/// Tyler Green (General Worker), and Chris Mason (Shop Lead).
|
||||
/// Seeds 3 named employees as ApplicationUser records for the demo company:
|
||||
/// Mike Sanders (Coater, Worker), Jake Wilson (Sandblaster, Worker),
|
||||
/// and Sarah Brooks (Shop Manager, Manager role with broader permissions).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Workers are ApplicationUser records with CompanyRole = ShopFloor and the
|
||||
/// Employee system role. They are seeded before jobs and time entries so that
|
||||
/// AssignedUserId on Job and UserId on JobTimeEntry can reference them.
|
||||
///
|
||||
/// Uses a consistent email domain (@pcldemo.com) that will never conflict with
|
||||
/// real user accounts, making them safe to identify and remove on Demo Reset.
|
||||
///
|
||||
/// Idempotency: bails early if any of the 5 worker emails already exist.
|
||||
/// Workers are seeded before jobs and time entries so that AssignedUserId on Job
|
||||
/// and UserId on JobTimeEntry and EmployeeClockEntry can reference them.
|
||||
/// Uses @pcldemo.com email domain — will never conflict with real accounts.
|
||||
/// Idempotency: bails early if any of the 3 emails already exist for this company.
|
||||
/// </remarks>
|
||||
private async Task<int> SeedShopWorkersAsync(Company company)
|
||||
{
|
||||
@@ -40,50 +34,71 @@ public partial class SeedDataService
|
||||
.AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
|
||||
if (anyExists) return 0;
|
||||
|
||||
const string defaultPassword = "Worker123!";
|
||||
const string pwd = "Worker123!Demo";
|
||||
var hireDate = DateTime.UtcNow.AddMonths(-18);
|
||||
int created = 0;
|
||||
|
||||
// (email, firstName, lastName, empNum, position, laborRate)
|
||||
var workers = new (string email, string fn, string ln, string emp, string pos, decimal rate)[]
|
||||
// ── 2 shop workers ────────────────────────────────────────────────────────
|
||||
foreach (var (email, fn, ln, emp, pos, rate) in new (string, string, string, string, string, decimal)[]
|
||||
{
|
||||
("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m),
|
||||
("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.00m),
|
||||
("sarah.brooks@pcldemo.com", "Sarah", "Brooks", "EMP-003", "Quality Inspector", 24.00m),
|
||||
("tyler.green@pcldemo.com", "Tyler", "Green", "EMP-004", "General Worker", 18.00m),
|
||||
("chris.mason@pcldemo.com", "Chris", "Mason", "EMP-005", "Shop Lead", 28.00m),
|
||||
};
|
||||
|
||||
foreach (var (email, fn, ln, emp, pos, rate) in workers)
|
||||
})
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user != null) continue;
|
||||
|
||||
user = new ApplicationUser
|
||||
if (await _userManager.FindByEmailAsync(email) != null) continue;
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
FirstName = fn,
|
||||
LastName = ln,
|
||||
EmployeeNumber = emp,
|
||||
UserName = email, Email = email,
|
||||
FirstName = fn, LastName = ln,
|
||||
EmployeeNumber = emp, Position = pos,
|
||||
Department = "Shop Floor",
|
||||
Position = pos,
|
||||
LaborCostPerHour = rate,
|
||||
EmailConfirmed = true,
|
||||
HireDate = DateTime.UtcNow.AddMonths(-12),
|
||||
IsActive = true,
|
||||
EmailConfirmed = true, IsActive = true,
|
||||
HireDate = hireDate,
|
||||
CompanyId = company.Id,
|
||||
CompanyRole = AppConstants.CompanyRoles.Worker,
|
||||
CanManageJobs = true,
|
||||
CanViewShopFloor = true,
|
||||
CreatedAt = DateTime.UtcNow.AddMonths(-12)
|
||||
CreatedAt = hireDate
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, defaultPassword);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var r = await _userManager.CreateAsync(user, pwd);
|
||||
if (!r.Succeeded)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to create {email}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
|
||||
await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
|
||||
created++;
|
||||
}
|
||||
|
||||
// ── 1 manager ─────────────────────────────────────────────────────────────
|
||||
const string managerEmail = "sarah.brooks@pcldemo.com";
|
||||
if (await _userManager.FindByEmailAsync(managerEmail) == null)
|
||||
{
|
||||
var mgr = new ApplicationUser
|
||||
{
|
||||
UserName = managerEmail, Email = managerEmail,
|
||||
FirstName = "Sarah", LastName = "Brooks",
|
||||
EmployeeNumber = "EMP-003", Position = "Shop Manager",
|
||||
Department = "Management",
|
||||
LaborCostPerHour = 32.00m,
|
||||
EmailConfirmed = true, IsActive = true,
|
||||
HireDate = hireDate.AddMonths(-6), // hired before the workers
|
||||
CompanyId = company.Id,
|
||||
CompanyRole = AppConstants.CompanyRoles.Manager,
|
||||
CanManageJobs = true, CanViewShopFloor = true,
|
||||
CanManageCustomers = true, CanCreateQuotes = true,
|
||||
CanApproveQuotes = true, CanManageCalendar = true,
|
||||
CanViewCalendar = true, CanManageProducts = true,
|
||||
CanViewProducts = true, CanManageEquipment = true,
|
||||
CanManageMaintenance = true, CanManageInventory = true,
|
||||
CanViewReports = true,
|
||||
CreatedAt = hireDate.AddMonths(-6)
|
||||
};
|
||||
var r = await _userManager.CreateAsync(mgr, pwd);
|
||||
if (!r.Succeeded)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to create {managerEmail}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
|
||||
await _userManager.AddToRoleAsync(mgr, AppConstants.Roles.Employee);
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
|
||||
@@ -427,6 +427,7 @@ public partial class SeedDataService : ISeedDataService
|
||||
await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company));
|
||||
await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company));
|
||||
await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company));
|
||||
await RunSeeder("Clock entries", details, errors, result, () => SeedEmployeeClockEntriesAsync(company));
|
||||
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
|
||||
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
|
||||
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
|
||||
@@ -478,6 +479,11 @@ public partial class SeedDataService : ISeedDataService
|
||||
catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); }
|
||||
|
||||
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
|
||||
await RunSeeder("Job notes", details, errors, result, () => SeedJobNotesAsync(company));
|
||||
await RunSeeder("Customer notes", details, errors, result, () => SeedCustomerNotesAsync(company));
|
||||
await RunSeeder("Rework records", details, errors, result, () => SeedReworkRecordsAsync(company));
|
||||
await RunSeeder("Deposits", details, errors, result, () => SeedDepositsAsync(company));
|
||||
await RunSeeder("Bank recon", details, errors, result, () => SeedBankReconciliationsAsync(company));
|
||||
|
||||
if (company.CompanyCode == "DEMO")
|
||||
{
|
||||
|
||||
@@ -181,6 +181,8 @@ public class CompanySettingsController : Controller
|
||||
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
|
||||
: null;
|
||||
|
||||
ViewBag.IsDemoCompany = company.CompanyCode == "DEMO";
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (FormatException fex)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Allows company admins logged into the DEMO company to reset demo data without
|
||||
/// switching to a SuperAdmin account. The CompanyCode guard ensures this action
|
||||
/// cannot affect any real tenant even if someone crafts a direct POST.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class DemoController : Controller
|
||||
{
|
||||
private readonly ISeedDataService _seedDataService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<DemoController> _logger;
|
||||
|
||||
public DemoController(
|
||||
ISeedDataService seedDataService,
|
||||
ITenantContext tenantContext,
|
||||
IUnitOfWork unitOfWork,
|
||||
ILogger<DemoController> logger)
|
||||
{
|
||||
_seedDataService = seedDataService;
|
||||
_tenantContext = tenantContext;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the demo company's seed data and redirects back to the dashboard.
|
||||
/// Fails fast if the current company is not the DEMO company.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResetDemoData()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
{
|
||||
TempData["ErrorMessage"] = "Unable to determine current company.";
|
||||
return RedirectToAction("Index", "CompanySettings");
|
||||
}
|
||||
|
||||
// Safety gate: only the DEMO company can use this action
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||
if (company == null || company.CompanyCode != "DEMO")
|
||||
{
|
||||
TempData["ErrorMessage"] = "Demo reset is only available for the DEMO company.";
|
||||
return RedirectToAction("Index", "CompanySettings");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var removeOptions = new RemoveSeedDataOptions
|
||||
{
|
||||
Customers = true,
|
||||
InventoryItems = true,
|
||||
Equipment = true,
|
||||
Catalog = true,
|
||||
Bills = true,
|
||||
Expenses = true,
|
||||
Workers = false,
|
||||
Vendors = true,
|
||||
NamedOvens = true,
|
||||
Appointments = true,
|
||||
ForceRemoveAll = true,
|
||||
};
|
||||
|
||||
var removeResult = await _seedDataService.RemoveSeedDataAsync(companyId.Value, removeOptions);
|
||||
if (!removeResult.Success)
|
||||
{
|
||||
TempData["ErrorMessage"] = $"Demo reset failed during wipe: {removeResult.Message}";
|
||||
return RedirectToAction("Index", "CompanySettings");
|
||||
}
|
||||
|
||||
var seedResult = await _seedDataService.SeedCompanyDataAsync(companyId.Value);
|
||||
|
||||
TempData["SuccessMessage"] = $"Demo data reset complete — {seedResult.ItemsSeeded} records refreshed with today’s dates.";
|
||||
|
||||
if (seedResult.Warnings.Any())
|
||||
TempData["WarningMessage"] = $"{seedResult.Warnings.Count} item(s) skipped during reseed (check Platform › Seed Data for details).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resetting demo data for company {CompanyId}", companyId);
|
||||
TempData["ErrorMessage"] = $"Demo reset encountered an error: {ex.Message}";
|
||||
}
|
||||
|
||||
return RedirectToAction("Index", "CompanySettings");
|
||||
}
|
||||
}
|
||||
@@ -220,11 +220,12 @@ public class ReportsController : Controller
|
||||
// Top customers by revenue
|
||||
var topCustomers = completedJobs
|
||||
.Where(j => j.Customer != null)
|
||||
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
|
||||
.GroupBy(j => j.Customer!.Id)
|
||||
.Select(g => new TopCustomerItem
|
||||
{
|
||||
Id = g.Key.Id,
|
||||
Name = g.Key.CompanyName,
|
||||
Id = g.Key,
|
||||
Name = g.First().Customer!.CompanyName
|
||||
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
|
||||
Revenue = g.Sum(j => j.FinalPrice),
|
||||
JobCount = g.Count()
|
||||
})
|
||||
@@ -447,7 +448,9 @@ public class ReportsController : Controller
|
||||
.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem
|
||||
{
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty,
|
||||
CustomerName = i.Customer?.CompanyName
|
||||
?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim()
|
||||
?? string.Empty,
|
||||
Amount = p.Amount,
|
||||
PaymentMethod = p.PaymentMethod.ToString(),
|
||||
PaymentDate = p.PaymentDate
|
||||
@@ -1360,8 +1363,15 @@ public class ReportsController : Controller
|
||||
monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m);
|
||||
}
|
||||
var topCustomers = completedJobs.Where(j => j.Customer != null)
|
||||
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
|
||||
.Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() })
|
||||
.GroupBy(j => j.Customer!.Id)
|
||||
.Select(g => new TopCustomerItem
|
||||
{
|
||||
Id = g.Key,
|
||||
Name = g.First().Customer!.CompanyName
|
||||
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
|
||||
Revenue = g.Sum(j => j.FinalPrice),
|
||||
JobCount = g.Count()
|
||||
})
|
||||
.OrderByDescending(x => x.Revenue).Take(5).ToList();
|
||||
var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName)
|
||||
.OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder)
|
||||
@@ -1415,8 +1425,15 @@ public class ReportsController : Controller
|
||||
.OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));
|
||||
var topCustomers = completedJobs.Where(j => j.Customer != null)
|
||||
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
|
||||
.Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() })
|
||||
.GroupBy(j => j.Customer!.Id)
|
||||
.Select(g => new TopCustomerItem
|
||||
{
|
||||
Id = g.Key,
|
||||
Name = g.First().Customer!.CompanyName
|
||||
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
|
||||
Revenue = g.Sum(j => j.FinalPrice),
|
||||
JobCount = g.Count()
|
||||
})
|
||||
.OrderByDescending(x => x.Revenue).Take(10).ToList();
|
||||
return View(new RevenueTrendsViewModel
|
||||
{
|
||||
@@ -1508,8 +1525,17 @@ public class ReportsController : Controller
|
||||
var customersWithMultiple = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1);
|
||||
var totalWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count();
|
||||
var retentionRate = totalWithJobs > 0 ? Math.Round((decimal)customersWithMultiple / totalWithJobs * 100, 1) : 0m;
|
||||
var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName })
|
||||
.Select(g => new CustomerLifetimeValueItem { CustomerName = g.Key.CompanyName, TotalRevenue = g.Sum(j => j.FinalPrice), JobCount = g.Count(), AvgOrderValue = g.Average(j => j.FinalPrice), FirstJobDate = g.Min(j => j.CreatedAt), LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) })
|
||||
var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id)
|
||||
.Select(g => new CustomerLifetimeValueItem
|
||||
{
|
||||
CustomerName = g.First().Customer!.CompanyName
|
||||
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
|
||||
TotalRevenue = g.Sum(j => j.FinalPrice),
|
||||
JobCount = g.Count(),
|
||||
AvgOrderValue = g.Average(j => j.FinalPrice),
|
||||
FirstJobDate = g.Min(j => j.CreatedAt),
|
||||
LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt)
|
||||
})
|
||||
.OrderByDescending(c => c.TotalRevenue).Take(10).ToList();
|
||||
var quotesByStatus = quotes.GroupBy(q => q.QuoteStatus.DisplayName).OrderBy(g => quotes.First(q => q.QuoteStatus.DisplayName == g.Key).QuoteStatus.DisplayOrder).ToDictionary(g => g.Key, g => g.Count());
|
||||
var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") };
|
||||
@@ -1549,7 +1575,7 @@ public class ReportsController : Controller
|
||||
|
||||
var agingBuckets = new List<AgingBucketItem> { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } };
|
||||
foreach (var inv in outstandingInvoices) { var days = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; agingBuckets[b].Amount += balance; agingBuckets[b].Count++; }
|
||||
var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty, Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
|
||||
var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim(), Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
|
||||
var topOutstanding = outstandingInvoices.Where(i => i.Customer != null).GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }).Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }).OrderByDescending(x => x.OutstandingBalance).Take(5).ToList();
|
||||
var paidWithDates = allInvoices.Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue).ToList();
|
||||
var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0;
|
||||
|
||||
@@ -2679,6 +2679,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (ViewBag.IsDemoCompany == true)
|
||||
{
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning bg-opacity-10 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-arrow-clockwise text-warning fs-5"></i>
|
||||
<strong>Demo Environment</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
This is the <strong>DEMO</strong> company. Use the button below to wipe and re-seed all
|
||||
demo data with fresh dates. Workers and system configuration are preserved.
|
||||
</p>
|
||||
<p class="text-muted small mb-3">
|
||||
Reset takes 10–30 seconds. You will be redirected here when complete.
|
||||
</p>
|
||||
<form asp-controller="Demo" asp-action="ResetDemoData" method="post"
|
||||
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').innerHTML='<span class=\'spinner-border spinner-border-sm me-2\'></span>Resetting…';">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Reset Demo Data
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
var _envName = _hostEnv.EnvironmentName; // Development, Staging, Production, etc.
|
||||
var _isNonProd = !_hostEnv.IsProduction();
|
||||
bool _isDemoCompany = false; // suppresses env banner for the demo tenant
|
||||
var _envColor = _envName.ToLower() switch
|
||||
{
|
||||
"development" => "#b45309", // amber-700
|
||||
@@ -55,6 +56,7 @@
|
||||
{
|
||||
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData?.Length > 0);
|
||||
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
|
||||
_isDemoCompany = company.CompanyCode == "DEMO";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,13 +73,17 @@
|
||||
if (companyId.HasValue && companyId.Value > 0)
|
||||
{
|
||||
var company = await UnitOfWork.Companies.GetByIdAsync(companyId.Value, ignoreQueryFilters: true);
|
||||
if (company != null && (!string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0)))
|
||||
if (company != null)
|
||||
{
|
||||
_isDemoCompany = company.CompanyCode == "DEMO";
|
||||
if (!string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0))
|
||||
{
|
||||
companyHasLogo = true;
|
||||
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular users AND company SuperAdmins see their real company name and logo
|
||||
@@ -89,6 +95,7 @@
|
||||
companyName = company.CompanyName;
|
||||
companyHasLogo = !string.IsNullOrEmpty(company.LogoFilePath) || (company.LogoData != null && company.LogoData.Length > 0);
|
||||
logoVersion = (company.UpdatedAt ?? DateTime.UtcNow).Ticks;
|
||||
_isDemoCompany = company.CompanyCode == "DEMO";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -153,7 +160,7 @@
|
||||
--sidebar-hover: rgba(255,255,255,0.07);
|
||||
--primary-color: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--env-banner-height: @(_isNonProd ? "32px" : "0px");
|
||||
--env-banner-height: @((_isNonProd && !_isDemoCompany) ? "32px" : "0px");
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -942,7 +949,7 @@
|
||||
<script>
|
||||
(function(){var s='@_serverNavMode',p=localStorage.getItem('pcl-nav-mode')||'ops';document.documentElement.dataset.navMode=s==='fin'?'fin':p;})();
|
||||
</script>
|
||||
@if (_isNonProd)
|
||||
@if (_isNonProd && !_isDemoCompany)
|
||||
{
|
||||
<div style="position:fixed;top:0;left:0;width:100%;height:var(--env-banner-height);z-index:2000;
|
||||
background:@_envColor;color:#fff;
|
||||
|
||||
Reference in New Issue
Block a user