Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/CompanyConfigHealthService.cs
T
2026-04-23 21:38:24 -04:00

187 lines
8.6 KiB
C#

using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Health;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Runs configuration health checks for tenant companies. Each check type is a single
/// batch DB query grouped by CompanyId — O(checks) round-trips regardless of company count.
/// </summary>
public class CompanyConfigHealthService : ICompanyConfigHealthService
{
private readonly ApplicationDbContext _db;
public CompanyConfigHealthService(ApplicationDbContext db)
{
_db = db;
}
public async Task<CompanyConfigHealth> CheckAsync(int companyId)
{
var batch = await CheckBatchAsync([companyId]);
return batch.TryGetValue(companyId, out var result)
? result
: new CompanyConfigHealth { CompanyId = companyId };
}
public async Task<Dictionary<int, CompanyConfigHealth>> CheckBatchAsync(IEnumerable<int> companyIds)
{
var ids = companyIds.ToList();
if (ids.Count == 0) return new Dictionary<int, CompanyConfigHealth>();
// --- Batch queries (one per check type) ---
var hasAccounts = new HashSet<int>(await _db.Accounts
.AsNoTracking().IgnoreQueryFilters()
.Where(a => ids.Contains(a.CompanyId) && a.IsActive && !a.IsDeleted)
.Select(a => a.CompanyId).Distinct().ToListAsync());
var hasPrepServices = new HashSet<int>(await _db.PrepServices
.AsNoTracking().IgnoreQueryFilters()
.Where(p => ids.Contains(p.CompanyId) && p.IsActive && !p.IsDeleted)
.Select(p => p.CompanyId).Distinct().ToListAsync());
var hasOperatingCosts = new HashSet<int>(await _db.CompanyOperatingCosts
.AsNoTracking().IgnoreQueryFilters()
.Where(o => ids.Contains(o.CompanyId) && !o.IsDeleted)
.Select(o => o.CompanyId).Distinct().ToListAsync());
// Calibrated = has named blast setups OR (operating costs with CompressorCfm > 0 OR BlastRateSqFtPerHourOverride set)
var hasNamedBlastSetups = new HashSet<int>(await _db.CompanyBlastSetups
.AsNoTracking().IgnoreQueryFilters()
.Where(b => ids.Contains(b.CompanyId) && !b.IsDeleted && b.IsActive)
.Select(b => b.CompanyId).Distinct().ToListAsync());
var hasQuotingCalibration = new HashSet<int>(hasNamedBlastSetups.Concat(
await _db.CompanyOperatingCosts
.AsNoTracking().IgnoreQueryFilters()
.Where(o => ids.Contains(o.CompanyId) && !o.IsDeleted
&& (o.CompressorCfm > 0 || o.BlastRateSqFtPerHourOverride != null))
.Select(o => o.CompanyId).Distinct().ToListAsync()));
var wizardDone = new HashSet<int>(await _db.CompanyPreferences
.AsNoTracking().IgnoreQueryFilters()
.Where(p => ids.Contains(p.CompanyId) && !p.IsDeleted && p.SetupWizardCompleted)
.Select(p => p.CompanyId).Distinct().ToListAsync());
var hasInventory = new HashSet<int>(await _db.InventoryItems
.AsNoTracking().IgnoreQueryFilters()
.Where(i => ids.Contains(i.CompanyId) && i.IsActive && !i.IsDeleted)
.Select(i => i.CompanyId).Distinct().ToListAsync());
var hasCatalogItems = new HashSet<int>(await _db.CatalogItems
.AsNoTracking().IgnoreQueryFilters()
.Where(c => ids.Contains(c.CompanyId) && c.IsActive && !c.IsDeleted)
.Select(c => c.CompanyId).Distinct().ToListAsync());
var hasOvenCosts = new HashSet<int>(await _db.OvenCosts
.AsNoTracking().IgnoreQueryFilters()
.Where(o => ids.Contains(o.CompanyId) && o.IsActive && !o.IsDeleted)
.Select(o => o.CompanyId).Distinct().ToListAsync());
// --- Build result per company ---
var results = new Dictionary<int, CompanyConfigHealth>();
foreach (var id in ids)
{
var health = new CompanyConfigHealth { CompanyId = id };
if (!hasAccounts.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_ACCOUNTS",
Title = "No Chart of Accounts",
Detail = "Invoicing, expense tracking, and financial reports require at least a basic chart of accounts.",
Severity = ConfigIssueSeverity.Critical,
FixPath = "/Accounts",
FixLabel = "Set Up Accounts"
});
if (!hasOperatingCosts.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_OPERATING_COSTS",
Title = "Operating Costs Not Configured",
Detail = "Labor, equipment, and overhead rates are missing — job pricing calculations will produce $0 estimates.",
Severity = ConfigIssueSeverity.Critical,
FixPath = "/CompanySettings#operating-costs",
FixLabel = "Configure Costs"
});
// Only prompt for calibration if operating costs are already set up
if (hasOperatingCosts.Contains(id) && !hasQuotingCalibration.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "QUOTING_NOT_CALIBRATED",
Title = "No Sandblasting Setups Found",
Detail = "Your shop's blast equipment hasn't been configured. AI photo quotes and calculated item time estimates will use generic industry averages instead of your actual throughput.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/CompanySettings#quoting-calibration",
FixLabel = "Configure Blast Setups"
});
if (!hasPrepServices.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_PREP_SERVICES",
Title = "No Prep Services Defined",
Detail = "The prep services step is skipped in the quote/job wizard. Workers won't see sandblasting, masking, or other prep options.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/CompanySettings#prep-services-lookup",
FixLabel = "Add Prep Services"
});
if (!wizardDone.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "WIZARD_INCOMPLETE",
Title = "Setup Wizard Not Completed",
Detail = "The initial company configuration wizard was never finished. Some rates and defaults may be missing.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/SetupWizard/Step?step=1",
FixLabel = "Resume Wizard"
});
if (!hasInventory.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_INVENTORY",
Title = "No Inventory Items",
Detail = "No powder or material items are in inventory. The coating layer step will have no powder options to select.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/Inventory",
FixLabel = "Add Inventory"
});
if (!hasCatalogItems.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_CATALOG",
Title = "No Catalog Items",
Detail = "No pre-defined products or services exist. Quotes can still be created with custom items.",
Severity = ConfigIssueSeverity.Info,
FixPath = "/CatalogItems",
FixLabel = "Add Catalog Items"
});
if (!hasOvenCosts.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_OVENS",
Title = "No Named Ovens Configured",
Detail = "The oven scheduler will use the default rate only. Capacity planning and batch suggestions require named ovens.",
Severity = ConfigIssueSeverity.Info,
FixPath = "/CompanySettings#operating-costs",
FixLabel = "Configure Ovens"
});
results[id] = health;
}
return results;
}
}