using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Health; using PowderCoating.Application.Interfaces; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Infrastructure.Services; /// /// 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. /// public class CompanyConfigHealthService : ICompanyConfigHealthService { private readonly ApplicationDbContext _db; public CompanyConfigHealthService(ApplicationDbContext db) { _db = db; } public async Task CheckAsync(int companyId) { var batch = await CheckBatchAsync([companyId]); return batch.TryGetValue(companyId, out var result) ? result : new CompanyConfigHealth { CompanyId = companyId }; } public async Task> CheckBatchAsync(IEnumerable companyIds) { var ids = companyIds.ToList(); if (ids.Count == 0) return new Dictionary(); // --- Batch queries (one per check type) --- var hasAccounts = new HashSet(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(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(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(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(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(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(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(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(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(); 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; } }