From ad986561c91cfdf1cbbf72537ca91c6dc7ae6652 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 3 Jun 2026 15:57:46 -0400 Subject: [PATCH] Fix AI quote blast rate: single formula path, correct client preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: company-settings-lookups.js had its own baseByCfm/multiplier tables that were completely different from ShopCapabilityCalculator.cs, so the UI showed an inflated rate (e.g. 82 sqft/hr) while the AI prompt received the server-computed rate (e.g. 9 sqft/hr). - Add CompanySettingsController.DeriveBlastRate endpoint — thin GET that calls ShopCapabilityCalculator directly; now the single formula path - Delete all client-side formula code (baseByCfm, multiplier tables, deriveBlastRate) — ~30 lines removed - Modal live preview calls /CompanySettings/DeriveBlastRate with 250ms debounce instead of computing locally - Blast setup table uses setup.derivedRate from GetBlastSetups (already server-computed) instead of recalculating client-side - QuotesController.AiAnalyzeItem: when no blast setup is explicitly selected, fall back to the company's default blast setup so the configured rate is always used Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/CompanySettingsController.cs | 20 ++++++ .../Controllers/QuotesController.cs | 10 ++- .../wwwroot/js/company-settings-lookups.js | 61 ++++++++----------- .../wwwroot/js/item-wizard.js | 14 +++-- 4 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs index 2195575..3126406 100644 --- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs +++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs @@ -1726,6 +1726,26 @@ public class CompanySettingsController : Controller #region Blast Setups + /// + /// Single authoritative blast-rate calculation endpoint. Takes equipment parameters and + /// returns the sqft/hr rate using the same ShopCapabilityCalculator formula the AI uses. + /// The modal live preview calls this instead of duplicating the formula in JavaScript. + /// + [HttpGet] + public IActionResult DeriveBlastRate(decimal cfm, int nozzle, int setupType, int substrate, decimal? rateOverride) + { + var setup = new CompanyBlastSetup + { + CompressorCfm = cfm, + BlastNozzleSize = nozzle, + SetupType = (BlastSetupType)setupType, + PrimarySubstrate = (BlastSubstrateType)substrate, + BlastRateSqFtPerHourOverride = rateOverride > 0 ? rateOverride : null + }; + var rate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup); + return Json(new { rate }); + } + /// Returns all active blast setups for the current company with their derived rates. [HttpGet] public async Task GetBlastSetups() diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 82f60a1..7929231 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -3435,13 +3435,21 @@ public class QuotesController : Controller // Build company AI context: profile text + recent accepted predictions as few-shot examples var aiContext = await BuildCompanyAiContextAsync(companyId, costs); - // Load the specific blast setup when the user picked one before analyzing + // Load the specific blast setup when the user picked one before analyzing. + // If none was explicitly chosen, fall back to the company's default blast setup so + // named-setup rates (e.g. a blast cabinet configured at 82 sqft/hr) are always + // used instead of the coarser company-level operating cost fallback. CompanyBlastSetup? selectedBlastSetup = null; if (request.BlastSetupId.HasValue) { var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId); selectedBlastSetup = setups.FirstOrDefault(); } + else + { + var defaultSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsDefault && b.IsActive && b.CompanyId == companyId); + selectedBlastSetup = defaultSetups.FirstOrDefault(); + } var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup); await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length)); diff --git a/src/PowderCoating.Web/wwwroot/js/company-settings-lookups.js b/src/PowderCoating.Web/wwwroot/js/company-settings-lookups.js index 054c735..ff6eb38 100644 --- a/src/PowderCoating.Web/wwwroot/js/company-settings-lookups.js +++ b/src/PowderCoating.Web/wwwroot/js/company-settings-lookups.js @@ -1094,30 +1094,31 @@ 3: 'Powder Coat' }; - // Nozzle multipliers matching ShopCapabilityCalculator - const blastNozzleMultipliers = [0, 0, 0.35, 0.55, 0.75, 1.00, 1.30, 1.65, 2.00]; - const blastSetupModalTypeMultipliers = { 0: 0.55, 1: 0.70, 2: 1.00, 3: 0.45 }; - const blastSubstrateMultipliers = { 0: 1.00, 1: 0.80, 2: 1.40, 3: 0.90 }; + // No client-side blast-rate formula here — ShopCapabilityCalculator.cs is the single + // source of truth. The table uses derivedRate from the server response; the modal + // live-preview calls /CompanySettings/DeriveBlastRate instead. - function baseByCfm(cfm) { - if (cfm <= 0) return 0; - if (cfm <= 5) return 15; - if (cfm <= 10) return 30; - if (cfm <= 15) return 50; - if (cfm <= 25) return 80; - if (cfm <= 40) return 130; - if (cfm <= 60) return 200; - return 300; - } + let _deriveRateTimer = null; - function deriveBlastRate(cfm, nozzle, setupType, substrate, override) { - if (override && parseFloat(override) > 0) return parseFloat(override); - const base = baseByCfm(parseFloat(cfm) || 0); - if (base === 0) return 0; - const nm = blastNozzleMultipliers[parseInt(nozzle)] || 1.00; - const sm = blastSetupModalTypeMultipliers[parseInt(setupType)] || 1.00; - const bm = blastSubstrateMultipliers[parseInt(substrate)] || 1.00; - return Math.round(base * nm * sm * bm * 10) / 10; + function updateBlastSetupDerivedRate() { + clearTimeout(_deriveRateTimer); + _deriveRateTimer = setTimeout(function () { + const cfm = document.getElementById('blastSetupCfm').value; + const nozzle = document.getElementById('blastSetupNozzleSize').value; + const setupType = document.getElementById('blastSetupModalType').value; + const substrate = document.getElementById('blastSetupSubstrate').value; + const override = document.getElementById('blastSetupOverride').value; + const el = document.getElementById('blastSetupDerivedRate'); + if (!el) return; + + const params = new URLSearchParams({ cfm, nozzle, setupType, substrate }); + if (override && parseFloat(override) > 0) params.set('rateOverride', override); + + fetch('/CompanySettings/DeriveBlastRate?' + params) + .then(r => r.json()) + .then(data => { el.textContent = data.rate > 0 ? data.rate + ' sqft/hr' : '—'; }) + .catch(() => { el.textContent = '—'; }); + }, 250); } window.loadBlastSetups = function () { @@ -1150,7 +1151,7 @@ window.blastSetups.forEach(function (setup) { const rate = setup.blastRateSqFtPerHourOverride > 0 ? setup.blastRateSqFtPerHourOverride + ' sqft/hr Override' - : deriveBlastRate(setup.compressorCfm, setup.blastNozzleSize, setup.setupType, setup.primarySubstrate, 0) + ' sqft/hr'; + : (setup.derivedRate > 0 ? setup.derivedRate + ' sqft/hr' : ''); const defaultBadge = setup.isDefault ? ' Default' @@ -1179,20 +1180,6 @@ }); } - function updateBlastSetupDerivedRate() { - const cfm = document.getElementById('blastSetupCfm').value; - const nozzle = document.getElementById('blastSetupNozzleSize').value; - const setupType = document.getElementById('blastSetupModalType').value; - const substrate = document.getElementById('blastSetupSubstrate').value; - const override = document.getElementById('blastSetupOverride').value; - - const rate = deriveBlastRate(cfm, nozzle, setupType, substrate, override); - const el = document.getElementById('blastSetupDerivedRate'); - if (el) { - el.textContent = rate > 0 ? rate + ' sqft/hr' : '—'; - } - } - window.showBlastSetupModal = function (setupId = null) { const modal = new bootstrap.Modal(document.getElementById('blastSetupModal')); const form = document.getElementById('blastSetupForm'); diff --git a/src/PowderCoating.Web/wwwroot/js/item-wizard.js b/src/PowderCoating.Web/wwwroot/js/item-wizard.js index bb7d3f5..d98b8ed 100644 --- a/src/PowderCoating.Web/wwwroot/js/item-wizard.js +++ b/src/PowderCoating.Web/wwwroot/js/item-wizard.js @@ -1510,8 +1510,11 @@ async function aiAnalyze() { document.getElementById('ai_resultsSection')?.classList.add('d-none'); document.getElementById('ai_errorAlert')?.classList.add('d-none'); - const blastSetupIdEl = document.getElementById('ai_blastSetupId'); - const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null; + const blastSetupIdEl = document.getElementById('ai_blastSetupId'); + const _defaultSetup = blastSetupData.find(s => s.isDefault) || blastSetupData[0]; + const blastSetupId = blastSetupIdEl + ? (parseInt(blastSetupIdEl.value) || null) + : (_defaultSetup ? _defaultSetup.id : null); const payload = { photoTempIds: wz.ai.tempIds, @@ -1590,8 +1593,11 @@ async function aiSendFollowup() { const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw; wz.data.quantity = qty; // persist before renderStep re-renders - const blastSetupIdEl2 = document.getElementById('ai_blastSetupId'); - const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null; + const blastSetupIdEl2 = document.getElementById('ai_blastSetupId'); + const _defaultSetup2 = blastSetupData.find(s => s.isDefault) || blastSetupData[0]; + const blastSetupId2 = blastSetupIdEl2 + ? (parseInt(blastSetupIdEl2.value) || null) + : (_defaultSetup2 ? _defaultSetup2.id : null); const payload = { photoTempIds: wz.ai.tempIds,