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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user