187 lines
8.6 KiB
C#
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;
|
|
}
|
|
}
|