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
/// <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>
[HttpGet]
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
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));
@@ -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 <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
? ' <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) {
const modal = new bootstrap.Modal(document.getElementById('blastSetupModal'));
const form = document.getElementById('blastSetupForm');
@@ -1511,7 +1511,10 @@ async function aiAnalyze() {
document.getElementById('ai_errorAlert')?.classList.add('d-none');
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 = {
photoTempIds: wz.ai.tempIds,
@@ -1591,7 +1594,10 @@ async function aiSendFollowup() {
wz.data.quantity = qty; // persist before renderStep re-renders
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 = {
photoTempIds: wz.ai.tempIds,