Fix AI quote blast rate: single formula path, correct client preview

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:57:46 -04:00
parent 0d5553f3b2
commit ad986561c9
4 changed files with 63 additions and 42 deletions
@@ -1726,6 +1726,26 @@ public class CompanySettingsController : Controller
#region Blast Setups #region Blast Setups
/// <summary>
/// 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.
/// </summary>
[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 });
}
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary> /// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetBlastSetups() public async Task<IActionResult> GetBlastSetups()
@@ -3435,13 +3435,21 @@ public class QuotesController : Controller
// Build company AI context: profile text + recent accepted predictions as few-shot examples // Build company AI context: profile text + recent accepted predictions as few-shot examples
var aiContext = await BuildCompanyAiContextAsync(companyId, costs); 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; CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue) if (request.BlastSetupId.HasValue)
{ {
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId); var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = setups.FirstOrDefault(); 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); 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)); await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
@@ -1094,30 +1094,31 @@
3: 'Powder Coat' 3: 'Powder Coat'
}; };
// Nozzle multipliers matching ShopCapabilityCalculator // No client-side blast-rate formula here — ShopCapabilityCalculator.cs is the single
const blastNozzleMultipliers = [0, 0, 0.35, 0.55, 0.75, 1.00, 1.30, 1.65, 2.00]; // source of truth. The table uses derivedRate from the server response; the modal
const blastSetupModalTypeMultipliers = { 0: 0.55, 1: 0.70, 2: 1.00, 3: 0.45 }; // live-preview calls /CompanySettings/DeriveBlastRate instead.
const blastSubstrateMultipliers = { 0: 1.00, 1: 0.80, 2: 1.40, 3: 0.90 };
function baseByCfm(cfm) { let _deriveRateTimer = null;
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;
}
function deriveBlastRate(cfm, nozzle, setupType, substrate, override) { function updateBlastSetupDerivedRate() {
if (override && parseFloat(override) > 0) return parseFloat(override); clearTimeout(_deriveRateTimer);
const base = baseByCfm(parseFloat(cfm) || 0); _deriveRateTimer = setTimeout(function () {
if (base === 0) return 0; const cfm = document.getElementById('blastSetupCfm').value;
const nm = blastNozzleMultipliers[parseInt(nozzle)] || 1.00; const nozzle = document.getElementById('blastSetupNozzleSize').value;
const sm = blastSetupModalTypeMultipliers[parseInt(setupType)] || 1.00; const setupType = document.getElementById('blastSetupModalType').value;
const bm = blastSubstrateMultipliers[parseInt(substrate)] || 1.00; const substrate = document.getElementById('blastSetupSubstrate').value;
return Math.round(base * nm * sm * bm * 10) / 10; 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 () { window.loadBlastSetups = function () {
@@ -1150,7 +1151,7 @@
window.blastSetups.forEach(function (setup) { window.blastSetups.forEach(function (setup) {
const rate = setup.blastRateSqFtPerHourOverride > 0 const rate = setup.blastRateSqFtPerHourOverride > 0
? setup.blastRateSqFtPerHourOverride + ' sqft/hr <span class="badge bg-secondary">Override</span>' ? setup.blastRateSqFtPerHourOverride + ' sqft/hr <span class="badge bg-secondary">Override</span>'
: deriveBlastRate(setup.compressorCfm, setup.blastNozzleSize, setup.setupType, setup.primarySubstrate, 0) + ' sqft/hr'; : (setup.derivedRate > 0 ? setup.derivedRate + ' sqft/hr' : '<span class="text-muted">—</span>');
const defaultBadge = setup.isDefault const defaultBadge = setup.isDefault
? ' <span class="badge bg-primary ms-1">Default</span>' ? ' <span class="badge bg-primary ms-1">Default</span>'
@@ -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) { window.showBlastSetupModal = function (setupId = null) {
const modal = new bootstrap.Modal(document.getElementById('blastSetupModal')); const modal = new bootstrap.Modal(document.getElementById('blastSetupModal'));
const form = document.getElementById('blastSetupForm'); const form = document.getElementById('blastSetupForm');
@@ -1510,8 +1510,11 @@ async function aiAnalyze() {
document.getElementById('ai_resultsSection')?.classList.add('d-none'); document.getElementById('ai_resultsSection')?.classList.add('d-none');
document.getElementById('ai_errorAlert')?.classList.add('d-none'); document.getElementById('ai_errorAlert')?.classList.add('d-none');
const blastSetupIdEl = document.getElementById('ai_blastSetupId'); const blastSetupIdEl = document.getElementById('ai_blastSetupId');
const blastSetupId = blastSetupIdEl ? (parseInt(blastSetupIdEl.value) || null) : null; const _defaultSetup = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
const blastSetupId = blastSetupIdEl
? (parseInt(blastSetupIdEl.value) || null)
: (_defaultSetup ? _defaultSetup.id : null);
const payload = { const payload = {
photoTempIds: wz.ai.tempIds, photoTempIds: wz.ai.tempIds,
@@ -1590,8 +1593,11 @@ async function aiSendFollowup() {
const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw; const weightLbs = isNaN(weightLbsRaw) || weightLbsRaw <= 0 ? null : weightLbsRaw;
wz.data.quantity = qty; // persist before renderStep re-renders wz.data.quantity = qty; // persist before renderStep re-renders
const blastSetupIdEl2 = document.getElementById('ai_blastSetupId'); const blastSetupIdEl2 = document.getElementById('ai_blastSetupId');
const blastSetupId2 = blastSetupIdEl2 ? (parseInt(blastSetupIdEl2.value) || null) : null; const _defaultSetup2 = blastSetupData.find(s => s.isDefault) || blastSetupData[0];
const blastSetupId2 = blastSetupIdEl2
? (parseInt(blastSetupIdEl2.value) || null)
: (_defaultSetup2 ? _defaultSetup2.id : null);
const payload = { const payload = {
photoTempIds: wz.ai.tempIds, photoTempIds: wz.ai.tempIds,