Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/AiQuoteService.cs
T
spouliot 31d305b66a Group platform admin tools into hub pages
- add grouped platform admin hub pages, view models, and shared card UI\n- simplify the super admin nav and dashboard quick links around the new hubs\n- fix the AiQuoteService EstimatedMinutes assignment so the infrastructure project builds cleanly
2026-05-12 09:03:18 -04:00

642 lines
33 KiB
C#

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.Application.Services;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public class AiQuoteService : IAiQuoteService
{
private readonly IConfiguration _config;
private readonly ILogger<AiQuoteService> _logger;
/// <summary>
/// The core system prompt that defines Claude's role as a powder coating estimator.
/// It specifies the exact JSON schema the model must return, complexity definitions,
/// surface-area estimation rules, and material-specific active-labor guidance for cast
/// iron, cast aluminum, galvanized steel, and heavy steel. The prompt deliberately
/// excludes oven dwell time from <c>estimatedMinutes</c> because cure time is added at
/// the quote level to avoid double-charging when multiple items share the same oven cycle.
/// Material-specific preheat/outgassing handling is separated into <c>requiresPreheat</c>
/// and <c>preheatMinutes</c> fields so the pricing engine can charge a distinct extra oven
/// cost only for materials that truly need it. The tags list is a closed enumeration to
/// prevent free-text values that would break tag-based filtering in future reporting.
/// </summary>
private const string BaseSystemPrompt = @"You are an expert powder coating estimator. Your job is to analyze photos of items to be powder coated and provide accurate estimates.
When analyzing images, you must respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation, just the JSON:
{
""description"": ""string - concise item name (e.g., 'Steel bracket', 'Aluminum wheel rim', '5-piece railing section')"",
""surfaceAreaSqFt"": number - estimated total surface area in square feet per single item (use the reference dimension provided),
""complexity"": ""Simple"" | ""Moderate"" | ""Complex"" | ""Extreme"",
""estimatedMinutes"": number - estimated ACTIVE LABOR time in minutes per single item covering: sandblasting/media blasting, chemical stripping (if needed), masking, racking/hanging, powder application, unracking, and inspection. DO NOT include oven cure/dwell time — that is priced separately. Only count minutes where a worker is actively doing something.
""requiresPreheat"": boolean - true if this material needs a dedicated outgassing or preheat oven cycle BEFORE coating (cast iron, cast aluminum, galvanized steel, wrought iron). False for standard steel, aluminum sheet/extrusion, stainless.
""preheatMinutes"": number - duration of the preheat/outgassing oven cycle in minutes. Set to 0 if requiresPreheat is false. Typical values: cast iron 45-60, cast aluminum 30-45, galvanized 30-45.
""confidence"": ""Low"" | ""Medium"" | ""High"",
""needsFollowUp"": boolean,
""followUpQuestion"": ""string or null"",
""reasoning"": ""string - brief explanation of your estimate"",
""tags"": [""string""] - 1 to 3 tags from ONLY this fixed list: automotive, railing, furniture, structural, flat-panel, tubular, ornamental, architectural, industrial, agricultural, equipment, enclosure, signage, bracket, gate, wheel, frame, sheet-metal
}
Complexity guide (affects both prep difficulty and coating time):
- Simple: flat panels, basic shapes, easy access to all surfaces, minimal masking needed
- Moderate: moderate curves, some recessed areas, standard brackets, light masking
- Complex: intricate geometry, deep recesses, welded assemblies, many surfaces, significant masking or stripping needed
- Extreme: highly ornate, very deep cavities, extreme surface variation, heavy prep and masking required
Surface area estimation:
- Use the reference dimension provided to calibrate your estimate
- Account for ALL surfaces that need coating (front, back, sides, edges)
- For hollow items (tubes, pipes), include interior only if specified
- Express as sq ft per single item
MATERIAL-SPECIFIC CONSIDERATIONS — these directly affect estimatedMinutes and complexity:
Cast Iron (active labor only — exclude oven dwell):
- Outgassing preheat setup: worker loads piece into oven, waits for outgassing, unloads — count ~10 min active handling, NOT the full oven cycle
- Porous surface needs aggressive sandblasting/media blasting — add 15-30 min depending on size
- Heavy pieces (30+ lbs) require two-person handling for racking/unracking — add 10-15 min vs lighter items
- Minimum complexity for cast iron is Moderate; anything with relief detail, fins, or recesses is Complex or Extreme
- Typical active labor for cast iron: 35-70 min depending on size and detail
Heavy Steel (30+ lbs, active labor only):
- Two-person handling for racking/unracking — add 10-15 min vs lighter items
- Heavier blasting/prep required for thick sections
Cast Aluminum (active labor only):
- Outgassing setup similar to cast iron — add 10 min active handling
- Chemical conversion coat or etch primer often needed — add 15-20 min if applicable
Galvanized Steel (active labor only):
- Must be chemically stripped or heavily blasted to ensure adhesion — add 20-30 min prep
If the user mentions the item is heavy, large, cast iron, or galvanized, ensure estimatedMinutes reflects the extra active prep and handling time for that material.
If you need clarification to give a good estimate, set needsFollowUp=true and provide one specific, answerable question in followUpQuestion.
Only ask follow-up questions if truly needed — prefer to make reasonable assumptions and note them in reasoning.";
/// <summary>
/// Builds the per-request system prompt by appending company-specific context to
/// <see cref="BaseSystemPrompt"/>. If no context is provided, or if both the profile
/// text and accepted-example list are empty, the base prompt is returned as-is to avoid
/// unnecessary string allocation. When context is present, the company's
/// <c>AiContextProfile</c> free-text block (from the AI Profile tab in Company Settings)
/// is appended first, followed by a few-shot calibration section listing previously
/// accepted quote items with their actual surface area, complexity, minutes, and unit
/// price. This two-layer approach lets shop owners nudge the AI toward their shop's
/// real-world pricing without modifying the core prompt — the profile text captures
/// qualitative rules ("we always charge a minimum of 20 min for racking") while the
/// few-shot examples capture quantitative calibration from accepted quotes.
/// </summary>
private static string BuildSystemPrompt(CompanyAiContext? context)
{
if (context == null ||
(string.IsNullOrWhiteSpace(context.ProfileText) && context.AcceptedExamples.Count == 0))
return BaseSystemPrompt;
var sb = new StringBuilder(BaseSystemPrompt);
if (!string.IsNullOrWhiteSpace(context.ProfileText))
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("COMPANY-SPECIFIC CONTEXT — use this to calibrate your estimates for this particular shop:");
sb.AppendLine(context.ProfileText.Trim());
}
if (context.AcceptedExamples.Count > 0)
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("CALIBRATION EXAMPLES — items this company has previously priced and accepted:");
sb.AppendLine("Use these as reference points to calibrate your surface area, complexity, time, and pricing estimates.");
foreach (var ex in context.AcceptedExamples)
{
var tags = string.IsNullOrWhiteSpace(ex.Tags) ? "" : $" [{ex.Tags}]";
sb.AppendLine($"- {ex.Description}{tags}: {ex.SurfaceAreaSqFt:F1} sqft, {ex.Complexity}, {ex.EstimatedMinutes} min, priced at ${ex.FinalUnitPrice:F2}/unit");
}
}
return sb.ToString();
}
/// <summary>
/// Initializes a new instance of <see cref="AiQuoteService"/>. The Anthropic API key is
/// read per call (not cached at construction time) so that configuration updates take
/// effect without restarting the application.
/// </summary>
public AiQuoteService(IConfiguration config, ILogger<AiQuoteService> logger)
{
_config = config;
_logger = logger;
}
/// <summary>
/// Analyzes one or more photos of an item to be powder coated and returns an estimated
/// surface area, complexity, active labor minutes, pricing breakdown, and optional
/// follow-up question. Supports up to two conversation rounds: on the first call the
/// photos are embedded as base64 image content blocks alongside the user prompt; on
/// subsequent calls the prior assistant and user turns are replayed from
/// <see cref="AiAnalyzeItemRequest.ConversationHistory"/> so Claude retains context
/// without the photos being re-uploaded. The round cap of 2 is enforced client-side
/// by forcing <c>NeedsFollowUp = false</c> on round 2 regardless of what Claude returns,
/// preventing an infinite loop if the model keeps asking questions. Pricing is calculated
/// locally in <see cref="CalculatePricingPreview"/> using the company's
/// <see cref="CompanyOperatingCosts"/> rather than asking Claude to price the item,
/// ensuring the estimate is always consistent with the company's configured rates.
/// </summary>
public async Task<AiAnalyzeItemResult> AnalyzeItemAsync(
AiAnalyzeItemRequest request,
List<(byte[] Data, string ContentType, string FileName)> photos,
CompanyOperatingCosts costs,
decimal avgPowderCostPerLb,
CompanyAiContext? context = null,
CompanyBlastSetup? selectedBlastSetup = null)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
};
}
try
{
var client = new AnthropicClient(apiKey);
// Build the conversation messages
var messages = new List<Message>();
// Add prior conversation history for follow-up rounds
if (request.ConversationHistory?.Count > 0)
{
foreach (var turn in request.ConversationHistory)
{
messages.Add(new Message
{
Role = turn.Role == "assistant" ? RoleType.Assistant : RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = turn.Content } }
});
}
// Add follow-up answer as latest user message
if (!string.IsNullOrWhiteSpace(request.FollowUpAnswer))
{
messages.Add(new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
new TextContent
{
Text = $"Answer to your question: {request.FollowUpAnswer}\n\nPlease now provide your final estimate as JSON."
}
}
});
}
}
else
{
// First call — build the initial user message with photos + context
var contentParts = new List<ContentBase>();
// Attach each photo as base64 image
foreach (var (data, contentType, fileName) in photos)
{
var mediaType = contentType.ToLowerInvariant() switch
{
"image/jpeg" => "image/jpeg",
"image/jpg" => "image/jpeg",
"image/png" => "image/png",
"image/gif" => "image/gif",
"image/webp" => "image/webp",
_ => "image/jpeg"
};
contentParts.Add(new ImageContent
{
Source = new ImageSource
{
MediaType = mediaType,
Data = Convert.ToBase64String(data)
}
});
}
var userText = BuildUserPrompt(request, costs, avgPowderCostPerLb, selectedBlastSetup);
contentParts.Add(new TextContent { Text = userText });
messages.Add(new Message
{
Role = RoleType.User,
Content = contentParts
});
}
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
// Low temperature for deterministic estimation — the prompt already constrains
// the model with exact blast/coating rates, so creativity adds noise, not value.
Temperature = 0.2m,
SystemMessage = BuildSystemPrompt(context),
Messages = messages
};
// On overloaded_error (HTTP 529): retry Sonnet once after a short delay, then
// fall back to Haiku (separate capacity pool). If Haiku is also overloaded, give up.
// Total worst-case added latency before fallback: ~5s.
MessageResponse response;
var modelsToTry = new[] { "claude-sonnet-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001" };
HttpRequestException? lastOverloadEx = null;
response = null!;
for (int attempt = 0; attempt < modelsToTry.Length; attempt++)
{
messageRequest.Model = modelsToTry[attempt];
if (attempt > 0)
{
var delay = attempt == 1 ? TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(3);
_logger.LogWarning("Claude API overloaded on {Model} (attempt {Attempt}); retrying with {NextModel} in {Delay}s",
modelsToTry[attempt - 1], attempt, modelsToTry[attempt], delay.TotalSeconds);
await Task.Delay(delay);
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
try
{
response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
lastOverloadEx = null;
break;
}
catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error"))
{
lastOverloadEx = hex;
}
}
if (lastOverloadEx != null)
{
_logger.LogWarning(lastOverloadEx, "Claude API overloaded on all models including fallback");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again."
};
}
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
_logger.LogInformation("Claude AI response for quote analysis: {Response}", rawText.Length > 500 ? rawText[..500] : rawText);
// Parse JSON response
var claudeResult = ParseClaudeResponse(rawText);
if (claudeResult == null)
{
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "AI returned an unexpected response format. Please try again."
};
}
// Determine follow-up round
var currentRound = request.ConversationHistory?.Count > 0 ? 2 : 1;
// Build updated conversation history
var newHistory = new List<AiConversationTurn>(request.ConversationHistory ?? new List<AiConversationTurn>());
if (request.FollowUpAnswer != null)
newHistory.Add(new AiConversationTurn { Role = "user", Content = $"Answer: {request.FollowUpAnswer}" });
newHistory.Add(new AiConversationTurn { Role = "assistant", Content = rawText });
// If AI wants a follow-up but we've already done 2 rounds, force completion
var needsFollowUp = claudeResult.NeedsFollowUp && currentRound < 2;
if (needsFollowUp)
{
return new AiAnalyzeItemResult
{
Success = true,
NeedsFollowUp = true,
FollowUpQuestion = claudeResult.FollowUpQuestion,
FollowUpRound = currentRound,
ConversationHistory = newHistory
};
}
// Calculate pricing preview using operating costs
var (unitPrice, total, breakdown) = CalculatePricingPreview(claudeResult, request, costs, avgPowderCostPerLb);
return new AiAnalyzeItemResult
{
Success = true,
NeedsFollowUp = false,
Description = claudeResult.Description,
SurfaceAreaSqFt = claudeResult.SurfaceAreaSqFt,
Complexity = claudeResult.Complexity,
EstimatedMinutes = claudeResult.EstimatedMinutes,
AiReasoning = claudeResult.Reasoning,
Confidence = claudeResult.Confidence,
EstimatedUnitPrice = unitPrice,
EstimatedTotal = total,
PowderCostPerLb = avgPowderCostPerLb,
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
ConversationHistory = newHistory,
Tags = claudeResult.Tags,
Breakdown = breakdown
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI request timed out after 60 seconds (quote analysis)");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service did not respond in time. Please try again."
};
}
catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error"))
{
_logger.LogWarning(hex, "Claude API overloaded (outer catch — unexpected path)");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again."
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Claude AI for quote analysis");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service encountered an error. Please try again."
};
}
}
/// <summary>
/// Builds the initial user message that accompanies the photo content blocks on the first
/// analysis round. It injects the reference dimension, material type, estimated weight,
/// quantity, desired finish, and coat count so Claude can calibrate surface area and active
/// labor time without asking follow-up questions for common inputs. The company's labor
/// rate and complexity multipliers are included so Claude can ground its reasoning in real
/// shop costs, even though final pricing is computed locally by
/// <see cref="CalculatePricingPreview"/>. The weight line is conditionally included only
/// when a weight is provided, because mentioning "not provided" for weight prompts Claude
/// to ask about it, which wastes a follow-up round for items where weight is irrelevant.
/// </summary>
private static string BuildUserPrompt(AiAnalyzeItemRequest request, CompanyOperatingCosts costs, decimal avgPowderCostPerLb, CompanyBlastSetup? selectedBlastSetup = null)
{
var materialLine = string.IsNullOrWhiteSpace(request.MaterialType)
? "- Material type: Unknown (infer from photo if possible)"
: $"- Material type: {request.MaterialType}";
var weightLine = request.EstimatedWeightLbs.HasValue && request.EstimatedWeightLbs > 0
? $"- Estimated weight: {request.EstimatedWeightLbs:F0} lbs per piece — factor in heavy-handling time and extended heat-soak/cure accordingly"
: "- Estimated weight: Not provided";
// Use the explicitly selected blast setup when provided; fall back to company-level costs.
decimal blastRate;
string blastSetupLabel;
if (selectedBlastSetup != null)
{
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(selectedBlastSetup);
blastSetupLabel = $" ({selectedBlastSetup.Name})";
}
else
{
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
blastSetupLabel = string.Empty;
}
var coatingRate = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(costs);
// Build a shop-speed context line only when the shop has calibrated their equipment.
// An uncalibrated shop (CFM = 0, no override) gets a generic instruction so the AI
// falls back to industry-average times rather than anchoring on a misleading zero.
string shopSpeedLine;
if (blastRate > 0)
{
shopSpeedLine = $"- THIS SHOP'S blast rate{blastSetupLabel}: ~{blastRate:F0} sqft/hr — use this to derive sandblasting time (surface area ÷ blast rate), NOT generic industry averages";
}
else
{
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
}
string coatingSpeedLine;
if (coatingRate > 0)
coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr — use this to derive coating time (surface area ÷ coating rate), NOT generic industry averages";
else
coatingSpeedLine = "- Shop coating rate: not calibrated — use conservative industry-average coating times for this shop tier";
var rateInstruction = (blastRate > 0 || coatingRate > 0)
? "IMPORTANT: For estimatedMinutes, you MUST use this shop's specific rates above where provided, not generic industry speeds."
: "IMPORTANT: For estimatedMinutes, use conservative industry-average times appropriate for a professional powder coating shop.";
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
User-provided context:
- Reference dimension: {request.ReferenceDimension}
{materialLine}
{weightLine}
- Quantity to be coated: {request.Quantity} piece(s)
- Desired color/finish: {request.DesiredColor}
- Number of coating stages: {request.CoatCount}
Company operating costs for your reference:
- Labor rate: ${costs.StandardLaborRate}/hr
- Complexity multipliers — Simple: +{costs.ComplexitySimplePercent}%, Moderate: +{costs.ComplexityModeratePercent}%, Complex: +{costs.ComplexityComplexPercent}%, Extreme: +{costs.ComplexityExtremePercent}%
{shopSpeedLine}
{coatingSpeedLine}
{rateInstruction}
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
Do NOT include oven cure/dwell — that is priced separately.
Estimate the surface area for ONE item. I will multiply by quantity later.
Respond with the JSON object only.";
}
/// <summary>
/// Parses Claude's raw text response into a <see cref="ClaudeQuoteResponse"/> by stripping
/// any surrounding markdown code fences and deserializing the JSON payload. Returns null
/// (rather than throwing) when parsing fails so that <see cref="AnalyzeItemAsync"/> can
/// return a structured error result instead of an unhandled exception. The fence-stripping
/// logic searches for the first <c>{</c> and last <c>}</c> to handle cases where Claude
/// includes a language tag on the fence (e.g., <c>```json</c>).
/// </summary>
private static ClaudeQuoteResponse? ParseClaudeResponse(string rawText)
{
try
{
// Strip any markdown code fences
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)];
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<ClaudeQuoteResponse>(json, options);
}
catch
{
return null;
}
}
/// <summary>
/// Translates Claude's estimated surface area, complexity, and active-labor minutes into
/// a concrete unit price using the company's configured operating costs. Key design decisions:
///
/// <list type="bullet">
/// <item>Powder consumption uses fixed defaults (30 sq ft/lb coverage, 65% transfer
/// efficiency) because most shops have not entered per-powder efficiency values, and the
/// defaults represent industry averages for electrostatic spray application.</item>
/// <item>Material minimum-minute floors are applied after dividing Claude's total batch
/// minutes by quantity. This prevents the AI from returning, for example, 20 total minutes
/// for a batch of 5 cast-iron pieces when each piece realistically requires at least 90
/// active minutes of preheat setup, blasting, and handling.</item>
/// <item>The standard oven cure cycle is not embedded in the unit price because it is
/// priced at the quote level to avoid double-charging when multiple items share the same
/// oven batch. <c>OvenCost</c> is therefore always 0 in the breakdown returned here.</item>
/// <item>Preheat/outgassing cost is the exception: it is an EXTRA oven cycle unique to
/// this item (cast iron, cast aluminum, galvanized), so it IS included in the unit price.</item>
/// <item>General markup applies only to material costs (powder + consumables), not to
/// labor or oven rates, because labor and oven rates are already set as billable rates.</item>
/// <item>Complexity multiplier is applied to the entire pre-complexity subtotal
/// (materials + labor + preheat) rather than just materials, reflecting the real-world
/// cost of complex work: more masking material, higher risk of rework, slower throughput.</item>
/// <item>Only computed dollar amounts are included in the breakdown — rates and percentages
/// are omitted to avoid exposing proprietary pricing configuration to the client.</item>
/// </list>
/// </summary>
private static (decimal UnitPrice, decimal Total, AiPricingBreakdown Breakdown) CalculatePricingPreview(
ClaudeQuoteResponse aiResult,
AiAnalyzeItemRequest request,
CompanyOperatingCosts costs,
decimal avgPowderCostPerLb)
{
// Material cost: powder per coat
const decimal defaultCoverage = 30m; // sq ft/lb
const decimal defaultEfficiency = 0.65m; // 65%
var lbsPerCoat = aiResult.SurfaceAreaSqFt > 0
? aiResult.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency)
: 0m;
var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb;
var consumablesSurcharge = materialCost * 0.05m;
// Material-type minimum time floors — safety net for AI underestimates.
// These reflect the bare minimum realistic time for each material regardless of what the AI returns.
// Active-labor-only floors (oven dwell excluded from estimatedMinutes per prompt instructions).
var materialMinMinutes = (request.MaterialType ?? "").ToLowerInvariant() switch
{
var m when m.Contains("cast iron") => 90,
var m when m.Contains("cast aluminum") => 70,
var m when m.Contains("galvanized") => 50,
var m when m.Contains("heavy steel") => 40,
var m when m.Contains("wrought iron") => 75,
_ => 0
};
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
// Unit price is per item; the caller multiplies by quantity for the line total.
var rawPerItemMinutes = aiResult.EstimatedMinutes;
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
var laborHours = perItemMinutes / 60m;
var laborCost = laborHours * costs.StandardLaborRate;
// Standard cure cycle is handled at the quote level — not embedded here to avoid double-charging.
var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45;
// Material-specific preheat/outgassing cycle — this is an EXTRA oven cycle beyond the standard cure,
// charged at the oven operating rate (not labor rate) directly to this item.
var preheatCost = 0m;
var preheatMinutes = 0;
if (aiResult.RequiresPreheat && aiResult.PreheatMinutes > 0)
{
preheatMinutes = aiResult.PreheatMinutes;
preheatCost = (preheatMinutes / 60m) * costs.OvenOperatingCostPerHour;
}
// Markup applies to materials only — labor and oven rates are already set as billable rates.
var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m);
var subtotalBeforeComplexity = materialWithMarkup + laborCost + preheatCost;
// Complexity multiplier
var complexityPct = aiResult.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;
// Additional coat charge — each coat beyond the first adds AdditionalCoatLaborPercent % of
// the subtotal, matching the formula in PricingCalculationService.CalculateQuoteItemPriceAsync.
// The coat count here comes from the wizard's step-2 field (aiCoatCount), so the preview
// reflects whatever multi-coat configuration the user specified before clicking Analyze.
if (request.CoatCount > 1)
{
var additionalCoatCharge = subtotal * (request.CoatCount - 1) * (costs.AdditionalCoatLaborPercent / 100m);
subtotal += additionalCoatCharge;
}
var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
// Apply shop minimum
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 breakdown = new AiPricingBreakdown
{
SurfaceAreaSqFt = Math.Round(aiResult.SurfaceAreaSqFt, 2),
PowderLbsPerCoat = Math.Round(lbsPerCoat, 3),
CoatCount = request.CoatCount,
MaterialCost = Math.Round(materialCost, 2),
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
EstimatedMinutes = perItemMinutes,
MaterialMinMinutes = materialMinMinutes,
MinFloorApplied = minFloorApplied,
LaborCost = Math.Round(laborCost, 2),
OvenCycleMinutes = ovenCycleMinutes,
OvenCost = 0m,
RequiresPreheat = aiResult.RequiresPreheat,
PreheatMinutes = preheatMinutes,
PreheatCost = Math.Round(preheatCost, 2),
SubtotalBeforeComplexity = Math.Round(subtotalBeforeComplexity, 2),
Complexity = aiResult.Complexity,
ComplexityCharge = Math.Round(complexityCharge, 2),
SubtotalBeforeMarkup = Math.Round(subtotalBeforeComplexity, 2),
MarkupAmount = Math.Round(markupAmount, 2),
UnitPrice = unitPrice,
};
return (unitPrice, total, breakdown);
}
}