Add AI Quick Quote widget and inline customer reassignment

- New AI Quick Quote floating button: staff type a verbal description to
  get an instant price estimate for phone/walk-in customers; detected
  color names are fuzzy-matched against inventory for stock status;
  saves draft quote under a Walk-In / Phone customer with one click
- Inline customer change on Quote Details and Job Details: always-visible
  native select with inline confirmation banner (no TomSelect dependency);
  ChangeCustomer AJAX endpoints on QuotesController and JobsController
- Quote Edit page: customer dropdown is now editable (lock removed)
- Fix AutoMapper missing CatalogCategory -> UpdateCategoryDto mapping
  that caused a crash on the catalog category Edit page
- Help docs and AI knowledge base updated for all three features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:02:03 -04:00
parent fc9ddc6d17
commit 8d94013895
18 changed files with 1611 additions and 37 deletions
@@ -0,0 +1,290 @@
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public class AiQuickQuoteService : IAiQuickQuoteService
{
private readonly IConfiguration _config;
private readonly ILogger<AiQuickQuoteService> _logger;
/// <summary>
/// Lean system prompt focused on verbal/phone descriptions.
/// Intentionally shorter than the photo-analysis prompt — no image guidance needed.
/// The detectedColorNames field lets Claude extract powder names so the server can
/// resolve inventory stock without sending the full inventory to the model.
/// </summary>
private const string SystemPrompt = @"You are an expert powder coating estimator. A customer is describing items over the phone or at the shop counter.
Based on the verbal description, respond ONLY with a valid JSON object — no markdown, no explanation:
{
""description"": ""string - concise item name (e.g., 'Steel bracket set', 'Aluminum wheel rims x4')"",
""surfaceAreaSqFt"": number - estimated surface area per single item in square feet,
""complexity"": ""Simple"" | ""Moderate"" | ""Complex"" | ""Extreme"",
""estimatedMinutes"": number - estimated ACTIVE LABOR time in minutes per item (blasting, masking, application, inspection — NOT oven cure time),
""requiresPreheat"": boolean - true for cast iron, cast aluminum, galvanized steel, wrought iron,
""preheatMinutes"": number - 0 if requiresPreheat is false; typical: cast iron 45-60, cast aluminum 30-45, galvanized 30-45,
""confidence"": ""Low"" | ""Medium"" | ""High"",
""reasoning"": ""string - one sentence explaining key assumptions made"",
""detectedColorNames"": [""string""] - color or powder finish names mentioned by the customer verbatim (e.g., [""Matte Black"", ""Alien Silver""]); empty array if none mentioned
}
Complexity guide:
- Simple: flat panels, basic shapes, minimal masking
- Moderate: moderate curves, some recesses, standard masking
- Complex: intricate geometry, deep recesses, welded assemblies, significant masking
- Extreme: highly ornate, deep cavities, heavy prep and masking required
When estimating from a verbal description:
- Default to steel if material is not stated
- Use common reference sizes (car wheel ~1.5-2.5 sqft, motorcycle frame ~8-12 sqft, door ~20 sqft, railing section ~4-6 sqft)
- Set confidence to ""Low"" when dimensions are missing or vague
- Never ask follow-up questions — return your best estimate with Low confidence instead";
public AiQuickQuoteService(IConfiguration config, ILogger<AiQuickQuoteService> logger)
{
_config = config;
_logger = logger;
}
/// <inheritdoc/>
public async Task<AiQuickQuoteResult> AnalyzeAsync(
AiQuickQuoteRequest request,
CompanyOperatingCosts costs,
decimal avgPowderCostPerLb,
CompanyAiContext? context = null)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new AiQuickQuoteResult
{
Success = false,
ErrorMessage = "Anthropic API key is not configured. Contact your administrator."
};
}
try
{
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 512,
Temperature = 0.2m,
SystemMessage = BuildSystemPrompt(context),
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
new TextContent { Text = BuildUserPrompt(request) }
}
}
}
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
_logger.LogInformation("Claude quick quote response: {Response}",
rawText.Length > 300 ? rawText[..300] : rawText);
var claudeResult = ParseResponse(rawText);
if (claudeResult == null)
{
return new AiQuickQuoteResult
{
Success = false,
ErrorMessage = "AI returned an unexpected response format. Please try again."
};
}
var (unitPrice, total, breakdown) = CalculatePricing(claudeResult, request, costs, avgPowderCostPerLb);
return new AiQuickQuoteResult
{
Success = true,
Description = claudeResult.Description,
SurfaceAreaSqFt = claudeResult.SurfaceAreaSqFt,
Complexity = claudeResult.Complexity,
EstimatedMinutes = claudeResult.EstimatedMinutes,
RequiresPreheat = claudeResult.RequiresPreheat,
PreheatMinutes = claudeResult.PreheatMinutes,
Confidence = claudeResult.Confidence,
Reasoning = claudeResult.Reasoning,
// Controller fills in stock status for each entry after an inventory lookup
PowderMatches = claudeResult.DetectedColorNames
.Select(n => new PowderStockMatch { DetectedColorName = n })
.ToList(),
EstimatedUnitPrice = unitPrice,
EstimatedTotal = total,
Breakdown = breakdown
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Quick quote AI request timed out after 30 s");
return new AiQuickQuoteResult { Success = false, ErrorMessage = "Request timed out. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Quick quote AI analysis failed");
return new AiQuickQuoteResult { Success = false, ErrorMessage = "AI analysis failed. Please try again." };
}
}
private static string BuildSystemPrompt(CompanyAiContext? context)
{
if (context == null ||
(string.IsNullOrWhiteSpace(context.ProfileText) && context.AcceptedExamples.Count == 0))
return SystemPrompt;
var sb = new StringBuilder(SystemPrompt);
if (!string.IsNullOrWhiteSpace(context.ProfileText))
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("COMPANY-SPECIFIC CONTEXT — use this to calibrate estimates for this shop:");
sb.AppendLine(context.ProfileText.Trim());
}
if (context.AcceptedExamples.Count > 0)
{
sb.AppendLine();
sb.AppendLine("CALIBRATION EXAMPLES from this shop's accepted quotes:");
foreach (var ex in context.AcceptedExamples)
sb.AppendLine($"- {ex.Description}: {ex.SurfaceAreaSqFt:F1} sqft, {ex.Complexity}, {ex.EstimatedMinutes} min, ${ex.FinalUnitPrice:F2}/item");
}
return sb.ToString();
}
private static string BuildUserPrompt(AiQuickQuoteRequest request)
{
var sb = new StringBuilder();
sb.AppendLine($"Customer description: {request.Description}");
sb.AppendLine($"Quantity: {request.Quantity}");
sb.AppendLine($"Coat count requested: {request.CoatCount}");
return sb.ToString().TrimEnd();
}
private ClaudeQuickQuoteResponse? ParseResponse(string rawText)
{
try
{
var json = rawText.Trim();
if (json.StartsWith("```"))
{
var start = json.IndexOf('{');
var end = json.LastIndexOf('}');
if (start >= 0 && end > start)
json = json[start..(end + 1)];
}
return JsonSerializer.Deserialize<ClaudeQuickQuoteResponse>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse Claude quick quote JSON: {Raw}", rawText);
return null;
}
}
/// <summary>
/// Server-side pricing using the same math as AiQuoteService.
/// Material-type minimum floors are omitted — verbal descriptions rarely include material,
/// and the confidence returned by the AI already reflects that uncertainty.
/// </summary>
private static (decimal UnitPrice, decimal Total, AiPricingBreakdown Breakdown) CalculatePricing(
ClaudeQuickQuoteResponse ai,
AiQuickQuoteRequest request,
CompanyOperatingCosts costs,
decimal avgPowderCostPerLb)
{
const decimal defaultCoverage = 30m;
const decimal defaultEfficiency = 0.65m;
var lbsPerCoat = ai.SurfaceAreaSqFt > 0
? ai.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency)
: 0m;
var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb;
var consumablesSurcharge = materialCost * 0.05m;
// System prompt asks Claude for per-item minutes — no division by quantity needed.
var perItemMinutes = (decimal)ai.EstimatedMinutes;
var laborCost = (perItemMinutes / 60m) * costs.StandardLaborRate;
var preheatCost = 0m;
var preheatMinutes = 0;
if (ai.RequiresPreheat && ai.PreheatMinutes > 0)
{
preheatMinutes = ai.PreheatMinutes;
preheatCost = (preheatMinutes / 60m) * costs.OvenOperatingCostPerHour;
}
var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m);
var subtotalBeforeComplexity = materialWithMarkup + laborCost + preheatCost;
var complexityPct = ai.Complexity switch
{
"Simple" => costs.ComplexitySimplePercent / 100m,
"Moderate" => costs.ComplexityModeratePercent / 100m,
"Complex" => costs.ComplexityComplexPercent / 100m,
"Extreme" => costs.ComplexityExtremePercent / 100m,
_ => 0m
};
var complexityCharge = subtotalBeforeComplexity * complexityPct;
var subtotal = subtotalBeforeComplexity + complexityCharge;
if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0)
subtotal = costs.ShopMinimumCharge;
var unitPrice = Math.Max(0, Math.Round(subtotal, 2));
var total = unitPrice * request.Quantity;
var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45;
var breakdown = new AiPricingBreakdown
{
SurfaceAreaSqFt = Math.Round(ai.SurfaceAreaSqFt, 2),
PowderLbsPerCoat = Math.Round(lbsPerCoat, 3),
CoatCount = request.CoatCount,
MaterialCost = Math.Round(materialCost, 2),
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
EstimatedMinutes = ai.EstimatedMinutes,
MaterialMinMinutes = 0,
MinFloorApplied = false,
LaborCost = Math.Round(laborCost, 2),
OvenCycleMinutes = ovenCycleMinutes,
OvenCost = 0m,
RequiresPreheat = ai.RequiresPreheat,
PreheatMinutes = preheatMinutes,
PreheatCost = Math.Round(preheatCost, 2),
SubtotalBeforeComplexity = Math.Round(subtotalBeforeComplexity, 2),
Complexity = ai.Complexity,
ComplexityCharge = Math.Round(complexityCharge, 2),
SubtotalBeforeMarkup = Math.Round(subtotalBeforeComplexity, 2),
MarkupAmount = Math.Round(markupAmount, 2),
UnitPrice = unitPrice
};
return (unitPrice, total, breakdown);
}
}