50b1794799
- PWA: manifest.json + minimal service worker so iOS/Android persist camera permission after "Add to Home Screen"; theme-color and apple meta tags in layout - PWA icons: 192x192 and 512x512 from transparent PCL logo; updated pcl-logo.png - AI pricing: apply AdditionalCoatLaborPercent per extra coat on AI items, matching the calculated-item path (was ignoring extra coats entirely) - AI wizard: live price recalc when coats are added/removed; session-expiry errors now show a clear "refresh and sign in" message instead of raw HTTP status; smooth-scroll to follow-up/results sections on AI response - Catalog lookup: exclude SKUs already in company inventory from results; pass currentId on edit so own entry still appears; vendor-scoped search with cross-vendor fallback; result count shown in multi-match modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
589 lines
30 KiB
C#
589 lines
30 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
|
||
};
|
||
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
|
||
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 (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";
|
||
}
|
||
|
||
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
|
||
|
||
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}
|
||
|
||
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
|
||
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 total batch minutes, so divide by quantity to get per-item minutes.
|
||
// The unit price × quantity must equal the total batch labor cost.
|
||
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
|
||
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 = (int)Math.Round(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);
|
||
}
|
||
}
|