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,